@veraxhq/verax 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +14 -36
  4. package/src/cli/commands/default.js +523 -0
  5. package/src/cli/commands/doctor.js +165 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +402 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +296 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +34 -0
  14. package/src/cli/util/expectation-extractor.js +378 -0
  15. package/src/cli/util/findings-writer.js +31 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +366 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +29 -0
  21. package/src/cli/util/project-discovery.js +277 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/summary-writer.js +32 -0
  26. package/src/verax/cli/ci-summary.js +35 -0
  27. package/src/verax/cli/context-explanation.js +89 -0
  28. package/src/verax/cli/doctor.js +277 -0
  29. package/src/verax/cli/error-normalizer.js +154 -0
  30. package/src/verax/cli/explain-output.js +105 -0
  31. package/src/verax/cli/finding-explainer.js +130 -0
  32. package/src/verax/cli/init.js +237 -0
  33. package/src/verax/cli/run-overview.js +163 -0
  34. package/src/verax/cli/url-safety.js +101 -0
  35. package/src/verax/cli/wizard.js +98 -0
  36. package/src/verax/cli/zero-findings-explainer.js +57 -0
  37. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  38. package/src/verax/core/action-classifier.js +86 -0
  39. package/src/verax/core/budget-engine.js +218 -0
  40. package/src/verax/core/canonical-outcomes.js +157 -0
  41. package/src/verax/core/decision-snapshot.js +335 -0
  42. package/src/verax/core/determinism-model.js +403 -0
  43. package/src/verax/core/incremental-store.js +237 -0
  44. package/src/verax/core/invariants.js +356 -0
  45. package/src/verax/core/promise-model.js +230 -0
  46. package/src/verax/core/replay-validator.js +350 -0
  47. package/src/verax/core/replay.js +222 -0
  48. package/src/verax/core/run-id.js +175 -0
  49. package/src/verax/core/run-manifest.js +99 -0
  50. package/src/verax/core/silence-impact.js +369 -0
  51. package/src/verax/core/silence-model.js +521 -0
  52. package/src/verax/detect/comparison.js +2 -34
  53. package/src/verax/detect/confidence-engine.js +764 -329
  54. package/src/verax/detect/detection-engine.js +293 -0
  55. package/src/verax/detect/evidence-index.js +177 -0
  56. package/src/verax/detect/expectation-model.js +194 -172
  57. package/src/verax/detect/explanation-helpers.js +187 -0
  58. package/src/verax/detect/finding-detector.js +450 -0
  59. package/src/verax/detect/findings-writer.js +44 -8
  60. package/src/verax/detect/flow-detector.js +366 -0
  61. package/src/verax/detect/index.js +172 -286
  62. package/src/verax/detect/interactive-findings.js +613 -0
  63. package/src/verax/detect/signal-mapper.js +308 -0
  64. package/src/verax/detect/verdict-engine.js +563 -0
  65. package/src/verax/evidence-index-writer.js +61 -0
  66. package/src/verax/index.js +90 -14
  67. package/src/verax/intel/effect-detector.js +368 -0
  68. package/src/verax/intel/handler-mapper.js +249 -0
  69. package/src/verax/intel/index.js +281 -0
  70. package/src/verax/intel/route-extractor.js +280 -0
  71. package/src/verax/intel/ts-program.js +256 -0
  72. package/src/verax/intel/vue-navigation-extractor.js +579 -0
  73. package/src/verax/intel/vue-router-extractor.js +323 -0
  74. package/src/verax/learn/action-contract-extractor.js +335 -101
  75. package/src/verax/learn/ast-contract-extractor.js +95 -5
  76. package/src/verax/learn/flow-extractor.js +172 -0
  77. package/src/verax/learn/manifest-writer.js +97 -47
  78. package/src/verax/learn/project-detector.js +40 -0
  79. package/src/verax/learn/route-extractor.js +27 -96
  80. package/src/verax/learn/state-extractor.js +212 -0
  81. package/src/verax/learn/static-extractor-navigation.js +114 -0
  82. package/src/verax/learn/static-extractor-validation.js +88 -0
  83. package/src/verax/learn/static-extractor.js +112 -4
  84. package/src/verax/learn/truth-assessor.js +24 -21
  85. package/src/verax/observe/aria-sensor.js +211 -0
  86. package/src/verax/observe/browser.js +10 -5
  87. package/src/verax/observe/console-sensor.js +1 -17
  88. package/src/verax/observe/domain-boundary.js +10 -1
  89. package/src/verax/observe/expectation-executor.js +512 -0
  90. package/src/verax/observe/flow-matcher.js +143 -0
  91. package/src/verax/observe/focus-sensor.js +196 -0
  92. package/src/verax/observe/human-driver.js +643 -275
  93. package/src/verax/observe/index.js +908 -27
  94. package/src/verax/observe/index.js.backup +1 -0
  95. package/src/verax/observe/interaction-discovery.js +365 -14
  96. package/src/verax/observe/interaction-runner.js +563 -198
  97. package/src/verax/observe/loading-sensor.js +139 -0
  98. package/src/verax/observe/navigation-sensor.js +255 -0
  99. package/src/verax/observe/network-sensor.js +55 -7
  100. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  101. package/src/verax/observe/observed-expectation.js +305 -0
  102. package/src/verax/observe/page-frontier.js +234 -0
  103. package/src/verax/observe/settle.js +37 -17
  104. package/src/verax/observe/state-sensor.js +389 -0
  105. package/src/verax/observe/timing-sensor.js +228 -0
  106. package/src/verax/observe/traces-writer.js +61 -20
  107. package/src/verax/observe/ui-signal-sensor.js +136 -17
  108. package/src/verax/scan-summary-writer.js +77 -15
  109. package/src/verax/shared/artifact-manager.js +110 -8
  110. package/src/verax/shared/budget-profiles.js +136 -0
  111. package/src/verax/shared/ci-detection.js +39 -0
  112. package/src/verax/shared/config-loader.js +170 -0
  113. package/src/verax/shared/dynamic-route-utils.js +218 -0
  114. package/src/verax/shared/expectation-coverage.js +44 -0
  115. package/src/verax/shared/expectation-prover.js +81 -0
  116. package/src/verax/shared/expectation-tracker.js +201 -0
  117. package/src/verax/shared/expectations-writer.js +60 -0
  118. package/src/verax/shared/first-run.js +44 -0
  119. package/src/verax/shared/progress-reporter.js +171 -0
  120. package/src/verax/shared/retry-policy.js +14 -1
  121. package/src/verax/shared/root-artifacts.js +49 -0
  122. package/src/verax/shared/scan-budget.js +86 -0
  123. package/src/verax/shared/url-normalizer.js +162 -0
  124. package/src/verax/shared/zip-artifacts.js +65 -0
  125. package/src/verax/validate/context-validator.js +244 -0
  126. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -2,30 +2,44 @@ import { resolve } from 'path';
