@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.
- package/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +14 -36
- package/src/cli/commands/default.js +523 -0
- package/src/cli/commands/doctor.js +165 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +402 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +296 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +34 -0
- package/src/cli/util/expectation-extractor.js +378 -0
- package/src/cli/util/findings-writer.js +31 -0
- package/src/cli/util/idgen.js +87 -0
- package/src/cli/util/learn-writer.js +39 -0
- package/src/cli/util/observation-engine.js +366 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +29 -0
- package/src/cli/util/project-discovery.js +277 -0
- package/src/cli/util/project-writer.js +26 -0
- package/src/cli/util/redact.js +128 -0
- package/src/cli/util/run-id.js +30 -0
- package/src/cli/util/summary-writer.js +32 -0
- package/src/verax/cli/ci-summary.js +35 -0
- package/src/verax/cli/context-explanation.js +89 -0
- package/src/verax/cli/doctor.js +277 -0
- package/src/verax/cli/error-normalizer.js +154 -0
- package/src/verax/cli/explain-output.js +105 -0
- package/src/verax/cli/finding-explainer.js +130 -0
- package/src/verax/cli/init.js +237 -0
- package/src/verax/cli/run-overview.js +163 -0
- package/src/verax/cli/url-safety.js +101 -0
- package/src/verax/cli/wizard.js +98 -0
- package/src/verax/cli/zero-findings-explainer.js +57 -0
- package/src/verax/cli/zero-interaction-explainer.js +127 -0
- package/src/verax/core/action-classifier.js +86 -0
- package/src/verax/core/budget-engine.js +218 -0
- package/src/verax/core/canonical-outcomes.js +157 -0
- package/src/verax/core/decision-snapshot.js +335 -0
- package/src/verax/core/determinism-model.js +403 -0
- package/src/verax/core/incremental-store.js +237 -0
- package/src/verax/core/invariants.js +356 -0
- package/src/verax/core/promise-model.js +230 -0
- package/src/verax/core/replay-validator.js +350 -0
- package/src/verax/core/replay.js +222 -0
- package/src/verax/core/run-id.js +175 -0
- package/src/verax/core/run-manifest.js +99 -0
- package/src/verax/core/silence-impact.js +369 -0
- package/src/verax/core/silence-model.js +521 -0
- package/src/verax/detect/comparison.js +2 -34
- package/src/verax/detect/confidence-engine.js +764 -329
- package/src/verax/detect/detection-engine.js +293 -0
- package/src/verax/detect/evidence-index.js +177 -0
- package/src/verax/detect/expectation-model.js +194 -172
- package/src/verax/detect/explanation-helpers.js +187 -0
- package/src/verax/detect/finding-detector.js +450 -0
- package/src/verax/detect/findings-writer.js +44 -8
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +172 -286
- package/src/verax/detect/interactive-findings.js +613 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/verdict-engine.js +563 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/index.js +90 -14
- package/src/verax/intel/effect-detector.js +368 -0
- package/src/verax/intel/handler-mapper.js +249 -0
- package/src/verax/intel/index.js +281 -0
- package/src/verax/intel/route-extractor.js +280 -0
- package/src/verax/intel/ts-program.js +256 -0
- package/src/verax/intel/vue-navigation-extractor.js +579 -0
- package/src/verax/intel/vue-router-extractor.js +323 -0
- package/src/verax/learn/action-contract-extractor.js +335 -101
- package/src/verax/learn/ast-contract-extractor.js +95 -5
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/manifest-writer.js +97 -47
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +27 -96
- package/src/verax/learn/state-extractor.js +212 -0
- package/src/verax/learn/static-extractor-navigation.js +114 -0
- package/src/verax/learn/static-extractor-validation.js +88 -0
- package/src/verax/learn/static-extractor.js +112 -4
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +10 -5
- package/src/verax/observe/console-sensor.js +1 -17
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +512 -0
- package/src/verax/observe/flow-matcher.js +143 -0
- package/src/verax/observe/focus-sensor.js +196 -0
- package/src/verax/observe/human-driver.js +643 -275
- package/src/verax/observe/index.js +908 -27
- package/src/verax/observe/index.js.backup +1 -0
- package/src/verax/observe/interaction-discovery.js +365 -14
- package/src/verax/observe/interaction-runner.js +563 -198
- package/src/verax/observe/loading-sensor.js +139 -0
- package/src/verax/observe/navigation-sensor.js +255 -0
- package/src/verax/observe/network-sensor.js +55 -7
- package/src/verax/observe/observed-expectation-deriver.js +186 -0
- package/src/verax/observe/observed-expectation.js +305 -0
- package/src/verax/observe/page-frontier.js +234 -0
- package/src/verax/observe/settle.js +37 -17
- package/src/verax/observe/state-sensor.js +389 -0
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +61 -20
- package/src/verax/observe/ui-signal-sensor.js +136 -17
- package/src/verax/scan-summary-writer.js +77 -15
- package/src/verax/shared/artifact-manager.js +110 -8
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +170 -0
- package/src/verax/shared/dynamic-route-utils.js +218 -0
- package/src/verax/shared/expectation-coverage.js +44 -0
- package/src/verax/shared/expectation-prover.js +81 -0
- package/src/verax/shared/expectation-tracker.js +201 -0
- package/src/verax/shared/expectations-writer.js +60 -0
- package/src/verax/shared/first-run.js +44 -0
- package/src/verax/shared/progress-reporter.js +171 -0
- package/src/verax/shared/retry-policy.js +14 -1
- package/src/verax/shared/root-artifacts.js +49 -0
- package/src/verax/shared/scan-budget.js +86 -0
- package/src/verax/shared/url-normalizer.js +162 -0
- package/src/verax/shared/zip-artifacts.js +65 -0
- package/src/verax/validate/context-validator.js +244 -0
- 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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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(
|
|
69
|
+
await page.waitForTimeout(midDelay);
|
|
48
70
|
await sampleDom();
|
|
49
|
-
await page.waitForTimeout(
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
82
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
277
|
+
});
|
|
278
|
+
}
|
|
216
279
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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:
|
|
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
|
-
//
|
|
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:
|
|
314
|
-
after:
|
|
315
|
-
|
|
633
|
+
before: uiBefore,
|
|
634
|
+
after: uiAfter,
|
|
635
|
+
diff: uiDiff
|
|
316
636
|
},
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
647
|
+
markTimeoutPolicy(trace, 'click');
|
|
648
|
+
await captureAfterOnly(page, screenshotsDir, timestamp, i, trace);
|
|
649
|
+
|
|
329
650
|
if (networkWindowId !== null) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
658
|
+
const consoleSummary = consoleSensor.stopWindow(consoleWindowId, page);
|
|
659
|
+
trace.sensors.console = consoleSummary;
|
|
660
|
+
} else {
|
|
661
|
+
trace.sensors.console = consoleSensor.getEmptySummary();
|
|
342
662
|
}
|
|
343
|
-
|
|
344
|
-
await
|
|
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
|
-
//
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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');
|