@veraxhq/verax 0.2.1 → 0.3.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 +14 -18
- package/bin/verax.js +7 -0
- package/package.json +3 -3
- package/src/cli/commands/baseline.js +104 -0
- package/src/cli/commands/default.js +79 -25
- package/src/cli/commands/ga.js +243 -0
- package/src/cli/commands/gates.js +95 -0
- package/src/cli/commands/inspect.js +131 -2
- package/src/cli/commands/release-check.js +213 -0
- package/src/cli/commands/run.js +246 -35
- package/src/cli/commands/security-check.js +211 -0
- package/src/cli/commands/truth.js +114 -0
- package/src/cli/entry.js +304 -67
- package/src/cli/util/angular-component-extractor.js +179 -0
- package/src/cli/util/angular-navigation-detector.js +141 -0
- package/src/cli/util/angular-network-detector.js +161 -0
- package/src/cli/util/angular-state-detector.js +162 -0
- package/src/cli/util/ast-interactive-detector.js +546 -0
- package/src/cli/util/ast-network-detector.js +603 -0
- package/src/cli/util/ast-usestate-detector.js +602 -0
- package/src/cli/util/bootstrap-guard.js +86 -0
- package/src/cli/util/determinism-runner.js +123 -0
- package/src/cli/util/determinism-writer.js +129 -0
- package/src/cli/util/env-url.js +4 -0
- package/src/cli/util/expectation-extractor.js +369 -73
- package/src/cli/util/findings-writer.js +126 -16
- package/src/cli/util/learn-writer.js +3 -1
- package/src/cli/util/observe-writer.js +3 -1
- package/src/cli/util/paths.js +3 -12
- package/src/cli/util/project-discovery.js +3 -0
- package/src/cli/util/project-writer.js +3 -1
- package/src/cli/util/run-resolver.js +64 -0
- package/src/cli/util/source-requirement.js +55 -0
- package/src/cli/util/summary-writer.js +1 -0
- package/src/cli/util/svelte-navigation-detector.js +163 -0
- package/src/cli/util/svelte-network-detector.js +80 -0
- package/src/cli/util/svelte-sfc-extractor.js +147 -0
- package/src/cli/util/svelte-state-detector.js +243 -0
- package/src/cli/util/vue-navigation-detector.js +177 -0
- package/src/cli/util/vue-sfc-extractor.js +162 -0
- package/src/cli/util/vue-state-detector.js +215 -0
- package/src/verax/cli/finding-explainer.js +56 -3
- package/src/verax/core/artifacts/registry.js +154 -0
- package/src/verax/core/artifacts/verifier.js +980 -0
- package/src/verax/core/baseline/baseline.enforcer.js +137 -0
- package/src/verax/core/baseline/baseline.snapshot.js +231 -0
- package/src/verax/core/capabilities/gates.js +499 -0
- package/src/verax/core/capabilities/registry.js +475 -0
- package/src/verax/core/confidence/confidence-compute.js +137 -0
- package/src/verax/core/confidence/confidence-invariants.js +234 -0
- package/src/verax/core/confidence/confidence-report-writer.js +112 -0
- package/src/verax/core/confidence/confidence-weights.js +44 -0
- package/src/verax/core/confidence/confidence.defaults.js +65 -0
- package/src/verax/core/confidence/confidence.loader.js +79 -0
- package/src/verax/core/confidence/confidence.schema.js +94 -0
- package/src/verax/core/confidence-engine-refactor.js +484 -0
- package/src/verax/core/confidence-engine.js +486 -0
- package/src/verax/core/confidence-engine.js.backup +471 -0
- package/src/verax/core/contracts/index.js +29 -0
- package/src/verax/core/contracts/types.js +185 -0
- package/src/verax/core/contracts/validators.js +381 -0
- package/src/verax/core/decision-snapshot.js +30 -3
- package/src/verax/core/decisions/decision.trace.js +276 -0
- package/src/verax/core/determinism/contract-writer.js +89 -0
- package/src/verax/core/determinism/contract.js +139 -0
- package/src/verax/core/determinism/diff.js +364 -0
- package/src/verax/core/determinism/engine.js +221 -0
- package/src/verax/core/determinism/finding-identity.js +148 -0
- package/src/verax/core/determinism/normalize.js +438 -0
- package/src/verax/core/determinism/report-writer.js +92 -0
- package/src/verax/core/determinism/run-fingerprint.js +118 -0
- package/src/verax/core/dynamic-route-intelligence.js +528 -0
- package/src/verax/core/evidence/evidence-capture-service.js +307 -0
- package/src/verax/core/evidence/evidence-intent-ledger.js +165 -0
- package/src/verax/core/evidence-builder.js +487 -0
- package/src/verax/core/execution-mode-context.js +77 -0
- package/src/verax/core/execution-mode-detector.js +190 -0
- package/src/verax/core/failures/exit-codes.js +86 -0
- package/src/verax/core/failures/failure-summary.js +76 -0
- package/src/verax/core/failures/failure.factory.js +225 -0
- package/src/verax/core/failures/failure.ledger.js +132 -0
- package/src/verax/core/failures/failure.types.js +196 -0
- package/src/verax/core/failures/index.js +10 -0
- package/src/verax/core/ga/ga-report-writer.js +43 -0
- package/src/verax/core/ga/ga.artifact.js +49 -0
- package/src/verax/core/ga/ga.contract.js +434 -0
- package/src/verax/core/ga/ga.enforcer.js +86 -0
- package/src/verax/core/guardrails/guardrails-report-writer.js +109 -0
- package/src/verax/core/guardrails/policy.defaults.js +210 -0
- package/src/verax/core/guardrails/policy.loader.js +83 -0
- package/src/verax/core/guardrails/policy.schema.js +110 -0
- package/src/verax/core/guardrails/truth-reconciliation.js +136 -0
- package/src/verax/core/guardrails-engine.js +505 -0
- package/src/verax/core/observe/run-timeline.js +316 -0
- package/src/verax/core/perf/perf.contract.js +186 -0
- package/src/verax/core/perf/perf.display.js +65 -0
- package/src/verax/core/perf/perf.enforcer.js +91 -0
- package/src/verax/core/perf/perf.monitor.js +209 -0
- package/src/verax/core/perf/perf.report.js +198 -0
- package/src/verax/core/pipeline-tracker.js +238 -0
- package/src/verax/core/product-definition.js +127 -0
- package/src/verax/core/release/provenance.builder.js +271 -0
- package/src/verax/core/release/release-report-writer.js +40 -0
- package/src/verax/core/release/release.enforcer.js +159 -0
- package/src/verax/core/release/reproducibility.check.js +221 -0
- package/src/verax/core/release/sbom.builder.js +283 -0
- package/src/verax/core/report/cross-index.js +192 -0
- package/src/verax/core/report/human-summary.js +222 -0
- package/src/verax/core/route-intelligence.js +419 -0
- package/src/verax/core/security/secrets.scan.js +326 -0
- package/src/verax/core/security/security-report.js +50 -0
- package/src/verax/core/security/security.enforcer.js +124 -0
- package/src/verax/core/security/supplychain.defaults.json +38 -0
- package/src/verax/core/security/supplychain.policy.js +326 -0
- package/src/verax/core/security/vuln.scan.js +265 -0
- package/src/verax/core/truth/truth.certificate.js +250 -0
- package/src/verax/core/ui-feedback-intelligence.js +515 -0
- package/src/verax/detect/confidence-engine.js +628 -40
- package/src/verax/detect/confidence-helper.js +33 -0
- package/src/verax/detect/detection-engine.js +18 -1
- package/src/verax/detect/dynamic-route-findings.js +335 -0
- package/src/verax/detect/expectation-chain-detector.js +417 -0
- package/src/verax/detect/expectation-model.js +3 -1
- package/src/verax/detect/findings-writer.js +141 -5
- package/src/verax/detect/index.js +229 -5
- package/src/verax/detect/journey-stall-detector.js +558 -0
- package/src/verax/detect/route-findings.js +218 -0
- package/src/verax/detect/ui-feedback-findings.js +207 -0
- package/src/verax/detect/verdict-engine.js +57 -3
- package/src/verax/detect/view-switch-correlator.js +242 -0
- package/src/verax/index.js +413 -45
- package/src/verax/learn/action-contract-extractor.js +682 -64
- package/src/verax/learn/route-validator.js +4 -1
- package/src/verax/observe/index.js +88 -843
- package/src/verax/observe/interaction-runner.js +25 -8
- package/src/verax/observe/observe-context.js +205 -0
- package/src/verax/observe/observe-helpers.js +191 -0
- package/src/verax/observe/observe-runner.js +226 -0
- package/src/verax/observe/observers/budget-observer.js +185 -0
- package/src/verax/observe/observers/console-observer.js +102 -0
- package/src/verax/observe/observers/coverage-observer.js +107 -0
- package/src/verax/observe/observers/interaction-observer.js +471 -0
- package/src/verax/observe/observers/navigation-observer.js +132 -0
- package/src/verax/observe/observers/network-observer.js +87 -0
- package/src/verax/observe/observers/safety-observer.js +82 -0
- package/src/verax/observe/observers/ui-feedback-observer.js +99 -0
- package/src/verax/observe/ui-feedback-detector.js +742 -0
- package/src/verax/observe/ui-signal-sensor.js +148 -2
- package/src/verax/scan-summary-writer.js +42 -8
- package/src/verax/shared/artifact-manager.js +8 -5
- package/src/verax/shared/css-spinner-rules.js +204 -0
- package/src/verax/shared/view-switch-rules.js +208 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PHASE 21.3 — Interaction Observer
|
|
3
|
+
*
|
|
4
|
+
* Extracted interaction-related logic from observe-runner.js
|
|
5
|
+
* Handles interaction discovery, execution, and processing
|
|
6
|
+
*
|
|
7
|
+
* NO file I/O - all artifacts written by caller
|
|
8
|
+
* NO loop control - caller controls iteration
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { discoverAllInteractions } from '../interaction-discovery.js';
|
|
12
|
+
import { runInteraction } from '../interaction-runner.js';
|
|
13
|
+
import { isExternalUrl } from '../domain-boundary.js';
|
|
14
|
+
import { computeRouteBudget } from '../../core/budget-engine.js';
|
|
15
|
+
import { shouldSkipInteractionIncremental } from '../../core/incremental-store.js';
|
|
16
|
+
import { deriveObservedExpectation, shouldAttemptRepeatObservedExpectation, evaluateObservedExpectation } from '../observed-expectation.js';
|
|
17
|
+
import { discoverPageLinks } from './navigation-observer.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Discover interactions on current page
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} context
|
|
23
|
+
* @param {import('playwright').Page} context.page
|
|
24
|
+
* @param {string} context.baseOrigin
|
|
25
|
+
* @param {Object} context.scanBudget
|
|
26
|
+
* @param {Object|null} context.manifest
|
|
27
|
+
* @param {string} context.currentUrl
|
|
28
|
+
* @returns {Promise<Object>} { interactions, routeBudget, totalDiscovered }
|
|
29
|
+
*/
|
|
30
|
+
export async function discoverInteractions(context) {
|
|
31
|
+
const { page, baseOrigin, scanBudget, manifest, currentUrl } = context;
|
|
32
|
+
|
|
33
|
+
// SCALE INTELLIGENCE: Compute adaptive budget for this route
|
|
34
|
+
const routeBudget = manifest ? computeRouteBudget(manifest, currentUrl, scanBudget) : scanBudget;
|
|
35
|
+
|
|
36
|
+
// Discover ALL interactions on this page
|
|
37
|
+
// Note: discoverAllInteractions already returns sorted interactions deterministically
|
|
38
|
+
const { interactions } = await discoverAllInteractions(page, baseOrigin, routeBudget);
|
|
39
|
+
|
|
40
|
+
// SCALE INTELLIGENCE: Apply adaptive budget cap (interactions are already sorted deterministically)
|
|
41
|
+
// Stable sorting ensures determinism: same interactions → same order
|
|
42
|
+
const sortedInteractions = interactions.slice(0, routeBudget.maxInteractionsPerPage);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
interactions: sortedInteractions,
|
|
46
|
+
routeBudget,
|
|
47
|
+
totalDiscovered: interactions.length
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if interaction should be skipped and handle skip logic
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} context
|
|
55
|
+
* @param {Object} interaction
|
|
56
|
+
* @param {string} currentUrl
|
|
57
|
+
* @param {Array} traces - Mutable array to push skipped traces
|
|
58
|
+
* @param {Array} skippedInteractions - Mutable array to push skipped interactions
|
|
59
|
+
* @param {SilenceTracker} silenceTracker
|
|
60
|
+
* @param {PageFrontier} frontier
|
|
61
|
+
* @param {boolean} incrementalMode
|
|
62
|
+
* @param {Object|null} manifest
|
|
63
|
+
* @param {Object|null} oldSnapshot
|
|
64
|
+
* @param {Object|null} snapshotDiff
|
|
65
|
+
* @param {boolean} allowWrites
|
|
66
|
+
* @param {boolean} allowRiskyActions
|
|
67
|
+
* @returns {Promise<Object>} { skip: boolean, reason?: string, trace?: Object }
|
|
68
|
+
*/
|
|
69
|
+
export async function checkAndSkipInteraction(
|
|
70
|
+
context,
|
|
71
|
+
interaction,
|
|
72
|
+
currentUrl,
|
|
73
|
+
traces,
|
|
74
|
+
skippedInteractions,
|
|
75
|
+
silenceTracker,
|
|
76
|
+
frontier,
|
|
77
|
+
incrementalMode,
|
|
78
|
+
manifest,
|
|
79
|
+
oldSnapshot,
|
|
80
|
+
snapshotDiff,
|
|
81
|
+
allowWrites,
|
|
82
|
+
allowRiskyActions
|
|
83
|
+
) {
|
|
84
|
+
// SCALE INTELLIGENCE: Check if interaction should be skipped in incremental mode
|
|
85
|
+
if (incrementalMode && manifest && oldSnapshot && snapshotDiff) {
|
|
86
|
+
const shouldSkip = shouldSkipInteractionIncremental(interaction, currentUrl, oldSnapshot, snapshotDiff);
|
|
87
|
+
if (shouldSkip) {
|
|
88
|
+
// Create a trace for skipped interaction (marked as incremental - will not produce findings)
|
|
89
|
+
const skippedTrace = {
|
|
90
|
+
interaction: {
|
|
91
|
+
type: interaction.type,
|
|
92
|
+
selector: interaction.selector,
|
|
93
|
+
label: interaction.label
|
|
94
|
+
},
|
|
95
|
+
before: { url: currentUrl },
|
|
96
|
+
after: { url: currentUrl },
|
|
97
|
+
incremental: true, // Mark as skipped in incremental mode - detect phase will skip this
|
|
98
|
+
resultType: 'INCREMENTAL_SKIP'
|
|
99
|
+
};
|
|
100
|
+
traces.push(skippedTrace);
|
|
101
|
+
|
|
102
|
+
// Track incremental skip as silence
|
|
103
|
+
silenceTracker.record({
|
|
104
|
+
scope: 'interaction',
|
|
105
|
+
reason: 'incremental_unchanged',
|
|
106
|
+
description: `Skipped re-observation (unchanged in incremental mode): ${interaction.label}`,
|
|
107
|
+
context: {
|
|
108
|
+
currentPage: currentUrl,
|
|
109
|
+
selector: interaction.selector,
|
|
110
|
+
interactionLabel: interaction.label,
|
|
111
|
+
type: interaction.type
|
|
112
|
+
},
|
|
113
|
+
impact: 'affects_expectations'
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
skippedInteractions.push({
|
|
117
|
+
interaction: {
|
|
118
|
+
type: interaction.type,
|
|
119
|
+
selector: interaction.selector,
|
|
120
|
+
label: interaction.label,
|
|
121
|
+
text: interaction.text
|
|
122
|
+
},
|
|
123
|
+
outcome: 'SKIPPED',
|
|
124
|
+
reason: 'incremental_unchanged',
|
|
125
|
+
url: currentUrl,
|
|
126
|
+
evidence: {
|
|
127
|
+
selector: interaction.selector,
|
|
128
|
+
label: interaction.label,
|
|
129
|
+
incremental: true
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return { skip: true, reason: 'incremental_unchanged' };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Skip dangerous interactions (logout, delete, etc.) with explicit reason
|
|
137
|
+
const skipCheck = frontier.shouldSkipInteraction(interaction);
|
|
138
|
+
if (skipCheck.skip) {
|
|
139
|
+
// Track safety skip as silence
|
|
140
|
+
silenceTracker.record({
|
|
141
|
+
scope: 'interaction',
|
|
142
|
+
reason: skipCheck.reason === 'destructive' ? 'destructive_text' : 'unsafe_pattern',
|
|
143
|
+
description: `Skipped potentially dangerous interaction: ${interaction.label}`,
|
|
144
|
+
context: {
|
|
145
|
+
currentPage: context.page.url(),
|
|
146
|
+
selector: interaction.selector,
|
|
147
|
+
interactionLabel: interaction.label,
|
|
148
|
+
text: interaction.text,
|
|
149
|
+
skipReason: skipCheck.reason,
|
|
150
|
+
skipMessage: skipCheck.message
|
|
151
|
+
},
|
|
152
|
+
impact: 'unknown_behavior'
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
skippedInteractions.push({
|
|
156
|
+
interaction: {
|
|
157
|
+
type: interaction.type,
|
|
158
|
+
selector: interaction.selector,
|
|
159
|
+
label: interaction.label,
|
|
160
|
+
text: interaction.text
|
|
161
|
+
},
|
|
162
|
+
outcome: 'SKIPPED',
|
|
163
|
+
reason: skipCheck.reason || 'safety_policy',
|
|
164
|
+
url: context.page.url(),
|
|
165
|
+
evidence: {
|
|
166
|
+
selector: interaction.selector,
|
|
167
|
+
label: interaction.label,
|
|
168
|
+
text: interaction.text,
|
|
169
|
+
sourcePage: context.page.url()
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
return { skip: true, reason: skipCheck.reason || 'safety_policy' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Phase 4: Check action classification and safety mode
|
|
176
|
+
const { shouldBlockAction } = await import('../../core/action-classifier.js');
|
|
177
|
+
const blockCheck = shouldBlockAction(interaction, { allowWrites, allowRiskyActions });
|
|
178
|
+
|
|
179
|
+
if (blockCheck.shouldBlock) {
|
|
180
|
+
// Track blocked action as silence
|
|
181
|
+
silenceTracker.record({
|
|
182
|
+
scope: 'safety',
|
|
183
|
+
reason: 'blocked_action',
|
|
184
|
+
description: `Action blocked by safety mode: ${interaction.label} (${blockCheck.classification})`,
|
|
185
|
+
context: {
|
|
186
|
+
currentPage: context.page.url(),
|
|
187
|
+
selector: interaction.selector,
|
|
188
|
+
interactionLabel: interaction.label,
|
|
189
|
+
text: interaction.text,
|
|
190
|
+
classification: blockCheck.classification,
|
|
191
|
+
blockReason: blockCheck.reason
|
|
192
|
+
},
|
|
193
|
+
impact: 'action_blocked'
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
skippedInteractions.push({
|
|
197
|
+
interaction: {
|
|
198
|
+
type: interaction.type,
|
|
199
|
+
selector: interaction.selector,
|
|
200
|
+
label: interaction.label,
|
|
201
|
+
text: interaction.text
|
|
202
|
+
},
|
|
203
|
+
outcome: 'BLOCKED',
|
|
204
|
+
reason: 'safety_mode',
|
|
205
|
+
classification: blockCheck.classification,
|
|
206
|
+
url: context.page.url(),
|
|
207
|
+
evidence: {
|
|
208
|
+
selector: interaction.selector,
|
|
209
|
+
label: interaction.label,
|
|
210
|
+
text: interaction.text,
|
|
211
|
+
classification: blockCheck.classification,
|
|
212
|
+
sourcePage: context.page.url()
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Create a minimal trace for blocked interactions so they appear in output
|
|
217
|
+
const blockedTrace = {
|
|
218
|
+
interaction: {
|
|
219
|
+
type: interaction.type,
|
|
220
|
+
selector: interaction.selector,
|
|
221
|
+
label: interaction.label,
|
|
222
|
+
text: interaction.text
|
|
223
|
+
},
|
|
224
|
+
before: {
|
|
225
|
+
url: context.page.url(),
|
|
226
|
+
screenshot: null
|
|
227
|
+
},
|
|
228
|
+
after: {
|
|
229
|
+
url: context.page.url(),
|
|
230
|
+
screenshot: null
|
|
231
|
+
},
|
|
232
|
+
policy: {
|
|
233
|
+
actionBlocked: true,
|
|
234
|
+
classification: blockCheck.classification,
|
|
235
|
+
reason: blockCheck.reason
|
|
236
|
+
},
|
|
237
|
+
outcome: 'BLOCKED_BY_SAFETY_MODE',
|
|
238
|
+
timestamp: Date.now()
|
|
239
|
+
};
|
|
240
|
+
traces.push(blockedTrace);
|
|
241
|
+
|
|
242
|
+
return { skip: true, reason: 'safety_mode', trace: blockedTrace };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { skip: false };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Execute interaction and process results
|
|
250
|
+
*
|
|
251
|
+
* @param {Object} context
|
|
252
|
+
* @param {Object} interaction
|
|
253
|
+
* @param {number} interactionIndex
|
|
254
|
+
* @param {string} beforeUrl
|
|
255
|
+
* @param {Array} traces - Mutable array to push traces
|
|
256
|
+
* @param {Array} observedExpectations - Mutable array to push observed expectations
|
|
257
|
+
* @param {Array} remainingInteractionsGaps - Mutable array to push gaps
|
|
258
|
+
* @param {PageFrontier} frontier
|
|
259
|
+
* @param {string} baseOrigin
|
|
260
|
+
* @param {boolean} incrementalMode
|
|
261
|
+
* @param {Object|null} expectationResults
|
|
262
|
+
* @param {number} startTime
|
|
263
|
+
* @param {Object} scanBudget
|
|
264
|
+
* @returns {Promise<Object>} { trace, repeatTrace, navigated, navigatedUrl, frontierCapped }
|
|
265
|
+
*/
|
|
266
|
+
export async function executeInteraction(
|
|
267
|
+
context,
|
|
268
|
+
interaction,
|
|
269
|
+
interactionIndex,
|
|
270
|
+
beforeUrl,
|
|
271
|
+
traces,
|
|
272
|
+
observedExpectations,
|
|
273
|
+
remainingInteractionsGaps,
|
|
274
|
+
frontier,
|
|
275
|
+
baseOrigin,
|
|
276
|
+
incrementalMode,
|
|
277
|
+
expectationResults,
|
|
278
|
+
startTime,
|
|
279
|
+
scanBudget
|
|
280
|
+
) {
|
|
281
|
+
const {
|
|
282
|
+
page,
|
|
283
|
+
timestamp,
|
|
284
|
+
screenshotsDir,
|
|
285
|
+
routeBudget,
|
|
286
|
+
silenceTracker
|
|
287
|
+
} = context;
|
|
288
|
+
|
|
289
|
+
const trace = await runInteraction(
|
|
290
|
+
page,
|
|
291
|
+
interaction,
|
|
292
|
+
timestamp,
|
|
293
|
+
interactionIndex,
|
|
294
|
+
screenshotsDir,
|
|
295
|
+
baseOrigin,
|
|
296
|
+
startTime,
|
|
297
|
+
routeBudget, // Use route-specific budget
|
|
298
|
+
null,
|
|
299
|
+
silenceTracker // Pass silence tracker
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Mark trace with incremental flag if applicable
|
|
303
|
+
if (incrementalMode && trace) {
|
|
304
|
+
trace.incremental = false; // This interaction was executed, not skipped
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let repeatTrace = null;
|
|
308
|
+
|
|
309
|
+
if (trace) {
|
|
310
|
+
const matchingExpectation = expectationResults?.results?.find(r => r.trace?.interaction?.selector === trace.interaction.selector);
|
|
311
|
+
if (matchingExpectation) {
|
|
312
|
+
trace.expectationDriven = true;
|
|
313
|
+
trace.expectationId = matchingExpectation.expectationId;
|
|
314
|
+
trace.expectationOutcome = matchingExpectation.outcome;
|
|
315
|
+
} else {
|
|
316
|
+
const observedExpectation = deriveObservedExpectation(interaction, trace, baseOrigin);
|
|
317
|
+
if (observedExpectation) {
|
|
318
|
+
trace.observedExpectation = observedExpectation;
|
|
319
|
+
trace.resultType = 'OBSERVED_EXPECTATION';
|
|
320
|
+
observedExpectations.push(observedExpectation);
|
|
321
|
+
|
|
322
|
+
const repeatEligible = shouldAttemptRepeatObservedExpectation(observedExpectation, trace);
|
|
323
|
+
const budgetAllowsRepeat = repeatEligible &&
|
|
324
|
+
(Date.now() - startTime) < scanBudget.maxScanDurationMs &&
|
|
325
|
+
(interactionIndex + 1) < scanBudget.maxTotalInteractions;
|
|
326
|
+
|
|
327
|
+
if (budgetAllowsRepeat) {
|
|
328
|
+
const repeatIndex = interactionIndex + 1;
|
|
329
|
+
const repeatResult = await repeatObservedInteraction(
|
|
330
|
+
page,
|
|
331
|
+
interaction,
|
|
332
|
+
observedExpectation,
|
|
333
|
+
timestamp,
|
|
334
|
+
repeatIndex,
|
|
335
|
+
screenshotsDir,
|
|
336
|
+
baseOrigin,
|
|
337
|
+
startTime,
|
|
338
|
+
scanBudget
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (repeatResult) {
|
|
342
|
+
const repeatEvaluation = repeatResult.repeatEvaluation;
|
|
343
|
+
trace.observedExpectation.repeatAttempted = true;
|
|
344
|
+
trace.observedExpectation.repeated = repeatEvaluation.outcome === 'VERIFIED';
|
|
345
|
+
trace.observedExpectation.repeatOutcome = repeatEvaluation.outcome;
|
|
346
|
+
trace.observedExpectation.repeatReason = repeatEvaluation.reason;
|
|
347
|
+
|
|
348
|
+
if (repeatEvaluation.outcome === 'OBSERVED_BREAK') {
|
|
349
|
+
trace.observedExpectation.outcome = 'OBSERVED_BREAK';
|
|
350
|
+
trace.observedExpectation.reason = 'inconsistent_on_repeat';
|
|
351
|
+
trace.observedExpectation.confidenceLevel = 'LOW';
|
|
352
|
+
} else if (trace.observedExpectation.repeated && trace.observedExpectation.outcome === 'VERIFIED') {
|
|
353
|
+
trace.observedExpectation.confidenceLevel = 'MEDIUM';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
repeatTrace = repeatResult.repeatTrace;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
trace.unprovenResult = true;
|
|
361
|
+
trace.resultType = 'UNPROVEN_RESULT';
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
traces.push(trace);
|
|
366
|
+
|
|
367
|
+
if (repeatTrace) {
|
|
368
|
+
traces.push(repeatTrace);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const afterUrl = trace.after?.url || page.url();
|
|
372
|
+
const navigatedSameOrigin = afterUrl && afterUrl !== beforeUrl && !isExternalUrl(afterUrl, baseOrigin);
|
|
373
|
+
if (navigatedSameOrigin && interaction.type === 'link') {
|
|
374
|
+
// Link navigation - add new page to frontier (if not already visited)
|
|
375
|
+
const normalizedAfter = frontier.normalizeUrl(afterUrl);
|
|
376
|
+
const wasAlreadyVisited = frontier.visited.has(normalizedAfter);
|
|
377
|
+
let frontierCapped = false;
|
|
378
|
+
|
|
379
|
+
if (!wasAlreadyVisited) {
|
|
380
|
+
const added = frontier.addUrl(afterUrl);
|
|
381
|
+
// If frontier was capped, record coverage gap
|
|
382
|
+
if (!added && frontier.frontierCapped) {
|
|
383
|
+
remainingInteractionsGaps.push({
|
|
384
|
+
interaction: {
|
|
385
|
+
type: 'link',
|
|
386
|
+
selector: interaction.selector,
|
|
387
|
+
label: interaction.label
|
|
388
|
+
},
|
|
389
|
+
reason: 'frontier_capped',
|
|
390
|
+
url: afterUrl
|
|
391
|
+
});
|
|
392
|
+
frontierCapped = true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Discover links on the new page immediately
|
|
397
|
+
await discoverPageLinks({ page, baseOrigin: baseOrigin, frontier, silenceTracker });
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
trace,
|
|
401
|
+
repeatTrace,
|
|
402
|
+
navigated: true,
|
|
403
|
+
navigatedUrl: afterUrl,
|
|
404
|
+
frontierCapped
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
trace,
|
|
411
|
+
repeatTrace,
|
|
412
|
+
navigated: false
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Repeat observed interaction (helper function)
|
|
418
|
+
*/
|
|
419
|
+
async function repeatObservedInteraction(
|
|
420
|
+
page,
|
|
421
|
+
interaction,
|
|
422
|
+
observedExpectation,
|
|
423
|
+
timestamp,
|
|
424
|
+
interactionIndex,
|
|
425
|
+
screenshotsDir,
|
|
426
|
+
baseOrigin,
|
|
427
|
+
startTime,
|
|
428
|
+
scanBudget
|
|
429
|
+
) {
|
|
430
|
+
const selector = observedExpectation.evidence?.selector || interaction.selector;
|
|
431
|
+
if (!selector) return null;
|
|
432
|
+
|
|
433
|
+
const locator = page.locator(selector).first();
|
|
434
|
+
const count = await locator.count();
|
|
435
|
+
if (count === 0) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const repeatInteraction = {
|
|
440
|
+
...interaction,
|
|
441
|
+
element: locator
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const repeatTrace = await runInteraction(
|
|
445
|
+
page,
|
|
446
|
+
repeatInteraction,
|
|
447
|
+
timestamp,
|
|
448
|
+
interactionIndex,
|
|
449
|
+
screenshotsDir,
|
|
450
|
+
baseOrigin,
|
|
451
|
+
startTime,
|
|
452
|
+
scanBudget,
|
|
453
|
+
null,
|
|
454
|
+
null // No silence tracker for repeat executions (not counted as new silence)
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
if (!repeatTrace) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
repeatTrace.repeatExecution = true;
|
|
462
|
+
repeatTrace.repeatOfObservedExpectationId = observedExpectation.id;
|
|
463
|
+
repeatTrace.resultType = 'OBSERVED_EXPECTATION_REPEAT';
|
|
464
|
+
|
|
465
|
+
const repeatEvaluation = evaluateObservedExpectation(observedExpectation, repeatTrace);
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
repeatTrace,
|
|
469
|
+
repeatEvaluation
|
|
470
|
+
};
|
|
471
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PHASE 21.3 — Navigation Observer
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Page navigation
|
|
6
|
+
* - Link discovery
|
|
7
|
+
* - Frontier management
|
|
8
|
+
* - NO file I/O
|
|
9
|
+
* - NO side effects outside its scope
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { navigateToUrl } from '../browser.js';
|
|
13
|
+
import { isExternalUrl } from '../domain-boundary.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Navigate to a URL
|
|
17
|
+
*
|
|
18
|
+
* @param {ObserveContext} context - Observe context
|
|
19
|
+
* @param {string} targetUrl - URL to navigate to
|
|
20
|
+
* @returns {Promise<boolean>} True if navigation succeeded, false if failed
|
|
21
|
+
*/
|
|
22
|
+
export async function navigateToPage(context, targetUrl) {
|
|
23
|
+
const { page, scanBudget, frontier, silenceTracker } = context;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await navigateToUrl(page, targetUrl, scanBudget);
|
|
27
|
+
return true;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
// Record navigation failure as silence and skip
|
|
30
|
+
silenceTracker.record({
|
|
31
|
+
scope: 'navigation',
|
|
32
|
+
reason: 'navigation_timeout',
|
|
33
|
+
description: 'Navigation to page failed',
|
|
34
|
+
context: { targetUrl },
|
|
35
|
+
impact: 'blocks_nav'
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const normalizedFailed = frontier.normalizeUrl(targetUrl);
|
|
39
|
+
if (!frontier.visited.has(normalizedFailed)) {
|
|
40
|
+
frontier.visited.add(normalizedFailed);
|
|
41
|
+
frontier.markVisited();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Discover links on current page and add to frontier
|
|
50
|
+
*
|
|
51
|
+
* @param {ObserveContext} context - Observe context
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
export async function discoverPageLinks(context) {
|
|
55
|
+
const { page, baseOrigin, frontier, silenceTracker } = context;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const currentLinks = await page.locator('a[href]').all();
|
|
59
|
+
for (const link of currentLinks) {
|
|
60
|
+
try {
|
|
61
|
+
const href = await link.getAttribute('href');
|
|
62
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
63
|
+
const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
|
|
64
|
+
if (!isExternalUrl(resolvedUrl, baseOrigin)) {
|
|
65
|
+
frontier.addUrl(resolvedUrl);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Record invalid URL discovery as silence
|
|
70
|
+
silenceTracker.record({
|
|
71
|
+
scope: 'discovery',
|
|
72
|
+
reason: 'discovery_error',
|
|
73
|
+
description: 'Invalid or unreadable link during discovery',
|
|
74
|
+
context: { pageUrl: page.url() },
|
|
75
|
+
impact: 'incomplete_check'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Record link discovery failure as silence
|
|
81
|
+
silenceTracker.record({
|
|
82
|
+
scope: 'discovery',
|
|
83
|
+
reason: 'discovery_error',
|
|
84
|
+
description: 'Link discovery failed on page',
|
|
85
|
+
context: { pageUrl: page.url() },
|
|
86
|
+
impact: 'incomplete_check'
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if we're already on the target page
|
|
93
|
+
*
|
|
94
|
+
* @param {ObserveContext} context - Observe context
|
|
95
|
+
* @param {string} targetUrl - Target URL
|
|
96
|
+
* @returns {boolean} True if already on page
|
|
97
|
+
*/
|
|
98
|
+
export function isAlreadyOnPage(context, targetUrl) {
|
|
99
|
+
const { page, frontier } = context;
|
|
100
|
+
const currentUrl = page.url();
|
|
101
|
+
const normalizedNext = frontier.normalizeUrl(targetUrl);
|
|
102
|
+
const normalizedCurrent = frontier.normalizeUrl(currentUrl);
|
|
103
|
+
return normalizedCurrent === normalizedNext;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Mark page as visited in frontier
|
|
108
|
+
*
|
|
109
|
+
* @param {ObserveContext} context - Observe context
|
|
110
|
+
* @param {string} targetUrl - Target URL
|
|
111
|
+
* @param {boolean} alreadyOnPage - Whether we're already on the page
|
|
112
|
+
* @returns {void}
|
|
113
|
+
*/
|
|
114
|
+
export function markPageVisited(context, targetUrl, alreadyOnPage) {
|
|
115
|
+
const { frontier } = context;
|
|
116
|
+
const normalizedNext = frontier.normalizeUrl(targetUrl);
|
|
117
|
+
|
|
118
|
+
if (!alreadyOnPage) {
|
|
119
|
+
// We navigated via getNextUrl() - it already marked in visited set, now increment counter
|
|
120
|
+
frontier.markVisited();
|
|
121
|
+
} else {
|
|
122
|
+
// We navigated via link click (alreadyOnPage=true) - mark as visited and increment
|
|
123
|
+
if (!frontier.visited.has(normalizedNext)) {
|
|
124
|
+
frontier.visited.add(normalizedNext);
|
|
125
|
+
frontier.markVisited();
|
|
126
|
+
} else {
|
|
127
|
+
// Already marked as visited, but still increment counter since we're processing it
|
|
128
|
+
frontier.markVisited();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PHASE 21.3 — Network Observer
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Network idle tracking
|
|
6
|
+
* - In-flight request tracking
|
|
7
|
+
* - Network-related observation signals
|
|
8
|
+
*
|
|
9
|
+
* MUST NOT intercept requests (safety-observer owns that)
|
|
10
|
+
* NO file I/O
|
|
11
|
+
* NO side effects outside its scope
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { NetworkSensor } from '../network-sensor.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Observe network state on current page
|
|
18
|
+
*
|
|
19
|
+
* @param {ObserveContext} context - Observe context
|
|
20
|
+
* @param {RunState} runState - Current run state
|
|
21
|
+
* @returns {Promise<Array<Observation>>} Array of network observations
|
|
22
|
+
*/
|
|
23
|
+
export async function observe(context, runState) {
|
|
24
|
+
const { page, currentUrl, timestamp } = context;
|
|
25
|
+
const observations = [];
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Create a network sensor to observe current state
|
|
29
|
+
const networkSensor = new NetworkSensor();
|
|
30
|
+
const windowId = networkSensor.startWindow(page);
|
|
31
|
+
|
|
32
|
+
// Wait a short time to capture any in-flight requests
|
|
33
|
+
await page.waitForTimeout(100);
|
|
34
|
+
|
|
35
|
+
// Stop monitoring and get summary
|
|
36
|
+
const summary = networkSensor.stopWindow(windowId);
|
|
37
|
+
|
|
38
|
+
// Create observation for network state
|
|
39
|
+
observations.push({
|
|
40
|
+
type: 'network_state',
|
|
41
|
+
scope: 'page',
|
|
42
|
+
data: {
|
|
43
|
+
totalRequests: summary.totalRequests,
|
|
44
|
+
failedRequests: summary.failedRequests,
|
|
45
|
+
successfulRequests: summary.successfulRequests,
|
|
46
|
+
unfinishedCount: summary.unfinishedCount,
|
|
47
|
+
hasNetworkActivity: summary.hasNetworkActivity,
|
|
48
|
+
slowRequestsCount: summary.slowRequestsCount,
|
|
49
|
+
failedByStatus: summary.failedByStatus
|
|
50
|
+
},
|
|
51
|
+
timestamp,
|
|
52
|
+
url: currentUrl
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// If there are in-flight requests, create an observation
|
|
56
|
+
if (summary.unfinishedCount > 0) {
|
|
57
|
+
observations.push({
|
|
58
|
+
type: 'network_in_flight',
|
|
59
|
+
scope: 'page',
|
|
60
|
+
data: {
|
|
61
|
+
inFlightCount: summary.unfinishedCount
|
|
62
|
+
},
|
|
63
|
+
timestamp,
|
|
64
|
+
url: currentUrl
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// If network is idle (no requests), create observation
|
|
69
|
+
if (!summary.hasNetworkActivity && summary.unfinishedCount === 0) {
|
|
70
|
+
observations.push({
|
|
71
|
+
type: 'network_idle',
|
|
72
|
+
scope: 'page',
|
|
73
|
+
data: {
|
|
74
|
+
idle: true
|
|
75
|
+
},
|
|
76
|
+
timestamp,
|
|
77
|
+
url: currentUrl
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Propagate error - no silent catch
|
|
82
|
+
throw new Error(`Network observer failed: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return observations;
|
|
86
|
+
}
|
|
87
|
+
|