2
2
  import { captureScreenshot } from './evidence-capture.js';
3
3
  import { isExternalUrl } from './domain-boundary.js';
4
4
  import { captureDomSignature } from './dom-signature.js';
5
- import { waitForSettle } from './settle.js';
6
5
  import { NetworkSensor } from './network-sensor.js';
7
6
  import { ConsoleSensor } from './console-sensor.js';
8
7
  import { UISignalSensor } from './ui-signal-sensor.js';
9
- import { StateUISensor } from './state-ui-sensor.js';
8
+ import { StateSensor } from './state-sensor.js';
9
+ import { NavigationSensor } from './navigation-sensor.js';
10
+ import { LoadingSensor } from './loading-sensor.js';
11
+ import { FocusSensor } from './focus-sensor.js';
12
+ import { AriaSensor } from './aria-sensor.js';
13
+ import { TimingSensor } from './timing-sensor.js';
14
+ import { HumanBehaviorDriver } from './human-driver.js';
15
+ import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
10
16
 
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) {
17
+ /**
18
+ * SILENCE TRACKING: Mark timeout and record to silence tracker.
19
+ * Timeouts are a form of silence - interaction attempted but outcome unknown.
20
+ */
21
+ function markTimeoutPolicy(trace, phase, silenceTracker = null) {
23
22
  trace.policy = {
24
23
  ...(trace.policy || {}),
25
24
  timeout: true,
26
25
  reason: 'interaction_timeout',
27
26
  phase
28
27
  };
28
+
29
+ // Track timeout as silence if tracker provided
30
+ if (silenceTracker) {
31
+ silenceTracker.record({
32
+ scope: 'interaction',
33
+ reason: phase === 'navigation' ? 'navigation_timeout' : 'interaction_timeout',
34
+ description: `Timeout during ${phase} - outcome unknown`,
35
+ context: {
36
+ interaction: trace.interaction,
37
+ phase,
38
+ url: trace.before?.url
39
+ },
40
+ impact: 'unknown_behavior'
41
+ });
42
+ }
29
43
  }
30
44
 
31
45
  function computeDomChangedDuringSettle(samples) {
@@ -35,7 +49,7 @@ function computeDomChangedDuringSettle(samples) {
35
49
  return samples[0] !== samples[1] || samples[1] !== samples[2];
36
50
  }
37
51
 
38
- async function captureSettledDom(page) {
52
+ async function captureSettledDom(page, scanBudget) {
39
53
  const samples = [];
40
54
 
41
55
  const sampleDom = async () => {
@@ -43,11 +57,22 @@ async function captureSettledDom(page) {
43
57
  samples.push(hash);
44
58
  };
45
59
 
60
+ // Use shorter stabilization for file:// fixtures but preserve async capture (700ms)
61
+ const isFile = (() => {
62
+ try { return (page.url() || '').startsWith('file:'); } catch { return false; }
63
+ })();
64
+ const midDelay = isFile ? 200 : Math.min(300, scanBudget.stabilizationSampleMidMs);
65
+ const endDelay = isFile ? 800 : Math.min(900, scanBudget.stabilizationSampleEndMs);
66
+ const networkDelay = isFile ? 100 : Math.min(400, scanBudget.networkWaitMs);
67
+
46
68
  await sampleDom();
47
- await page.waitForTimeout(STABILIZATION_SAMPLE_MID_MS);
69
+ await page.waitForTimeout(midDelay);
48
70
  await sampleDom();
49
- await page.waitForTimeout(STABILIZATION_SAMPLE_END_MS - STABILIZATION_SAMPLE_MID_MS);
71
+ await page.waitForTimeout(Math.max(0, endDelay - midDelay));
50
72
  await sampleDom();
73
+
74
+ // NETWORK INTELLIGENCE: Wait a bit longer to ensure slow requests complete
75
+ await page.waitForTimeout(networkDelay);
51
76
 
52
77
  const domChangedDuringSettle = computeDomChangedDuringSettle(samples);
53
78
 
@@ -58,12 +83,16 @@ async function captureSettledDom(page) {
58
83
  };
59
84
  }
60
85
 
61
- export async function runInteraction(page, interaction, timestamp, i, screenshotsDir, baseOrigin, startTime, maxDurationMs) {
86
+ export async function runInteraction(page, interaction, timestamp, i, screenshotsDir, baseOrigin, startTime, scanBudget, flowContext = null, silenceTracker = null) {
62
87
  const trace = {
63
88
  interaction: {
64
89
  type: interaction.type,
65
90
  selector: interaction.selector,
66
- label: interaction.label
91
+ label: interaction.label,
92
+ href: interaction.href || null,
93
+ dataHref: interaction.dataHref || null,
94
+ text: interaction.text || null,
95
+ formAction: interaction.formAction || null
67
96
  },
68
97
  before: {
69
98
  url: '',
@@ -72,18 +101,61 @@ export async function runInteraction(page, interaction, timestamp, i, screenshot
72
101
  after: {
73
102
  url: '',
74
103
  screenshot: ''
75
- }
104
+ },
105
+ sensors: {},
106
+ humanDriver: true // Flag indicating human driver was used
76
107
  };
77
108
 
78
- // Declare window IDs outside try block so they're available in catch
109
+ // Add flow context if provided
110
+ if (flowContext) {
111
+ trace.flow = {
112
+ flowId: flowContext.flowId,
113
+ stepIndex: flowContext.stepIndex,
114
+ startedAtInteraction: flowContext.startedAtInteraction,
115
+ startedAt: flowContext.startedAt,
116
+ interactionId: i
117
+ };
118
+ }
119
+ const networkSensor = new NetworkSensor();
120
+ const consoleSensor = new ConsoleSensor();
121
+ const uiSignalSensor = new UISignalSensor();
122
+ const stateSensor = new StateSensor();
123
+ const navigationSensor = new NavigationSensor();
124
+ const loadingSensor = new LoadingSensor({ loadingTimeout: 5000 });
125
+ const focusSensor = new FocusSensor();
126
+ const ariaSensor = new AriaSensor();
127
+ const timingSensor = new TimingSensor({
128
+ feedbackGapThresholdMs: 1500,
129
+ freezeLikeThresholdMs: 3000
130
+ });
131
+ const humanDriver = new HumanBehaviorDriver({}, scanBudget);
132
+
79
133
  let networkWindowId = null;
80
134
  let consoleWindowId = null;
81
- let uiSignalsBefore = null;
82
- let stateUiBefore = null;
135
+ let stateSensorActive = false;
136
+ let loadingWindowData = null;
137
+
138
+ let uiBefore = {};
139
+ let stateBefore = null;
140
+ let sessionStateBefore = null;
83
141
 
84
142
  try {
85
- if (Date.now() - startTime > maxDurationMs) {
143
+ // Capture session state before interaction for auth-aware interactions
144
+ if (interaction.type === 'login' || interaction.type === 'logout') {
145
+ sessionStateBefore = await humanDriver.captureSessionState(page);
146
+ }
147
+
148
+ if (Date.now() - startTime > scanBudget.maxScanDurationMs) {
86
149
  trace.policy = { timeout: true, reason: 'max_scan_duration_exceeded' };
150
+ trace.sensors = {
151
+ network: networkSensor.getEmptySummary(),
152
+ console: consoleSensor.getEmptySummary(),
153
+ uiSignals: {
154
+ before: {},
155
+ after: {},
156
+ diff: { changed: false, explanation: '', summary: {} }
157
+ }
158
+ };
87
159
  return trace;
88
160
  }
89
161
 
@@ -91,75 +163,46 @@ export async function runInteraction(page, interaction, timestamp, i, screenshot
91
163
  const beforeScreenshot = resolve(screenshotsDir, `before-${timestamp}-${i}.png`);
92
164
  await captureScreenshot(page, beforeScreenshot);
93
165
  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
- }
166
+ const beforeTitle = typeof page.title === 'function' ? await page.title().catch(() => null) : (page.title || null);
124
167
 
125
168
  trace.before.url = beforeUrl;
126
169
  trace.before.screenshot = `screenshots/before-${timestamp}-${i}.png`;
127
170
  if (beforeDomHash) {
128
171
  trace.dom = { beforeHash: beforeDomHash };
129
172
  }
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
- }
173
+ if (!trace.page) {
174
+ trace.page = {};
175
+ }
176
+ trace.page.beforeTitle = beforeTitle;
151
177
 
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;
178
+ uiBefore = await uiSignalSensor.snapshot(page).catch(() => ({}));
179
+
180
+ // A11Y INTELLIGENCE: Capture focus and ARIA state before interaction
181
+ await focusSensor.captureBefore(page);
182
+ await ariaSensor.captureBefore(page);
183
+
184
+ // PERFORMANCE INTELLIGENCE: Start timing sensor
185
+ timingSensor.startTiming();
186
+
187
+ // NAVIGATION INTELLIGENCE v2: Inject tracking script and start navigation sensor
188
+ await navigationSensor.injectTrackingScript(page);
189
+ const navigationWindowId = navigationSensor.startWindow(page);
190
+
191
+ // STATE INTELLIGENCE: Detect and activate state sensor if supported stores found
192
+ const stateDetection = await stateSensor.detect(page);
193
+ stateSensorActive = stateDetection.detected;
194
+ if (stateSensorActive) {
195
+ await stateSensor.captureBefore(page);
161
196
  }
162
197
 
198
+ networkWindowId = networkSensor.startWindow(page);
199
+ consoleWindowId = consoleSensor.startWindow(page);
200
+
201
+ // ASYNC INTELLIGENCE: Start loading sensor for async detection
202
+ loadingWindowData = loadingSensor.startWindow(page);
203
+ const loadingWindowId = loadingWindowData.windowId;
204
+ const loadingState = loadingWindowData.state;
205
+
163
206
  if (interaction.isExternal && interaction.type === 'link') {
164
207
  const href = await interaction.element.getAttribute('href');
165
208
  const resolvedUrl = href.startsWith('http') ? href : new URL(href, beforeUrl).href;
@@ -169,17 +212,7 @@ export async function runInteraction(page, interaction, timestamp, i, screenshot
169
212
  blockedUrl: resolvedUrl
170
213
  };
171
214
 
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);
215
+ const { settleResult, afterUrl } = await captureAfterState(page, screenshotsDir, timestamp, i, trace, scanBudget);
183
216
  trace.after.url = afterUrl;
184
217
  trace.after.screenshot = `screenshots/after-${timestamp}-${i}.png`;
185
218
  if (!trace.dom) {
@@ -193,56 +226,286 @@ export async function runInteraction(page, interaction, timestamp, i, screenshot
193
226
  domChangedDuringSettle: settleResult.domChangedDuringSettle
194
227
  };
195
228
 
196
- // Add sensor evidence to trace
229
+ const networkSummary = networkSensor.stopWindow(networkWindowId);
230
+ const consoleSummary = consoleSensor.stopWindow(consoleWindowId, page);
231
+ const uiAfter = await uiSignalSensor.snapshot(page);
232
+ const uiDiff = uiSignalSensor.diff(uiBefore, uiAfter);
233
+
234
+ // STATE INTELLIGENCE: Capture after state and compute diff
235
+ let stateDiff = { changed: [], available: false };
236
+ let storeType = null;
237
+ if (stateSensorActive) {
238
+ await stateSensor.captureAfter(page);
239
+ stateDiff = stateSensor.getDiff();
240
+ storeType = stateSensor.activeType; // Store before cleanup
241
+ stateSensor.cleanup();
242
+ }
243
+
197
244
  trace.sensors = {
198
245
  network: networkSummary,
199
- console: consoleSummary
246
+ console: consoleSummary,
247
+ uiSignals: {
248
+ before: uiBefore,
249
+ after: uiAfter,
250
+ diff: uiDiff
251
+ },
252
+ state: {
253
+ available: stateDiff.available,
254
+ changed: stateDiff.changed,
255
+ storeType: storeType
256
+ }
200
257
  };
201
258
 
202
259
  return trace;
203
260
  }
204
261
 
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' })
262
+ // REAL USER SIMULATION: Use human driver for all interactions
263
+ const locator = interaction.element;
264
+ // On file:// origins, avoid long navigation waits for simple link clicks
265
+ const isFileOrigin = baseOrigin && baseOrigin.startsWith('file:');
266
+ const shouldWaitForNavigation = (interaction.type === 'link' || interaction.type === 'form') && !isFileOrigin;
267
+ let navigationResult = null;
268
+
269
+ try {
270
+ if (shouldWaitForNavigation) {
271
+ navigationResult = page.waitForNavigation({ timeout: scanBudget.navigationTimeoutMs, waitUntil: 'domcontentloaded' })
209
272
  .catch((error) => {
210
273
  if (error && error.name === 'TimeoutError') {
211
- markTimeoutPolicy(trace, 'navigation');
274
+ markTimeoutPolicy(trace, 'navigation', silenceTracker);
212
275
  }
213
276
  return null;
214
- })
215
- : null;
277
+ });
278
+ }
216
279
 
217
- try {
218
- await Promise.race([
219
- clickPromise,
220
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')),
221
- INTERACTION_TIMEOUT_MS))
222
- ]);
280
+ if (interaction.type === 'login') {
281
+ // Login form submission: fill with deterministic credentials and submit
282
+ const loginResult = await humanDriver.executeLogin(page, locator);
283
+ const sessionStateAfter = await humanDriver.captureSessionState(page);
284
+ trace.login = {
285
+ submitted: loginResult.submitted,
286
+ found: loginResult.found !== false,
287
+ redirected: loginResult.redirected,
288
+ url: loginResult.url,
289
+ storageChanged: loginResult.storageChanged,
290
+ cookiesChanged: loginResult.cookiesChanged,
291
+ beforeStorage: loginResult.beforeStorage || [],
292
+ afterStorage: loginResult.afterStorage || []
293
+ };
294
+ trace.session = sessionStateAfter;
295
+ trace.interactionType = 'login';
296
+ shouldWaitForNavigation = loginResult.redirected && !isFileOrigin;
297
+ if (shouldWaitForNavigation && !navigationResult) {
298
+ navigationResult = page.waitForNavigation({ timeout: scanBudget.navigationTimeoutMs, waitUntil: 'domcontentloaded' })
299
+ .catch(() => null);
300
+ }
301
+ } else if (interaction.type === 'logout') {
302
+ // Logout action: click logout and observe session changes
303
+ const logoutResult = await humanDriver.executeLogout(page, locator);
304
+ const sessionStateAfter = await humanDriver.captureSessionState(page);
305
+ trace.logout = {
306
+ clicked: logoutResult.clicked,
307
+ found: logoutResult.found !== false,
308
+ redirected: logoutResult.redirected,
309
+ url: logoutResult.url,
310
+ storageChanged: logoutResult.storageChanged,
311
+ cookiesChanged: logoutResult.cookiesChanged,
312
+ beforeStorage: logoutResult.beforeStorage || [],
313
+ afterStorage: logoutResult.afterStorage || []
314
+ };
315
+ trace.session = sessionStateAfter;
316
+ trace.interactionType = 'logout';
317
+ shouldWaitForNavigation = logoutResult.redirected && !isFileOrigin;
318
+ if (shouldWaitForNavigation && !navigationResult) {
319
+ navigationResult = page.waitForNavigation({ timeout: scanBudget.navigationTimeoutMs, waitUntil: 'domcontentloaded' })
320
+ .catch(() => null);
321
+ }
322
+ } else if (interaction.type === 'form') {
323
+ // Form submission: fill fields first, then submit
324
+ const fillResult = await humanDriver.fillFormFields(page, locator);
325
+ if (fillResult.filled && fillResult.filled.length > 0) {
326
+ trace.humanDriverFilled = fillResult.filled;
327
+ }
328
+ if (fillResult.reason) {
329
+ trace.humanDriverSkipReason = fillResult.reason;
330
+ }
331
+
332
+ // Submit form using human driver
333
+ const submitResult = await humanDriver.submitForm(page, locator);
334
+ trace.humanDriverSubmitted = submitResult.submitted;
335
+ trace.humanDriverAttempts = submitResult.attempts;
336
+ } else if (interaction.type === 'keyboard') {
337
+ // Keyboard navigation: perform full keyboard sweep
338
+ const keyboardResult = await humanDriver.performKeyboardNavigation(page, 12);
339
+ trace.keyboard = {
340
+ focusOrder: keyboardResult.focusOrder,
341
+ actions: keyboardResult.actions,
342
+ attemptedTabs: keyboardResult.attemptedTabs
343
+ };
344
+ trace.interactionType = 'keyboard';
345
+ } else if (interaction.type === 'hover') {
346
+ // Hover interaction: hover and observe DOM changes
347
+ const hoverResult = await humanDriver.hoverAndObserve(page, locator);
348
+
349
+ // Capture DOM before/after for hover
350
+ const beforeDom = await page.evaluate(() => document.body ? document.body.innerHTML.length : 0);
351
+ await page.waitForTimeout(200);
352
+ const afterDom = await page.evaluate(() => document.body ? document.body.innerHTML.length : 0);
353
+
354
+ const visiblePopups = await page.evaluate(() => {
355
+ const popups = Array.from(document.querySelectorAll('[role="menu"], [role="dialog"], .dropdown, .popup, [aria-haspopup]'));
356
+ return popups.filter(el => {
357
+ const style = window.getComputedStyle(el);
358
+ return style.display !== 'none' && style.visibility !== 'hidden';
359
+ }).length;
360
+ }).catch(() => 0);
361
+
362
+ trace.hover = {
363
+ selector: hoverResult.selector,
364
+ revealed: hoverResult.revealed,
365
+ domChanged: beforeDom !== afterDom,
366
+ popupsRevealed: visiblePopups
367
+ };
368
+ trace.interactionType = 'hover';
369
+ } else if (interaction.type === 'file_upload') {
370
+ // File upload: attach test file using ensureUploadFixture
371
+ const uploadResult = await humanDriver.uploadFile(page, locator);
372
+ trace.fileUpload = uploadResult;
373
+ trace.interactionType = 'file_upload';
374
+ } else if (interaction.type === 'auth_guard') {
375
+ // Auth guard: check protected route access
376
+ const href = interaction.href || (await locator.getAttribute('href').catch(() => null));
377
+ if (href) {
378
+ const currentUrl = page.url();
379
+ const fullUrl = href.startsWith('http') ? href : new URL(href, currentUrl).href;
380
+ const guardResult = await humanDriver.checkProtectedRoute(page, fullUrl);
381
+ const sessionStateAfter = await humanDriver.captureSessionState(page);
382
+ trace.authGuard = {
383
+ url: guardResult.url,
384
+ isProtected: guardResult.isProtected,
385
+ redirectedToLogin: guardResult.redirectedToLogin,
386
+ hasAccessDenied: guardResult.hasAccessDenied,
387
+ httpStatus: guardResult.httpStatus,
388
+ beforeUrl: guardResult.beforeUrl,
389
+ afterUrl: guardResult.afterUrl
390
+ };
391
+ trace.session = sessionStateAfter;
392
+ trace.interactionType = 'auth_guard';
393
+ // Navigate back to original page if redirected
394
+ if (guardResult.afterUrl !== guardResult.beforeUrl) {
395
+ await page.goto(beforeUrl, { waitUntil: 'domcontentloaded', timeout: CLICK_TIMEOUT_MS }).catch(() => null);
396
+ }
397
+ }
398
+ } else {
399
+ // Click/link: use human driver click
400
+ const clickResult = await humanDriver.clickElement(page, locator);
401
+ trace.humanDriverClicked = clickResult.clicked;
402
+ }
403
+
404
+ // PERFORMANCE INTELLIGENCE: Capture periodic timing snapshots after interaction
405
+ // Check for feedback signals at intervals
406
+ if (timingSensor && timingSensor.t0) {
407
+ // Capture snapshot immediately after interaction
408
+ await timingSensor.captureTimingSnapshot(page);
409
+
410
+ // Wait a bit and capture again to catch delayed feedback
411
+ await page.waitForTimeout(300);
412
+ await timingSensor.captureTimingSnapshot(page);
413
+
414
+ // Wait longer for slow feedback
415
+ await page.waitForTimeout(1200);
416
+ await timingSensor.captureTimingSnapshot(page);
417
+
418
+ // Record UI change if detected
419
+ if (uiSignalSensor) {
420
+ const currentUi = await uiSignalSensor.snapshot(page).catch(() => ({}));
421
+ const currentDiff = uiSignalSensor.diff(uiBefore, currentUi);
422
+ if (currentDiff.changed) {
423
+ timingSensor.recordUiChange();
424
+ }
425
+ }
426
+ }
427
+
428
+ if (navigationResult) {
429
+ navigationResult = await navigationResult;
430
+ }
223
431
  } catch (error) {
224
432
  if (error.message === 'timeout' || error.name === 'TimeoutError') {
225
- // Stop sensors on timeout
433
+ markTimeoutPolicy(trace, 'click', silenceTracker);
434
+ await captureAfterOnly(page, screenshotsDir, timestamp, i, trace);
435
+
226
436
  if (networkWindowId !== null) {
227
- networkSensor.stopWindow(networkWindowId);
437
+ const networkSummary = networkSensor.stopWindow(networkWindowId);
438
+ trace.sensors.network = networkSummary;
439
+ } else {
440
+ trace.sensors.network = networkSensor.getEmptySummary();
441
+ // Track sensor silence when empty summary is used
442
+ if (silenceTracker) {
443
+ silenceTracker.record({
444
+ scope: 'sensor',
445
+ reason: 'sensor_unavailable',
446
+ description: 'Network sensor data unavailable (window not started)',
447
+ context: {
448
+ interaction: trace.interaction,
449
+ sensor: 'network'
450
+ },
451
+ impact: 'incomplete_check'
452
+ });
453
+ }
228
454
  }
455
+
229
456
  if (consoleWindowId !== null) {
230
- consoleSensor.stopWindow(consoleWindowId, page);
457
+ const consoleSummary = consoleSensor.stopWindow(consoleWindowId, page);
458
+ trace.sensors.console = consoleSummary;
459
+ } else {
460
+ trace.sensors.console = consoleSensor.getEmptySummary();
461
+ // Track sensor silence when empty summary is used
462
+ if (silenceTracker) {
463
+ silenceTracker.record({
464
+ scope: 'sensor',
465
+ reason: 'sensor_unavailable',
466
+ description: 'Console sensor data unavailable (window not started)',
467
+ context: {
468
+ interaction: trace.interaction,
469
+ sensor: 'console'
470
+ },
471
+ impact: 'incomplete_check'
472
+ });
473
+ }
231
474
  }
232
475
 
233
- markTimeoutPolicy(trace, 'click');
234
- await captureAfterOnly(page, screenshotsDir, timestamp, i, trace);
476
+ const uiAfter = await uiSignalSensor.snapshot(page).catch(() => ({}));
477
+ const uiDiff = uiSignalSensor.diff(uiBefore, uiAfter);
478
+
479
+ // STATE INTELLIGENCE: Capture after state and compute diff
480
+ let stateDiff = { changed: [], available: false };
481
+ let storeType = null;
482
+ if (stateSensorActive) {
483
+ await stateSensor.captureAfter(page);
484
+ stateDiff = stateSensor.getDiff();
485
+ storeType = stateSensor.activeType;
486
+ stateSensor.cleanup();
487
+ }
488
+
489
+ trace.sensors.uiSignals = {
490
+ before: uiBefore,
491
+ after: uiAfter,
492
+ diff: uiDiff
493
+ };
494
+ trace.sensors.state = {
495
+ available: stateDiff.available,
496
+ changed: stateDiff.changed,
497
+ storeType: storeType
498
+ };
499
+
235
500
  return trace;
236
501
  }
237
502
  throw error;
238
503
  }
239
504
 
240
- const navigationResult = navigationPromise ? await navigationPromise : null;
241
-
242
505
  if (navigationResult) {
243
506
  const afterUrl = page.url();
244
507
  if (isExternalUrl(afterUrl, baseOrigin)) {
245
- await page.goBack({ timeout: NAVIGATION_TIMEOUT_MS }).catch(() => {});
508
+ await page.goBack({ timeout: scanBudget.navigationTimeoutMs }).catch(() => {});
246
509
  trace.policy = {
247
510
  ...(trace.policy || {}),
248
511
  externalNavigationBlocked: true,
@@ -251,47 +514,7 @@ export async function runInteraction(page, interaction, timestamp, i, screenshot
251
514
  }
252
515
  }
253
516
 
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
-
517
+ const { settleResult, afterUrl } = await captureAfterState(page, screenshotsDir, timestamp, i, trace, scanBudget);
295
518
  trace.after.url = afterUrl;
296
519
  trace.after.screenshot = `screenshots/after-${timestamp}-${i}.png`;
297
520
  if (!trace.dom) {
@@ -305,69 +528,211 @@ export async function runInteraction(page, interaction, timestamp, i, screenshot
305
528
  domChangedDuringSettle: settleResult.domChangedDuringSettle
306
529
  };
307
530
 
308
- // Add sensor evidence to trace
531
+ // Capture after page title
532
+ const afterTitle = typeof page.title === 'function' ? await page.title().catch(() => null) : (page.title || null);
533
+ if (!trace.page) {
534
+ trace.page = {};
535
+ }
536
+ trace.page.afterTitle = afterTitle;
537
+
538
+ const networkSummary = networkSensor.stopWindow(networkWindowId);
539
+ const consoleSummary = consoleSensor.stopWindow(consoleWindowId, page);
540
+ const navigationSummary = await navigationSensor.stopWindow(navigationWindowId, page);
541
+ const loadingSummary = await loadingSensor.stopWindow(loadingWindowId, loadingState);
542
+
543
+ // PERFORMANCE INTELLIGENCE: Analyze timing for feedback gaps
544
+ if (networkSummary && networkSummary.totalRequests > 0) {
545
+ timingSensor.analyzeNetworkSummary(networkSummary);
546
+ }
547
+ if (loadingSummary && loadingSummary.hasLoadingIndicators && loadingState) {
548
+ // Record loading start - use the timestamp when loading was detected
549
+ // loadingState.loadingStartTime is set when loading indicators first appear
550
+ if (loadingState.loadingStartTime) {
551
+ timingSensor.recordLoadingStart(loadingState.loadingStartTime);
552
+ } else {
553
+ // Fallback: estimate based on interaction start
554
+ timingSensor.recordLoadingStart();
555
+ }
556
+ }
557
+
558
+ const timingAnalysis = timingSensor.getTimingAnalysis();
559
+
560
+ // Capture HTTP status from network summary
561
+ // Network sensor summary doesn't include full requests Map, but provides:
562
+ // - failedRequests count
563
+ // - topFailedUrls array
564
+ // - totalRequests count
565
+ if (networkSummary) {
566
+ if (!trace.page) {
567
+ trace.page = {};
568
+ }
569
+
570
+ // If navigation completed and we have network activity, check for errors
571
+ if (networkSummary.topFailedUrls && networkSummary.topFailedUrls.length > 0) {
572
+ // Check if the failed URL matches our destination
573
+ const failedMatch = networkSummary.topFailedUrls.find(failed => {
574
+ try {
575
+ const failedUrl = new URL(failed.url);
576
+ const pageUrl = new URL(afterUrl);
577
+ return failedUrl.pathname === pageUrl.pathname && failedUrl.origin === pageUrl.origin;
578
+ } catch {
579
+ return false;
580
+ }
581
+ });
582
+
583
+ if (failedMatch) {
584
+ // Navigation target failed with HTTP error
585
+ trace.page.httpStatus = failedMatch.status || 500;
586
+ } else if (networkSummary.totalRequests > 0 && networkSummary.failedRequests === 0) {
587
+ // No failures, navigation likely succeeded with 200
588
+ trace.page.httpStatus = 200;
589
+ }
590
+ } else if (networkSummary.totalRequests > 0 && networkSummary.failedRequests === 0) {
591
+ // No failed requests, navigation likely succeeded with 200
592
+ trace.page.httpStatus = 200;
593
+ } else if (navigationSummary && navigationSummary.urlChanged && !navigationSummary.blockedNavigations) {
594
+ // Navigation completed successfully - assume HTTP 200
595
+ // This is safe because Playwright's waitForNavigation only resolves on successful navigation
596
+ trace.page.httpStatus = 200;
597
+ }
598
+ }
599
+
600
+ const uiAfter = await uiSignalSensor.snapshot(page);
601
+ const uiDiff = uiSignalSensor.diff(uiBefore, uiAfter);
602
+
603
+ // PERFORMANCE INTELLIGENCE: Record UI change in timing sensor if detected
604
+ if (timingSensor && uiDiff.changed) {
605
+ timingSensor.recordUiChange();
606
+ }
607
+
608
+ // A11Y INTELLIGENCE: Capture focus and ARIA state after interaction
609
+ await focusSensor.captureAfter(page);
610
+ await ariaSensor.captureAfter(page);
611
+ const focusDiff = focusSensor.getFocusDiff();
612
+ const ariaDiff = ariaSensor.getAriaDiff();
613
+
614
+ // STATE INTELLIGENCE: Capture after state and compute diff
615
+ let stateDiff = { changed: [], available: false };
616
+ let storeType = null;
617
+ if (stateSensorActive) {
618
+ await stateSensor.captureAfter(page);
619
+ stateDiff = stateSensor.getDiff();
620
+ storeType = stateSensor.activeType;
621
+ stateSensor.cleanup();
622
+ }
623
+
309
624
  trace.sensors = {
310
625
  network: networkSummary,
311
626
  console: consoleSummary,
627
+ navigation: navigationSummary, // NAVIGATION INTELLIGENCE v2: Add navigation sensor data
628
+ loading: loadingSummary, // ASYNC INTELLIGENCE: Add loading sensor data
629
+ focus: focusDiff, // A11Y INTELLIGENCE: Add focus sensor data
630
+ aria: ariaDiff, // A11Y INTELLIGENCE: Add ARIA sensor data
631
+ timing: timingAnalysis, // PERFORMANCE INTELLIGENCE: Add timing analysis
312
632
  uiSignals: {
313
- before: uiSignalsBefore,
314
- after: uiSignalsAfter,
315
- changes: uiSignalChanges
633
+ before: uiBefore,
634
+ after: uiAfter,
635
+ diff: uiDiff
316
636
  },
317
- stateUI: {
318
- before: stateUiBefore,
319
- after: stateUIAfter,
320
- changed: stateUIChanges.changed,
321
- reasons: stateUIChanges.reasons
637
+ state: {
638
+ available: stateDiff.available,
639
+ changed: stateDiff.changed,
640
+ storeType: storeType
322
641
  }
323
642
  };
324
643
 
325
644
  return trace;
326
645
  } catch (error) {
327
646
  if (error.message === 'timeout' || error.name === 'TimeoutError') {
328
- // Stop sensors on timeout
647
+ markTimeoutPolicy(trace, 'click');
648
+ await captureAfterOnly(page, screenshotsDir, timestamp, i, trace);
649
+
329
650
  if (networkWindowId !== null) {
330
- try {
331
- networkSensor.stopWindow(networkWindowId);
332
- } catch (e) {
333
- // Ignore sensor cleanup errors
334
- }
651
+ const networkSummary = networkSensor.stopWindow(networkWindowId);
652
+ trace.sensors.network = networkSummary;
653
+ } else {
654
+ trace.sensors.network = networkSensor.getEmptySummary();
335
655
  }
656
+
336
657
  if (consoleWindowId !== null) {
337
- try {
338
- consoleSensor.stopWindow(consoleWindowId, page);
339
- } catch (e) {
340
- // Ignore sensor cleanup errors
341
- }
658
+ const consoleSummary = consoleSensor.stopWindow(consoleWindowId, page);
659
+ trace.sensors.console = consoleSummary;
660
+ } else {
661
+ trace.sensors.console = consoleSensor.getEmptySummary();
342
662
  }
343
- markTimeoutPolicy(trace, 'click');
344
- await captureAfterOnly(page, screenshotsDir, timestamp, i, trace);
663
+
664
+ const uiAfter = await uiSignalSensor.snapshot(page).catch(() => ({}));
665
+ const uiDiff = uiSignalSensor.diff(uiBefore || {}, uiAfter);
666
+
667
+ // STATE INTELLIGENCE: Capture after state and compute diff
668
+ let stateDiff = { changed: [], available: false };
669
+ let storeType = null;
670
+ if (stateSensorActive) {
671
+ await stateSensor.captureAfter(page);
672
+ stateDiff = stateSensor.getDiff();
673
+ storeType = stateSensor.activeType;
674
+ stateSensor.cleanup();
675
+ }
676
+
677
+ trace.sensors.uiSignals = {
678
+ before: uiBefore || {},
679
+ after: uiAfter,
680
+ diff: uiDiff
681
+ };
682
+ trace.sensors.state = {
683
+ available: stateDiff.available,
684
+ changed: stateDiff.changed,
685
+ storeType: storeType
686
+ };
687
+
345
688
  return trace;
346
689
  }
347
690
 
348
- // Stop sensors on unexpected error
691
+ // For non-timeout errors, capture as execution error trace instead of returning null
692
+ trace.policy = {
693
+ ...(trace.policy || {}),
694
+ executionError: true,
695
+ reason: error.message
696
+ };
697
+
349
698
  if (networkWindowId !== null) {
350
- try {
351
- networkSensor.stopWindow(networkWindowId);
352
- } catch (e) {
353
- // Ignore sensor cleanup errors
354
- }
699
+ trace.sensors.network = networkSensor.stopWindow(networkWindowId);
700
+ } else {
701
+ trace.sensors.network = networkSensor.getEmptySummary();
355
702
  }
356
703
  if (consoleWindowId !== null) {
357
- try {
358
- consoleSensor.stopWindow(consoleWindowId, page);
359
- } catch (e) {
360
- // Ignore sensor cleanup errors
361
- }
704
+ trace.sensors.console = consoleSensor.stopWindow(consoleWindowId, page);
705
+ } else {
706
+ trace.sensors.console = consoleSensor.getEmptySummary();
362
707
  }
363
- return null;
708
+ if (stateSensorActive) {
709
+ stateSensor.cleanup();
710
+ const stateDiff = stateSensor.getDiff();
711
+ trace.sensors.state = {
712
+ available: stateDiff.available,
713
+ changed: stateDiff.changed,
714
+ storeType: stateSensor.activeType
715
+ };
716
+ } else {
717
+ trace.sensors.state = { available: false, changed: [], storeType: null };
718
+ }
719
+
720
+ const uiAfter = await uiSignalSensor.snapshot(page).catch(() => ({}));
721
+ const uiDiff = uiSignalSensor.diff(uiBefore || {}, uiAfter || {});
722
+ trace.sensors.uiSignals = {
723
+ before: uiBefore || {},
724
+ after: uiAfter || {},
725
+ diff: uiDiff
726
+ };
727
+
728
+ // Best-effort after state
729
+ await captureAfterOnly(page, screenshotsDir, timestamp, i, trace).catch(() => {});
730
+
731
+ return trace;
364
732
  }
365
733
  }
366
734
 
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
-
735
+ async function captureAfterState(page, screenshotsDir, timestamp, interactionIndex, trace, scanBudget) {
371
736
  let settleResult = {
372
737
  samples: [],
373
738
  domChangedDuringSettle: false,
@@ -375,7 +740,7 @@ async function captureAfterState(page, screenshotsDir, timestamp, interactionInd
375
740
  };
376
741
 
377
742
  try {
378
- settleResult = await captureSettledDom(page);
743
+ settleResult = await captureSettledDom(page, scanBudget);
379
744
  } catch (error) {
380
745
  if (error.message === 'timeout' || error.name === 'TimeoutError') {
381
746
  markTimeoutPolicy(trace, 'settle');