@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
|
@@ -1,67 +1,948 @@
|
|
|
1
1
|
import { resolve, dirname } from 'path';
|
|
2
|
-
import { mkdirSync } from 'fs';
|
|
2
|
+
import { mkdirSync, readFileSync, existsSync } from 'fs';
|
|
3
3
|
import { createBrowser, navigateToUrl, closeBrowser } from './browser.js';
|
|
4
|
-
import {
|
|
4
|
+
import { discoverAllInteractions } from './interaction-discovery.js';
|
|
5
5
|
import { captureScreenshot } from './evidence-capture.js';
|
|
6
6
|
import { runInteraction } from './interaction-runner.js';
|
|
7
7
|
import { writeTraces } from './traces-writer.js';
|
|
8
|
-
import { getBaseOrigin } from './domain-boundary.js';
|
|
8
|
+
import { getBaseOrigin, isExternalUrl } from './domain-boundary.js';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
|
|
11
|
+
import { executeProvenExpectations } from './expectation-executor.js';
|
|
12
|
+
import { isProvenExpectation } from '../shared/expectation-prover.js';
|
|
13
|
+
import { PageFrontier } from './page-frontier.js';
|
|
14
|
+
import { deriveObservedExpectation, shouldAttemptRepeatObservedExpectation, evaluateObservedExpectation } from './observed-expectation.js';
|
|
15
|
+
import { computeRouteBudget } from '../core/budget-engine.js';
|
|
16
|
+
import { loadPreviousSnapshot, saveSnapshot, buildSnapshot, compareSnapshots, shouldSkipInteractionIncremental } from '../core/incremental-store.js';
|
|
17
|
+
import SilenceTracker from '../core/silence-model.js';
|
|
18
|
+
import { DecisionRecorder, recordBudgetProfile, recordTimeoutConfig, recordAdaptiveStabilization, recordTruncation, recordEnvironment } from '../core/determinism-model.js';
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
/**
|
|
21
|
+
* OBSERVE PHASE - Execute interactions and capture runtime behavior
|
|
22
|
+
*
|
|
23
|
+
* SILENCE TRACKING: Every skip, timeout, cap, and drop is recorded.
|
|
24
|
+
* Nothing unobserved is allowed to disappear.
|
|
25
|
+
*
|
|
26
|
+
* - Incremental skips: Tracked as silence (reused previous data)
|
|
27
|
+
* - Safety skips: Tracked as silence (destructive/unsafe actions)
|
|
28
|
+
* - Budget caps: Tracked as silence (unevaluated interactions)
|
|
29
|
+
* - Timeouts: Tracked as silence (unknown outcomes)
|
|
30
|
+
* - Sensor failures: Tracked as silence (missing data)
|
|
31
|
+
*
|
|
32
|
+
* All silence is explicit in output - no silent success.
|
|
33
|
+
*
|
|
34
|
+
* PHASE 4: Safety mode enabled by default
|
|
35
|
+
* - Blocks risky/write actions unless flags allow
|
|
36
|
+
* - Blocks POST/PUT/PATCH/DELETE unless --allow-writes
|
|
37
|
+
* - Blocks cross-origin unless --allow-cross-origin
|
|
38
|
+
*
|
|
39
|
+
* PHASE 5: Deterministic artifact paths
|
|
40
|
+
* - All artifacts written to .verax/runs/<runId>/
|
|
41
|
+
*/
|
|
42
|
+
export async function observe(url, manifestPath = null, scanBudgetOverride = null, safetyFlags = {}, projectDir = null, runId = null) {
|
|
43
|
+
const scanBudget = scanBudgetOverride || DEFAULT_SCAN_BUDGET;
|
|
13
44
|
const { browser, page } = await createBrowser();
|
|
14
45
|
const startTime = Date.now();
|
|
15
46
|
const baseOrigin = getBaseOrigin(url);
|
|
47
|
+
const silenceTracker = new SilenceTracker();
|
|
16
48
|
|
|
17
|
-
|
|
18
|
-
|
|
49
|
+
// PHASE 6: Record all adaptive decisions for determinism tracking
|
|
50
|
+
const decisionRecorder = new DecisionRecorder(runId);
|
|
51
|
+
|
|
52
|
+
// Record budget and timeout configuration decisions
|
|
53
|
+
const profileName = scanBudgetOverride ? 'custom' : 'standard';
|
|
54
|
+
recordBudgetProfile(decisionRecorder, profileName, scanBudget);
|
|
55
|
+
recordTimeoutConfig(decisionRecorder, scanBudget);
|
|
56
|
+
|
|
57
|
+
// Record environment decisions
|
|
58
|
+
recordEnvironment(decisionRecorder, { browserType: 'chromium', viewport: { width: 1280, height: 720 } });
|
|
59
|
+
|
|
60
|
+
// Phase 5: Detect projectDir if not provided (for backwards compatibility with tests)
|
|
61
|
+
if (!projectDir) {
|
|
62
|
+
projectDir = process.cwd();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Phase 4: Extract safety flags
|
|
66
|
+
const { allowWrites = false, allowRiskyActions = false, allowCrossOrigin = false } = safetyFlags;
|
|
67
|
+
let blockedNetworkWrites = [];
|
|
68
|
+
let blockedCrossOrigin = [];
|
|
69
|
+
|
|
70
|
+
// Phase 4: Setup network interception firewall
|
|
71
|
+
await page.route('**/*', (route) => {
|
|
72
|
+
const request = route.request();
|
|
73
|
+
const method = request.method();
|
|
74
|
+
const requestUrl = request.url();
|
|
75
|
+
const resourceType = request.resourceType();
|
|
19
76
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
77
|
+
// Check cross-origin blocking (skip for file:// URLs)
|
|
78
|
+
if (!allowCrossOrigin && !requestUrl.startsWith('file://')) {
|
|
79
|
+
try {
|
|
80
|
+
const reqOrigin = new URL(requestUrl).origin;
|
|
81
|
+
if (reqOrigin !== baseOrigin) {
|
|
82
|
+
blockedCrossOrigin.push({
|
|
83
|
+
url: requestUrl,
|
|
84
|
+
origin: reqOrigin,
|
|
85
|
+
method,
|
|
86
|
+
resourceType,
|
|
87
|
+
timestamp: Date.now()
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
silenceTracker.record({
|
|
91
|
+
scope: 'safety',
|
|
92
|
+
reason: 'cross_origin_blocked',
|
|
93
|
+
description: `Cross-origin request blocked: ${method} ${requestUrl}`,
|
|
94
|
+
context: { url: requestUrl, origin: reqOrigin, method, baseOrigin },
|
|
95
|
+
impact: 'request_blocked'
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return route.abort('blockedbyclient');
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// Invalid URL, allow and let browser handle
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check write method blocking
|
|
106
|
+
if (!allowWrites && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
107
|
+
// Check if it's a GraphQL mutation (best-effort)
|
|
108
|
+
const isGraphQLMutation = requestUrl.includes('/graphql') && method === 'POST';
|
|
109
|
+
|
|
110
|
+
blockedNetworkWrites.push({
|
|
111
|
+
url: requestUrl,
|
|
112
|
+
method,
|
|
113
|
+
resourceType,
|
|
114
|
+
isGraphQLMutation,
|
|
115
|
+
timestamp: Date.now()
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
silenceTracker.record({
|
|
119
|
+
scope: 'safety',
|
|
120
|
+
reason: 'blocked_network_write',
|
|
121
|
+
description: `Network write blocked: ${method} ${requestUrl}${isGraphQLMutation ? ' (GraphQL mutation)' : ''}`,
|
|
122
|
+
context: { url: requestUrl, method, resourceType, isGraphQLMutation },
|
|
123
|
+
impact: 'write_blocked'
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return route.abort('blockedbyclient');
|
|
27
127
|
}
|
|
128
|
+
|
|
129
|
+
// Allow request
|
|
130
|
+
route.continue();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await navigateToUrl(page, url, scanBudget);
|
|
135
|
+
|
|
136
|
+
const projectDir = manifestPath ? dirname(dirname(dirname(manifestPath))) : process.cwd();
|
|
137
|
+
const observeDir = resolve(projectDir, '.veraxverax', 'observe');
|
|
138
|
+
const screenshotsDir = resolve(observeDir, 'screenshots');
|
|
28
139
|
mkdirSync(screenshotsDir, { recursive: true });
|
|
29
140
|
|
|
30
141
|
const timestamp = Date.now();
|
|
31
142
|
const initialScreenshot = resolve(screenshotsDir, `initial-${timestamp}.png`);
|
|
32
143
|
await captureScreenshot(page, initialScreenshot);
|
|
33
144
|
|
|
34
|
-
|
|
145
|
+
// 1) Execute PROVEN expectations first (if manifest exists)
|
|
146
|
+
let manifest = null;
|
|
147
|
+
let expectationResults = null;
|
|
148
|
+
let expectationCoverageGaps = [];
|
|
149
|
+
let incrementalMode = false;
|
|
150
|
+
let snapshotDiff = null;
|
|
151
|
+
let oldSnapshot = null;
|
|
152
|
+
|
|
153
|
+
if (manifestPath && existsSync(manifestPath)) {
|
|
154
|
+
try {
|
|
155
|
+
const manifestContent = readFileSync(manifestPath, 'utf-8');
|
|
156
|
+
manifest = JSON.parse(manifestContent);
|
|
157
|
+
|
|
158
|
+
// SCALE INTELLIGENCE: Load previous snapshot for incremental mode
|
|
159
|
+
oldSnapshot = loadPreviousSnapshot(projectDir);
|
|
160
|
+
if (oldSnapshot) {
|
|
161
|
+
const currentSnapshot = buildSnapshot(manifest, []);
|
|
162
|
+
snapshotDiff = compareSnapshots(oldSnapshot, currentSnapshot);
|
|
163
|
+
incrementalMode = !snapshotDiff.hasChanges; // Use incremental if nothing changed
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const provenCount = (manifest.staticExpectations || []).filter(exp => isProvenExpectation(exp)).length;
|
|
167
|
+
if (provenCount > 0) {
|
|
168
|
+
expectationResults = await executeProvenExpectations(
|
|
169
|
+
page,
|
|
170
|
+
manifest,
|
|
171
|
+
url,
|
|
172
|
+
screenshotsDir,
|
|
173
|
+
scanBudget,
|
|
174
|
+
startTime,
|
|
175
|
+
projectDir
|
|
176
|
+
);
|
|
177
|
+
expectationCoverageGaps = expectationResults.coverageGaps || [];
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// Record manifest load/expectation execution failure as silence
|
|
181
|
+
silenceTracker.record({
|
|
182
|
+
scope: 'discovery',
|
|
183
|
+
reason: 'discovery_error',
|
|
184
|
+
description: 'Manifest load or expectation execution failed',
|
|
185
|
+
context: { error: err?.message },
|
|
186
|
+
impact: 'incomplete_check'
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Reset to start URL before traversal
|
|
192
|
+
await navigateToUrl(page, url, scanBudget);
|
|
193
|
+
|
|
194
|
+
const frontier = new PageFrontier(url, baseOrigin, scanBudget, startTime);
|
|
35
195
|
const traces = [];
|
|
36
196
|
const observeWarnings = [];
|
|
37
|
-
|
|
197
|
+
const skippedInteractions = [];
|
|
198
|
+
const observedExpectations = []; // Store observed expectations for runtime report
|
|
199
|
+
let totalInteractionsDiscovered = 0;
|
|
200
|
+
let totalInteractionsExecuted = 0;
|
|
201
|
+
let remainingInteractionsGaps = [];
|
|
202
|
+
|
|
203
|
+
let nextPageUrl = frontier.getNextUrl();
|
|
204
|
+
|
|
205
|
+
while (nextPageUrl && Date.now() - startTime < scanBudget.maxScanDurationMs) {
|
|
206
|
+
if (frontier.isPageLimitExceeded()) {
|
|
207
|
+
// PHASE 6: Record truncation decision
|
|
208
|
+
recordTruncation(decisionRecorder, 'pages', {
|
|
209
|
+
limit: scanBudget.maxPages,
|
|
210
|
+
reached: frontier.pagesVisited
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
silenceTracker.record({
|
|
214
|
+
scope: 'page',
|
|
215
|
+
reason: 'page_limit_exceeded',
|
|
216
|
+
description: `Reached maximum of ${scanBudget.maxPages} pages visited`,
|
|
217
|
+
context: { pagesVisited: frontier.pagesVisited, maxPages: scanBudget.maxPages },
|
|
218
|
+
impact: 'blocks_nav'
|
|
219
|
+
});
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if we're already on the target page (from navigation via link click)
|
|
224
|
+
const currentUrl = page.url();
|
|
225
|
+
const normalizedNext = frontier.normalizeUrl(nextPageUrl);
|
|
226
|
+
const normalizedCurrent = frontier.normalizeUrl(currentUrl);
|
|
227
|
+
const alreadyOnPage = normalizedCurrent === normalizedNext;
|
|
228
|
+
|
|
229
|
+
if (!alreadyOnPage) {
|
|
230
|
+
// Navigate to next page
|
|
231
|
+
try {
|
|
232
|
+
await navigateToUrl(page, nextPageUrl, scanBudget);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
// Record navigation failure as silence and skip
|
|
235
|
+
silenceTracker.record({
|
|
236
|
+
scope: 'navigation',
|
|
237
|
+
reason: 'navigation_timeout',
|
|
238
|
+
description: 'Navigation to page failed',
|
|
239
|
+
context: { targetUrl: nextPageUrl },
|
|
240
|
+
impact: 'blocks_nav'
|
|
241
|
+
});
|
|
242
|
+
const normalizedFailed = frontier.normalizeUrl(nextPageUrl);
|
|
243
|
+
if (!frontier.visited.has(normalizedFailed)) {
|
|
244
|
+
frontier.visited.add(normalizedFailed);
|
|
245
|
+
frontier.markVisited();
|
|
246
|
+
}
|
|
247
|
+
nextPageUrl = frontier.getNextUrl();
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Mark as visited and increment counter
|
|
253
|
+
// getNextUrl() marks as visited in the set but doesn't call markVisited() to increment counter
|
|
254
|
+
// So we need to call markVisited() here when we process a page
|
|
255
|
+
if (!alreadyOnPage) {
|
|
256
|
+
// We navigated via getNextUrl() - it already marked in visited set, now increment counter
|
|
257
|
+
// (getNextUrl() marks in visited set but doesn't increment pagesVisited)
|
|
258
|
+
frontier.markVisited();
|
|
259
|
+
} else {
|
|
260
|
+
// We navigated via link click (alreadyOnPage=true) - mark as visited and increment
|
|
261
|
+
if (!frontier.visited.has(normalizedNext)) {
|
|
262
|
+
frontier.visited.add(normalizedNext);
|
|
263
|
+
frontier.markVisited();
|
|
264
|
+
} else {
|
|
265
|
+
// Already marked as visited, but still increment counter since we're processing it
|
|
266
|
+
frontier.markVisited();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Discover ALL links on this page and add to frontier BEFORE executing interactions
|
|
271
|
+
try {
|
|
272
|
+
const currentLinks = await page.locator('a[href]').all();
|
|
273
|
+
for (const link of currentLinks) {
|
|
274
|
+
try {
|
|
275
|
+
const href = await link.getAttribute('href');
|
|
276
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
277
|
+
const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
|
|
278
|
+
if (!isExternalUrl(resolvedUrl, baseOrigin)) {
|
|
279
|
+
frontier.addUrl(resolvedUrl);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// Record invalid URL discovery as silence
|
|
284
|
+
silenceTracker.record({
|
|
285
|
+
scope: 'discovery',
|
|
286
|
+
reason: 'discovery_error',
|
|
287
|
+
description: 'Invalid or unreadable link during discovery',
|
|
288
|
+
context: { pageUrl: page.url() },
|
|
289
|
+
impact: 'incomplete_check'
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} catch (error) {
|
|
294
|
+
// Record link discovery failure as silence
|
|
295
|
+
silenceTracker.record({
|
|
296
|
+
scope: 'discovery',
|
|
297
|
+
reason: 'discovery_error',
|
|
298
|
+
description: 'Link discovery failed on page',
|
|
299
|
+
context: { pageUrl: page.url() },
|
|
300
|
+
impact: 'incomplete_check'
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// SCALE INTELLIGENCE: Compute adaptive budget for this route
|
|
305
|
+
// Reuse currentUrl from above (already captured at line 95)
|
|
306
|
+
const routeBudget = manifest ? computeRouteBudget(manifest, currentUrl, scanBudget) : scanBudget;
|
|
307
|
+
|
|
308
|
+
// Discover ALL interactions on this page
|
|
309
|
+
// Note: discoverAllInteractions already returns sorted interactions deterministically
|
|
310
|
+
const { interactions, coverage: pageCoverage } = await discoverAllInteractions(page, baseOrigin, routeBudget);
|
|
311
|
+
totalInteractionsDiscovered += interactions.length;
|
|
312
|
+
|
|
313
|
+
// SCALE INTELLIGENCE: Apply adaptive budget cap (interactions are already sorted deterministically)
|
|
314
|
+
// Stable sorting ensures determinism: same interactions → same order
|
|
315
|
+
const sortedInteractions = interactions.slice(0, routeBudget.maxInteractionsPerPage);
|
|
316
|
+
|
|
317
|
+
// Track if we navigated during interaction execution
|
|
318
|
+
let navigatedToNewPage = false;
|
|
319
|
+
let navigatedPageUrl = null;
|
|
320
|
+
let remainingInteractionsStartIndex = 0;
|
|
321
|
+
|
|
322
|
+
// Execute discovered interactions on this page (sorted for determinism)
|
|
323
|
+
for (let i = 0; i < sortedInteractions.length; i++) {
|
|
324
|
+
if (Date.now() - startTime > scanBudget.maxScanDurationMs) {
|
|
325
|
+
// PHASE 6: Record truncation decision
|
|
326
|
+
recordTruncation(decisionRecorder, 'time', {
|
|
327
|
+
limit: scanBudget.maxScanDurationMs,
|
|
328
|
+
elapsed: Date.now() - startTime
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Mark remaining interactions as COVERAGE_GAP
|
|
332
|
+
silenceTracker.record({
|
|
333
|
+
scope: 'interaction',
|
|
334
|
+
reason: 'scan_time_exceeded',
|
|
335
|
+
description: `Scan time limit (${scanBudget.maxScanDurationMs}ms) exceeded`,
|
|
336
|
+
context: {
|
|
337
|
+
elapsed: Date.now() - startTime,
|
|
338
|
+
maxDuration: scanBudget.maxScanDurationMs,
|
|
339
|
+
remainingInteractions: sortedInteractions.length - i
|
|
340
|
+
},
|
|
341
|
+
impact: 'blocks_nav',
|
|
342
|
+
count: sortedInteractions.length - i
|
|
343
|
+
});
|
|
344
|
+
remainingInteractionsStartIndex = i;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (totalInteractionsExecuted >= routeBudget.maxInteractionsPerPage) {
|
|
349
|
+
// PHASE 6: Record truncation decision
|
|
350
|
+
recordTruncation(decisionRecorder, 'interactions', {
|
|
351
|
+
limit: routeBudget.maxInteractionsPerPage,
|
|
352
|
+
reached: totalInteractionsExecuted,
|
|
353
|
+
scope: 'per_page'
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Route-specific budget exceeded
|
|
357
|
+
silenceTracker.record({
|
|
358
|
+
scope: 'interaction',
|
|
359
|
+
reason: 'route_interaction_limit_exceeded',
|
|
360
|
+
description: `Reached max ${routeBudget.maxInteractionsPerPage} interactions per page`,
|
|
361
|
+
context: {
|
|
362
|
+
currentPage: page.url(),
|
|
363
|
+
executed: totalInteractionsExecuted,
|
|
364
|
+
maxPerPage: routeBudget.maxInteractionsPerPage,
|
|
365
|
+
remainingInteractions: sortedInteractions.length - i
|
|
366
|
+
},
|
|
367
|
+
impact: 'affects_expectations',
|
|
368
|
+
count: sortedInteractions.length - i
|
|
369
|
+
});
|
|
370
|
+
remainingInteractionsStartIndex = i;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (totalInteractionsExecuted >= scanBudget.maxTotalInteractions) {
|
|
375
|
+
// PHASE 6: Record truncation decision
|
|
376
|
+
recordTruncation(decisionRecorder, 'interactions', {
|
|
377
|
+
limit: scanBudget.maxTotalInteractions,
|
|
378
|
+
reached: totalInteractionsExecuted,
|
|
379
|
+
scope: 'total'
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Mark remaining interactions as COVERAGE_GAP with reason 'budget_exceeded'
|
|
383
|
+
silenceTracker.record({
|
|
384
|
+
scope: 'interaction',
|
|
385
|
+
reason: 'interaction_limit_exceeded',
|
|
386
|
+
description: `Reached max ${scanBudget.maxTotalInteractions} total interactions`,
|
|
387
|
+
context: {
|
|
388
|
+
executed: totalInteractionsExecuted,
|
|
389
|
+
maxTotal: scanBudget.maxTotalInteractions,
|
|
390
|
+
remainingInteractions: sortedInteractions.length - i
|
|
391
|
+
},
|
|
392
|
+
impact: 'blocks_nav',
|
|
393
|
+
count: sortedInteractions.length - i
|
|
394
|
+
});
|
|
395
|
+
remainingInteractionsStartIndex = i;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const interaction = sortedInteractions[i];
|
|
400
|
+
|
|
401
|
+
// SCALE INTELLIGENCE: Check if interaction should be skipped in incremental mode
|
|
402
|
+
if (incrementalMode && manifest && oldSnapshot && snapshotDiff) {
|
|
403
|
+
const shouldSkip = shouldSkipInteractionIncremental(interaction, currentUrl, oldSnapshot, snapshotDiff);
|
|
404
|
+
if (shouldSkip) {
|
|
405
|
+
// Create a trace for skipped interaction (marked as incremental - will not produce findings)
|
|
406
|
+
const skippedTrace = {
|
|
407
|
+
interaction: {
|
|
408
|
+
type: interaction.type,
|
|
409
|
+
selector: interaction.selector,
|
|
410
|
+
label: interaction.label
|
|
411
|
+
},
|
|
412
|
+
before: { url: currentUrl },
|
|
413
|
+
after: { url: currentUrl },
|
|
414
|
+
incremental: true, // Mark as skipped in incremental mode - detect phase will skip this
|
|
415
|
+
resultType: 'INCREMENTAL_SKIP'
|
|
416
|
+
};
|
|
417
|
+
traces.push(skippedTrace);
|
|
418
|
+
|
|
419
|
+
// Track incremental skip as silence
|
|
420
|
+
silenceTracker.record({
|
|
421
|
+
scope: 'interaction',
|
|
422
|
+
reason: 'incremental_unchanged',
|
|
423
|
+
description: `Skipped re-observation (unchanged in incremental mode): ${interaction.label}`,
|
|
424
|
+
context: {
|
|
425
|
+
currentPage: currentUrl,
|
|
426
|
+
selector: interaction.selector,
|
|
427
|
+
interactionLabel: interaction.label,
|
|
428
|
+
type: interaction.type
|
|
429
|
+
},
|
|
430
|
+
impact: 'affects_expectations'
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
skippedInteractions.push({
|
|
434
|
+
interaction: {
|
|
435
|
+
type: interaction.type,
|
|
436
|
+
selector: interaction.selector,
|
|
437
|
+
label: interaction.label,
|
|
438
|
+
text: interaction.text
|
|
439
|
+
},
|
|
440
|
+
outcome: 'SKIPPED',
|
|
441
|
+
reason: 'incremental_unchanged',
|
|
442
|
+
url: currentUrl,
|
|
443
|
+
evidence: {
|
|
444
|
+
selector: interaction.selector,
|
|
445
|
+
label: interaction.label,
|
|
446
|
+
incremental: true
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Skip dangerous interactions (logout, delete, etc.) with explicit reason
|
|
454
|
+
const skipCheck = frontier.shouldSkipInteraction(interaction);
|
|
455
|
+
if (skipCheck.skip) {
|
|
456
|
+
// Track safety skip as silence
|
|
457
|
+
silenceTracker.record({
|
|
458
|
+
scope: 'interaction',
|
|
459
|
+
reason: skipCheck.reason === 'destructive' ? 'destructive_text' : 'unsafe_pattern',
|
|
460
|
+
description: `Skipped potentially dangerous interaction: ${interaction.label}`,
|
|
461
|
+
context: {
|
|
462
|
+
currentPage: page.url(),
|
|
463
|
+
selector: interaction.selector,
|
|
464
|
+
interactionLabel: interaction.label,
|
|
465
|
+
text: interaction.text,
|
|
466
|
+
skipReason: skipCheck.reason,
|
|
467
|
+
skipMessage: skipCheck.message
|
|
468
|
+
},
|
|
469
|
+
impact: 'unknown_behavior'
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
skippedInteractions.push({
|
|
473
|
+
interaction: {
|
|
474
|
+
type: interaction.type,
|
|
475
|
+
selector: interaction.selector,
|
|
476
|
+
label: interaction.label,
|
|
477
|
+
text: interaction.text
|
|
478
|
+
},
|
|
479
|
+
outcome: 'SKIPPED',
|
|
480
|
+
reason: skipCheck.reason || 'safety_policy',
|
|
481
|
+
url: page.url(),
|
|
482
|
+
evidence: {
|
|
483
|
+
selector: interaction.selector,
|
|
484
|
+
label: interaction.label,
|
|
485
|
+
text: interaction.text,
|
|
486
|
+
sourcePage: page.url()
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Phase 4: Check action classification and safety mode
|
|
493
|
+
const { shouldBlockAction } = await import('../core/action-classifier.js');
|
|
494
|
+
const blockCheck = shouldBlockAction(interaction, { allowWrites, allowRiskyActions });
|
|
495
|
+
|
|
496
|
+
if (blockCheck.shouldBlock) {
|
|
497
|
+
// Track blocked action as silence
|
|
498
|
+
silenceTracker.record({
|
|
499
|
+
scope: 'safety',
|
|
500
|
+
reason: 'blocked_action',
|
|
501
|
+
description: `Action blocked by safety mode: ${interaction.label} (${blockCheck.classification})`,
|
|
502
|
+
context: {
|
|
503
|
+
currentPage: page.url(),
|
|
504
|
+
selector: interaction.selector,
|
|
505
|
+
interactionLabel: interaction.label,
|
|
506
|
+
text: interaction.text,
|
|
507
|
+
classification: blockCheck.classification,
|
|
508
|
+
blockReason: blockCheck.reason
|
|
509
|
+
},
|
|
510
|
+
impact: 'action_blocked'
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
skippedInteractions.push({
|
|
514
|
+
interaction: {
|
|
515
|
+
type: interaction.type,
|
|
516
|
+
selector: interaction.selector,
|
|
517
|
+
label: interaction.label,
|
|
518
|
+
text: interaction.text
|
|
519
|
+
},
|
|
520
|
+
outcome: 'BLOCKED',
|
|
521
|
+
reason: 'safety_mode',
|
|
522
|
+
classification: blockCheck.classification,
|
|
523
|
+
url: page.url(),
|
|
524
|
+
evidence: {
|
|
525
|
+
selector: interaction.selector,
|
|
526
|
+
label: interaction.label,
|
|
527
|
+
text: interaction.text,
|
|
528
|
+
classification: blockCheck.classification,
|
|
529
|
+
sourcePage: page.url()
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// Create a minimal trace for blocked interactions so they appear in output
|
|
534
|
+
const blockedTrace = {
|
|
535
|
+
interaction: {
|
|
536
|
+
type: interaction.type,
|
|
537
|
+
selector: interaction.selector,
|
|
538
|
+
label: interaction.label,
|
|
539
|
+
text: interaction.text
|
|
540
|
+
},
|
|
541
|
+
before: {
|
|
542
|
+
url: page.url(),
|
|
543
|
+
screenshot: null
|
|
544
|
+
},
|
|
545
|
+
after: {
|
|
546
|
+
url: page.url(),
|
|
547
|
+
screenshot: null
|
|
548
|
+
},
|
|
549
|
+
policy: {
|
|
550
|
+
actionBlocked: true,
|
|
551
|
+
classification: blockCheck.classification,
|
|
552
|
+
reason: blockCheck.reason
|
|
553
|
+
},
|
|
554
|
+
outcome: 'BLOCKED_BY_SAFETY_MODE',
|
|
555
|
+
timestamp: Date.now()
|
|
556
|
+
};
|
|
557
|
+
traces.push(blockedTrace);
|
|
558
|
+
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const beforeUrl = page.url();
|
|
563
|
+
const interactionIndex = totalInteractionsExecuted;
|
|
564
|
+
const trace = await runInteraction(
|
|
565
|
+
page,
|
|
566
|
+
interaction,
|
|
567
|
+
timestamp,
|
|
568
|
+
interactionIndex,
|
|
569
|
+
screenshotsDir,
|
|
570
|
+
baseOrigin,
|
|
571
|
+
startTime,
|
|
572
|
+
routeBudget, // Use route-specific budget
|
|
573
|
+
null,
|
|
574
|
+
silenceTracker // Pass silence tracker
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
// Mark trace with incremental flag if applicable
|
|
578
|
+
if (incrementalMode && trace) {
|
|
579
|
+
trace.incremental = false; // This interaction was executed, not skipped
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let repeatTrace = null;
|
|
583
|
+
|
|
584
|
+
if (trace) {
|
|
585
|
+
const matchingExpectation = expectationResults?.results?.find(r => r.trace?.interaction?.selector === trace.interaction.selector);
|
|
586
|
+
if (matchingExpectation) {
|
|
587
|
+
trace.expectationDriven = true;
|
|
588
|
+
trace.expectationId = matchingExpectation.expectationId;
|
|
589
|
+
trace.expectationOutcome = matchingExpectation.outcome;
|
|
590
|
+
} else {
|
|
591
|
+
const observedExpectation = deriveObservedExpectation(interaction, trace, baseOrigin);
|
|
592
|
+
if (observedExpectation) {
|
|
593
|
+
trace.observedExpectation = observedExpectation;
|
|
594
|
+
trace.resultType = 'OBSERVED_EXPECTATION';
|
|
595
|
+
observedExpectations.push(observedExpectation);
|
|
596
|
+
|
|
597
|
+
const repeatEligible = shouldAttemptRepeatObservedExpectation(observedExpectation, trace);
|
|
598
|
+
const budgetAllowsRepeat = repeatEligible &&
|
|
599
|
+
(Date.now() - startTime) < scanBudget.maxScanDurationMs &&
|
|
600
|
+
(totalInteractionsExecuted + 1) < scanBudget.maxTotalInteractions;
|
|
601
|
+
|
|
602
|
+
if (budgetAllowsRepeat) {
|
|
603
|
+
const repeatIndex = totalInteractionsExecuted + 1;
|
|
604
|
+
const repeatResult = await repeatObservedInteraction(
|
|
605
|
+
page,
|
|
606
|
+
interaction,
|
|
607
|
+
observedExpectation,
|
|
608
|
+
timestamp,
|
|
609
|
+
repeatIndex,
|
|
610
|
+
screenshotsDir,
|
|
611
|
+
baseOrigin,
|
|
612
|
+
startTime,
|
|
613
|
+
scanBudget
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
if (repeatResult) {
|
|
617
|
+
const repeatEvaluation = repeatResult.repeatEvaluation;
|
|
618
|
+
trace.observedExpectation.repeatAttempted = true;
|
|
619
|
+
trace.observedExpectation.repeated = repeatEvaluation.outcome === 'VERIFIED';
|
|
620
|
+
trace.observedExpectation.repeatOutcome = repeatEvaluation.outcome;
|
|
621
|
+
trace.observedExpectation.repeatReason = repeatEvaluation.reason;
|
|
622
|
+
|
|
623
|
+
if (repeatEvaluation.outcome === 'OBSERVED_BREAK') {
|
|
624
|
+
trace.observedExpectation.outcome = 'OBSERVED_BREAK';
|
|
625
|
+
trace.observedExpectation.reason = 'inconsistent_on_repeat';
|
|
626
|
+
trace.observedExpectation.confidenceLevel = 'LOW';
|
|
627
|
+
} else if (trace.observedExpectation.repeated && trace.observedExpectation.outcome === 'VERIFIED') {
|
|
628
|
+
trace.observedExpectation.confidenceLevel = 'MEDIUM';
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
repeatTrace = repeatResult.repeatTrace;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
trace.unprovenResult = true;
|
|
636
|
+
trace.resultType = 'UNPROVEN_RESULT';
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
traces.push(trace);
|
|
641
|
+
totalInteractionsExecuted++;
|
|
642
|
+
|
|
643
|
+
if (repeatTrace) {
|
|
644
|
+
traces.push(repeatTrace);
|
|
645
|
+
totalInteractionsExecuted++;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const afterUrl = trace.after?.url || page.url();
|
|
649
|
+
const navigatedSameOrigin = afterUrl && afterUrl !== beforeUrl && !isExternalUrl(afterUrl, baseOrigin);
|
|
650
|
+
if (navigatedSameOrigin && interaction.type === 'link') {
|
|
651
|
+
// Link navigation - add new page to frontier (if not already visited)
|
|
652
|
+
const normalizedAfter = frontier.normalizeUrl(afterUrl);
|
|
653
|
+
const wasAlreadyVisited = frontier.visited.has(normalizedAfter);
|
|
654
|
+
if (!wasAlreadyVisited) {
|
|
655
|
+
const added = frontier.addUrl(afterUrl);
|
|
656
|
+
// If frontier was capped, record coverage gap
|
|
657
|
+
if (!added && frontier.frontierCapped) {
|
|
658
|
+
remainingInteractionsGaps.push({
|
|
659
|
+
interaction: {
|
|
660
|
+
type: 'link',
|
|
661
|
+
selector: interaction.selector,
|
|
662
|
+
label: interaction.label
|
|
663
|
+
},
|
|
664
|
+
reason: 'frontier_capped',
|
|
665
|
+
url: afterUrl
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Stay on the new page and continue executing interactions there
|
|
671
|
+
navigatedToNewPage = true;
|
|
672
|
+
navigatedPageUrl = afterUrl;
|
|
673
|
+
|
|
674
|
+
// Discover links on the new page immediately
|
|
675
|
+
try {
|
|
676
|
+
const newPageLinks = await page.locator('a[href]').all();
|
|
677
|
+
for (const link of newPageLinks) {
|
|
678
|
+
try {
|
|
679
|
+
const href = await link.getAttribute('href');
|
|
680
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
681
|
+
const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
|
|
682
|
+
if (!isExternalUrl(resolvedUrl, baseOrigin)) {
|
|
683
|
+
frontier.addUrl(resolvedUrl);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
} catch (error) {
|
|
687
|
+
// Record invalid URL discovery as silence
|
|
688
|
+
silenceTracker.record({
|
|
689
|
+
scope: 'discovery',
|
|
690
|
+
reason: 'discovery_error',
|
|
691
|
+
description: 'Invalid or unreadable link during discovery',
|
|
692
|
+
context: { pageUrl: page.url() },
|
|
693
|
+
impact: 'incomplete_check'
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
} catch (error) {
|
|
698
|
+
// Record link discovery failure as silence
|
|
699
|
+
silenceTracker.record({
|
|
700
|
+
scope: 'discovery',
|
|
701
|
+
reason: 'discovery_error',
|
|
702
|
+
description: 'Link discovery failed on page',
|
|
703
|
+
context: { pageUrl: page.url() },
|
|
704
|
+
impact: 'incomplete_check'
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Break to restart loop on new page
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Mark remaining interactions as COVERAGE_GAP if we stopped early
|
|
715
|
+
if (remainingInteractionsStartIndex > 0 && remainingInteractionsStartIndex < sortedInteractions.length && !navigatedToNewPage) {
|
|
716
|
+
for (let j = remainingInteractionsStartIndex; j < sortedInteractions.length; j++) {
|
|
717
|
+
const reason = totalInteractionsExecuted >= scanBudget.maxTotalInteractions ? 'budget_exceeded' :
|
|
718
|
+
(totalInteractionsExecuted >= routeBudget.maxInteractionsPerPage ? 'route_budget_exceeded' : 'budget_exceeded');
|
|
719
|
+
remainingInteractionsGaps.push({
|
|
720
|
+
interaction: {
|
|
721
|
+
type: sortedInteractions[j].type,
|
|
722
|
+
selector: sortedInteractions[j].selector,
|
|
723
|
+
label: sortedInteractions[j].label
|
|
724
|
+
},
|
|
725
|
+
reason: reason,
|
|
726
|
+
url: currentUrl
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// If we navigated to a new page, stay on it and continue (next iteration will handle it)
|
|
732
|
+
if (navigatedToNewPage && navigatedPageUrl) {
|
|
733
|
+
// Don't mark as visited yet - we'll do it at the start of next iteration
|
|
734
|
+
// This ensures the page counter is incremented when we process the page
|
|
735
|
+
// Set nextPageUrl to the navigated page so we process it in the next iteration
|
|
736
|
+
nextPageUrl = navigatedPageUrl;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// After executing all interactions on current page, move to next page in frontier
|
|
741
|
+
nextPageUrl = frontier.getNextUrl();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Combine all coverage gaps
|
|
745
|
+
if (remainingInteractionsGaps.length > 0) {
|
|
746
|
+
expectationCoverageGaps.push(...remainingInteractionsGaps.map(gap => ({
|
|
747
|
+
expectationId: null,
|
|
748
|
+
type: gap.interaction.type,
|
|
749
|
+
reason: gap.reason,
|
|
750
|
+
fromPath: gap.url,
|
|
751
|
+
source: null,
|
|
752
|
+
evidence: {
|
|
753
|
+
interaction: gap.interaction
|
|
754
|
+
}
|
|
755
|
+
})));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Record frontier capping as coverage gap if it occurred
|
|
759
|
+
if (frontier.frontierCapped) {
|
|
760
|
+
expectationCoverageGaps.push({
|
|
761
|
+
expectationId: null,
|
|
762
|
+
type: 'navigation',
|
|
763
|
+
reason: 'frontier_capped',
|
|
764
|
+
fromPath: page.url(),
|
|
765
|
+
source: null,
|
|
766
|
+
evidence: {
|
|
767
|
+
message: `Frontier capped at ${scanBudget.maxUniqueUrls || 'unlimited'} unique URLs`
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Build coverage object matching writeTraces expected format
|
|
773
|
+
const coverage = {
|
|
774
|
+
candidatesDiscovered: totalInteractionsDiscovered,
|
|
775
|
+
candidatesSelected: totalInteractionsExecuted,
|
|
776
|
+
cap: scanBudget.maxTotalInteractions,
|
|
777
|
+
capped: totalInteractionsExecuted >= scanBudget.maxTotalInteractions || remainingInteractionsGaps.length > 0,
|
|
778
|
+
pagesVisited: frontier.pagesVisited,
|
|
779
|
+
pagesDiscovered: frontier.pagesDiscovered,
|
|
780
|
+
skippedInteractions: skippedInteractions.length,
|
|
781
|
+
interactionsDiscovered: totalInteractionsDiscovered,
|
|
782
|
+
interactionsExecuted: totalInteractionsExecuted
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
// Ensure we increment pagesVisited when we navigate via getNextUrl()
|
|
786
|
+
// getNextUrl() marks as visited but doesn't increment counter - we do it here
|
|
787
|
+
// BUT: when alreadyOnPage is true, we've already marked it, so don't double-count
|
|
788
|
+
|
|
789
|
+
// Record warnings
|
|
790
|
+
if (coverage.capped) {
|
|
38
791
|
observeWarnings.push({
|
|
39
792
|
code: 'INTERACTIONS_CAPPED',
|
|
40
|
-
message:
|
|
793
|
+
message: `Interaction execution capped. Visited ${coverage.pagesVisited} pages, discovered ${coverage.pagesDiscovered}, executed ${coverage.candidatesSelected} of ${coverage.candidatesDiscovered} interactions. Coverage incomplete.`
|
|
41
794
|
});
|
|
42
795
|
}
|
|
43
796
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
797
|
+
if (skippedInteractions.length > 0) {
|
|
798
|
+
observeWarnings.push({
|
|
799
|
+
code: 'INTERACTIONS_SKIPPED',
|
|
800
|
+
message: `Skipped ${skippedInteractions.length} dangerous interactions`,
|
|
801
|
+
details: skippedInteractions
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Append expectation traces for completeness
|
|
806
|
+
if (expectationResults && expectationResults.results) {
|
|
807
|
+
for (const result of expectationResults.results) {
|
|
808
|
+
if (result.trace) {
|
|
809
|
+
result.trace.expectationDriven = true;
|
|
810
|
+
result.trace.expectationId = result.expectationId;
|
|
811
|
+
result.trace.expectationOutcome = result.outcome;
|
|
812
|
+
traces.push(result.trace);
|
|
813
|
+
}
|
|
47
814
|
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const observation = writeTraces(projectDir, url, traces, coverage, observeWarnings, observedExpectations, silenceTracker, runId);
|
|
818
|
+
|
|
819
|
+
// Add silence tracking to observation result
|
|
820
|
+
observation.silences = silenceTracker.getDetailedSummary();
|
|
821
|
+
|
|
822
|
+
if (expectationResults) {
|
|
823
|
+
observation.expectationExecution = {
|
|
824
|
+
totalProvenExpectations: expectationResults.totalProvenExpectations,
|
|
825
|
+
executedCount: expectationResults.executedCount,
|
|
826
|
+
coverageGapsCount: expectationCoverageGaps.length,
|
|
827
|
+
results: expectationResults.results.map(r => ({
|
|
828
|
+
expectationId: r.expectationId,
|
|
829
|
+
type: r.type,
|
|
830
|
+
fromPath: r.fromPath,
|
|
831
|
+
outcome: r.outcome,
|
|
832
|
+
reason: r.reason
|
|
833
|
+
}))
|
|
834
|
+
};
|
|
835
|
+
observation.expectationCoverageGaps = expectationCoverageGaps;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// SCALE INTELLIGENCE: Save snapshot for next incremental run
|
|
839
|
+
if (manifest) {
|
|
840
|
+
// Build snapshot from current run (extract interactions from traces)
|
|
841
|
+
const observedInteractions = traces
|
|
842
|
+
.filter(t => t.interaction && !t.incremental)
|
|
843
|
+
.map(t => ({
|
|
844
|
+
type: t.interaction?.type,
|
|
845
|
+
selector: t.interaction?.selector,
|
|
846
|
+
url: t.before?.url || url
|
|
847
|
+
}));
|
|
48
848
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
849
|
+
const currentSnapshot = buildSnapshot(manifest, observedInteractions);
|
|
850
|
+
saveSnapshot(projectDir, currentSnapshot);
|
|
851
|
+
|
|
852
|
+
// Add incremental mode metadata to observation
|
|
853
|
+
observation.incremental = {
|
|
854
|
+
enabled: incrementalMode,
|
|
855
|
+
snapshotDiff: snapshotDiff,
|
|
856
|
+
skippedInteractionsCount: skippedInteractions.filter(s => s.reason === 'incremental_unchanged').length
|
|
857
|
+
};
|
|
53
858
|
}
|
|
859
|
+
|
|
860
|
+
await closeBrowser(browser);
|
|
54
861
|
|
|
55
|
-
|
|
862
|
+
// PHASE 6: Export determinism decisions to run directory
|
|
863
|
+
if (runId && projectDir) {
|
|
864
|
+
const runsDir = resolve(projectDir, '.verax', 'runs', runId);
|
|
865
|
+
mkdirSync(runsDir, { recursive: true });
|
|
866
|
+
const decisionsPath = resolve(runsDir, 'decisions.json');
|
|
867
|
+
const decisionsData = JSON.stringify(decisionRecorder.export(), null, 2);
|
|
868
|
+
require('fs').writeFileSync(decisionsPath, decisionsData, 'utf-8');
|
|
869
|
+
}
|
|
56
870
|
|
|
57
|
-
|
|
871
|
+
// Phase 4: Add safety mode statistics
|
|
872
|
+
const safetyBlocks = {
|
|
873
|
+
actionsBlocked: skippedInteractions.filter(s => s.reason === 'safety_mode').length,
|
|
874
|
+
networkWritesBlocked: blockedNetworkWrites.length,
|
|
875
|
+
crossOriginBlocked: blockedCrossOrigin.length,
|
|
876
|
+
blockedActions: skippedInteractions.filter(s => s.reason === 'safety_mode').map(s => ({
|
|
877
|
+
label: s.interaction.label,
|
|
878
|
+
classification: s.classification,
|
|
879
|
+
url: s.url
|
|
880
|
+
})),
|
|
881
|
+
blockedNetworkWrites: blockedNetworkWrites.slice(0, 5), // Top 5
|
|
882
|
+
blockedCrossOrigin: blockedCrossOrigin.slice(0, 5) // Top 5
|
|
883
|
+
};
|
|
58
884
|
|
|
59
885
|
return {
|
|
60
886
|
...observation,
|
|
61
|
-
screenshotsDir: screenshotsDir
|
|
887
|
+
screenshotsDir: screenshotsDir,
|
|
888
|
+
safetyBlocks
|
|
62
889
|
};
|
|
63
890
|
} catch (error) {
|
|
64
891
|
await closeBrowser(browser);
|
|
65
892
|
throw error;
|
|
66
893
|
}
|
|
67
894
|
}
|
|
895
|
+
|
|
896
|
+
async function repeatObservedInteraction(
|
|
897
|
+
page,
|
|
898
|
+
interaction,
|
|
899
|
+
observedExpectation,
|
|
900
|
+
timestamp,
|
|
901
|
+
interactionIndex,
|
|
902
|
+
screenshotsDir,
|
|
903
|
+
baseOrigin,
|
|
904
|
+
startTime,
|
|
905
|
+
scanBudget
|
|
906
|
+
) {
|
|
907
|
+
const selector = observedExpectation.evidence?.selector || interaction.selector;
|
|
908
|
+
if (!selector) return null;
|
|
909
|
+
|
|
910
|
+
const locator = page.locator(selector).first();
|
|
911
|
+
const count = await locator.count();
|
|
912
|
+
if (count === 0) {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const repeatInteraction = {
|
|
917
|
+
...interaction,
|
|
918
|
+
element: locator
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const repeatTrace = await runInteraction(
|
|
922
|
+
page,
|
|
923
|
+
repeatInteraction,
|
|
924
|
+
timestamp,
|
|
925
|
+
interactionIndex,
|
|
926
|
+
screenshotsDir,
|
|
927
|
+
baseOrigin,
|
|
928
|
+
startTime,
|
|
929
|
+
scanBudget,
|
|
930
|
+
null,
|
|
931
|
+
null // No silence tracker for repeat executions (not counted as new silence)
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
if (!repeatTrace) {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
repeatTrace.repeatExecution = true;
|
|
939
|
+
repeatTrace.repeatOfObservedExpectationId = observedExpectation.id;
|
|
940
|
+
repeatTrace.resultType = 'OBSERVED_EXPECTATION_REPEAT';
|
|
941
|
+
|
|
942
|
+
const repeatEvaluation = evaluateObservedExpectation(observedExpectation, repeatTrace);
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
repeatTrace,
|
|
946
|
+
repeatEvaluation
|
|
947
|
+
};
|
|
948
|
+
}
|