@veraxhq/verax 0.1.0 → 0.2.1
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 +24 -36
- package/src/cli/commands/default.js +681 -0
- package/src/cli/commands/doctor.js +197 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +586 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +297 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +110 -0
- package/src/cli/util/expectation-extractor.js +388 -0
- package/src/cli/util/findings-writer.js +32 -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 +412 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +30 -0
- package/src/cli/util/project-discovery.js +297 -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/runtime-budget.js +147 -0
- package/src/cli/util/summary-writer.js +43 -0
- package/src/types/global.d.ts +28 -0
- package/src/types/ts-ast.d.ts +24 -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 +111 -0
- package/src/verax/cli/wizard.js +109 -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 +432 -0
- package/src/verax/core/incremental-store.js +245 -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 +523 -0
- package/src/verax/detect/comparison.js +7 -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 +127 -0
- package/src/verax/detect/expectation-model.js +241 -168
- 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 +41 -12
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +200 -288
- package/src/verax/detect/interactive-findings.js +612 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/skip-classifier.js +4 -4
- package/src/verax/detect/verdict-engine.js +561 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/flow/flow-engine.js +3 -2
- package/src/verax/flow/flow-spec.js +1 -2
- package/src/verax/index.js +103 -15
- 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 +642 -0
- package/src/verax/intel/vue-router-extractor.js +325 -0
- package/src/verax/learn/action-contract-extractor.js +338 -104
- package/src/verax/learn/ast-contract-extractor.js +148 -6
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/index.js +36 -2
- package/src/verax/learn/manifest-writer.js +122 -58
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +28 -97
- package/src/verax/learn/route-validator.js +8 -7
- 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 +119 -10
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/learn/ts-contract-resolver.js +14 -12
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +30 -6
- package/src/verax/observe/console-sensor.js +2 -18
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +513 -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 +660 -273
- package/src/verax/observe/index.js +910 -26
- package/src/verax/observe/interaction-discovery.js +378 -15
- package/src/verax/observe/interaction-runner.js +562 -197
- package/src/verax/observe/loading-sensor.js +145 -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 +38 -17
- package/src/verax/observe/state-sensor.js +393 -0
- package/src/verax/observe/state-ui-sensor.js +7 -1
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +73 -21
- package/src/verax/observe/ui-signal-sensor.js +143 -17
- package/src/verax/scan-summary-writer.js +80 -15
- package/src/verax/shared/artifact-manager.js +111 -9
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/caching.js +1 -1
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +169 -0
- package/src/verax/shared/dynamic-route-utils.js +224 -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 +9 -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 +66 -0
- package/src/verax/validate/context-validator.js +244 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXPECTATION-DRIVEN EXECUTION ENGINE
|
|
3
|
+
*
|
|
4
|
+
* Executes every PROVEN expectation from the manifest.
|
|
5
|
+
* Each expectation must result in: VERIFIED, SILENT_FAILURE, or COVERAGE_GAP.
|
|
6
|
+
* @typedef {import('playwright').Page} Page
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isProvenExpectation } from '../shared/expectation-prover.js';
|
|
10
|
+
import { getUrlPath } from '../detect/evidence-validator.js';
|
|
11
|
+
import { hasMeaningfulUrlChange, hasVisibleChange, hasDomChange } from '../detect/comparison.js';
|
|
12
|
+
import { runInteraction } from './interaction-runner.js';
|
|
13
|
+
import { captureScreenshot } from './evidence-capture.js';
|
|
14
|
+
import { getBaseOrigin } from './domain-boundary.js';
|
|
15
|
+
import { resolve } from 'path';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Execute a single PROVEN expectation.
|
|
19
|
+
* @param {Page} page - Playwright page
|
|
20
|
+
* @param {Object} expectation - PROVEN expectation from manifest
|
|
21
|
+
* @param {string} baseUrl - Base URL of the site
|
|
22
|
+
* @param {string} screenshotsDir - Directory for screenshots
|
|
23
|
+
* @param {number} timestamp - Timestamp for file naming
|
|
24
|
+
* @param {number} expectationIndex - Index of expectation in list
|
|
25
|
+
* @param {Object} scanBudget - Scan budget
|
|
26
|
+
* @param {number} startTime - Scan start time
|
|
27
|
+
* @param {string} projectDir - Project directory for path resolution
|
|
28
|
+
* @returns {Promise<Object>} Execution result: { outcome, reason?, evidence?, trace? }
|
|
29
|
+
*/
|
|
30
|
+
async function executeExpectation(page, expectation, baseUrl, screenshotsDir, timestamp, expectationIndex, scanBudget, startTime, projectDir) {
|
|
31
|
+
const baseOrigin = getBaseOrigin(baseUrl);
|
|
32
|
+
const result = {
|
|
33
|
+
expectationId: expectation.id || `exp-${expectationIndex}`,
|
|
34
|
+
fromPath: expectation.fromPath,
|
|
35
|
+
type: expectation.type,
|
|
36
|
+
outcome: null,
|
|
37
|
+
reason: null,
|
|
38
|
+
evidence: {},
|
|
39
|
+
trace: null
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// NOTE: Budget check is done at the loop level (executeProvenExpectations)
|
|
43
|
+
// Once execution starts here, we must complete with a real outcome:
|
|
44
|
+
// VERIFIED, SILENT_FAILURE, or COVERAGE_GAP (with real reason like element_not_found, etc.)
|
|
45
|
+
// Never return budget_exceeded from inside executeExpectation.
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Step 1: Navigate to fromPath
|
|
49
|
+
let fromUrl;
|
|
50
|
+
try {
|
|
51
|
+
// Handle both absolute and relative paths
|
|
52
|
+
if (expectation.fromPath.startsWith('http://') || expectation.fromPath.startsWith('https://')) {
|
|
53
|
+
fromUrl = expectation.fromPath;
|
|
54
|
+
} else {
|
|
55
|
+
fromUrl = new URL(expectation.fromPath, baseUrl).href;
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
result.outcome = 'COVERAGE_GAP';
|
|
59
|
+
result.reason = 'invalid_from_path';
|
|
60
|
+
result.evidence = {
|
|
61
|
+
error: error.message,
|
|
62
|
+
fromPath: expectation.fromPath
|
|
63
|
+
};
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let navigationSuccess = false;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await page.goto(fromUrl, { waitUntil: 'networkidle', timeout: scanBudget.initialNavigationTimeoutMs });
|
|
71
|
+
await page.waitForTimeout(scanBudget.navigationStableWaitMs);
|
|
72
|
+
const currentPath = getUrlPath(page.url());
|
|
73
|
+
const normalizedFrom = expectation.fromPath.replace(/\/$/, '') || '/';
|
|
74
|
+
const normalizedCurrent = currentPath ? currentPath.replace(/\/$/, '') || '/' : '';
|
|
75
|
+
|
|
76
|
+
// Allow for SPA routing - if we're on a different path but same origin, continue
|
|
77
|
+
// Also allow if we're on the same origin (SPA may not change pathname)
|
|
78
|
+
// For file:// URLs, just check if we're on the same origin
|
|
79
|
+
if (normalizedCurrent === normalizedFrom || page.url().startsWith(baseOrigin)) {
|
|
80
|
+
navigationSuccess = true;
|
|
81
|
+
} else if (baseUrl.startsWith('file://') && page.url().startsWith('file://')) {
|
|
82
|
+
// For file:// URLs, navigation is considered successful if we're on file://
|
|
83
|
+
navigationSuccess = true;
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
result.outcome = 'COVERAGE_GAP';
|
|
87
|
+
result.reason = 'page_unreachable';
|
|
88
|
+
result.evidence = {
|
|
89
|
+
error: error.message,
|
|
90
|
+
attemptedUrl: fromUrl
|
|
91
|
+
};
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!navigationSuccess) {
|
|
96
|
+
result.outcome = 'COVERAGE_GAP';
|
|
97
|
+
result.reason = 'page_unreachable';
|
|
98
|
+
result.evidence = {
|
|
99
|
+
attemptedUrl: fromUrl,
|
|
100
|
+
actualUrl: page.url()
|
|
101
|
+
};
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Step 2: Find target element
|
|
106
|
+
const selectorHint = expectation.evidence?.selectorHint || expectation.selectorHint || '';
|
|
107
|
+
const targetPath = expectation.targetPath || expectation.expectedTarget || '';
|
|
108
|
+
const interactionType = determineInteractionType(expectation.type);
|
|
109
|
+
|
|
110
|
+
let element = null;
|
|
111
|
+
let interaction = null;
|
|
112
|
+
|
|
113
|
+
if (selectorHint) {
|
|
114
|
+
// Try to find element by selector
|
|
115
|
+
try {
|
|
116
|
+
// Try exact selector first
|
|
117
|
+
let locator = page.locator(selectorHint).first();
|
|
118
|
+
let count = await locator.count();
|
|
119
|
+
|
|
120
|
+
if (count === 0) {
|
|
121
|
+
// Try without the tag prefix (e.g., if selectorHint is "a#id", try "#id")
|
|
122
|
+
const idMatch = selectorHint.match(/#[\w-]+$/);
|
|
123
|
+
if (idMatch) {
|
|
124
|
+
const idSelector = idMatch[0];
|
|
125
|
+
locator = page.locator(idSelector).first();
|
|
126
|
+
count = await locator.count();
|
|
127
|
+
if (count > 0) {
|
|
128
|
+
element = locator;
|
|
129
|
+
interaction = {
|
|
130
|
+
type: interactionType,
|
|
131
|
+
selector: idSelector,
|
|
132
|
+
label: await extractLabel(page, idSelector).catch(() => ''),
|
|
133
|
+
element: locator
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
element = locator;
|
|
139
|
+
interaction = {
|
|
140
|
+
type: interactionType,
|
|
141
|
+
selector: selectorHint,
|
|
142
|
+
label: await extractLabel(page, selectorHint).catch(() => ''),
|
|
143
|
+
element: locator
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// Selector invalid or element not found
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fallback: Try to find by href/action/targetPath
|
|
152
|
+
if (!element && targetPath) {
|
|
153
|
+
if (expectation.type === 'navigation' || expectation.type === 'spa_navigation') {
|
|
154
|
+
// Try to find link with matching href
|
|
155
|
+
try {
|
|
156
|
+
// Normalize targetPath for matching (remove leading slash if present, add .html if needed)
|
|
157
|
+
const normalizedTarget = targetPath.replace(/^\//, '');
|
|
158
|
+
const hrefSelectors = [
|
|
159
|
+
`a[href="${targetPath}"]`,
|
|
160
|
+
`a[href="/${normalizedTarget}"]`,
|
|
161
|
+
`a[href="${targetPath}/"]`,
|
|
162
|
+
`a[href*="${normalizedTarget}"]`,
|
|
163
|
+
`a[href*="${targetPath}"]`
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
for (const selector of hrefSelectors) {
|
|
167
|
+
const locator = page.locator(selector).first();
|
|
168
|
+
const count = await locator.count();
|
|
169
|
+
if (count > 0) {
|
|
170
|
+
element = locator;
|
|
171
|
+
interaction = {
|
|
172
|
+
type: 'link',
|
|
173
|
+
selector: selector,
|
|
174
|
+
label: await extractLabel(page, selector).catch(() => ''),
|
|
175
|
+
element: locator
|
|
176
|
+
};
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Fallback failed
|
|
182
|
+
}
|
|
183
|
+
} else if (expectation.type === 'form_submission') {
|
|
184
|
+
// Try to find form with matching action
|
|
185
|
+
try {
|
|
186
|
+
const actionSelectors = [
|
|
187
|
+
`form[action="${targetPath}"]`,
|
|
188
|
+
`form[action="${targetPath}/"]`,
|
|
189
|
+
`form[action*="${targetPath}"]`
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
for (const selector of actionSelectors) {
|
|
193
|
+
const submitButton = page.locator(`${selector} button[type="submit"], ${selector} input[type="submit"]`).first();
|
|
194
|
+
const count = await submitButton.count();
|
|
195
|
+
if (count > 0) {
|
|
196
|
+
element = submitButton;
|
|
197
|
+
interaction = {
|
|
198
|
+
type: 'form',
|
|
199
|
+
selector: selector,
|
|
200
|
+
label: await extractLabel(page, selector).catch(() => ''),
|
|
201
|
+
element: submitButton
|
|
202
|
+
};
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
// Fallback failed
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// If element still not found, this is a coverage gap
|
|
213
|
+
if (!element || !interaction) {
|
|
214
|
+
result.outcome = 'COVERAGE_GAP';
|
|
215
|
+
result.reason = 'element_not_found';
|
|
216
|
+
result.evidence = {
|
|
217
|
+
selectorHint: selectorHint,
|
|
218
|
+
targetPath: targetPath,
|
|
219
|
+
fromPath: expectation.fromPath,
|
|
220
|
+
currentUrl: page.url()
|
|
221
|
+
};
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Step 3: Execute interaction
|
|
226
|
+
const beforeUrl = page.url();
|
|
227
|
+
const beforeScreenshot = resolve(screenshotsDir, `exp-${expectationIndex}-before.png`);
|
|
228
|
+
await captureScreenshot(page, beforeScreenshot);
|
|
229
|
+
|
|
230
|
+
const trace = await runInteraction(
|
|
231
|
+
page,
|
|
232
|
+
interaction,
|
|
233
|
+
timestamp,
|
|
234
|
+
expectationIndex,
|
|
235
|
+
screenshotsDir,
|
|
236
|
+
baseOrigin,
|
|
237
|
+
startTime,
|
|
238
|
+
scanBudget,
|
|
239
|
+
null // No flow context for expectation-driven execution
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
result.trace = trace;
|
|
243
|
+
// Update trace with expectation metadata
|
|
244
|
+
if (trace) {
|
|
245
|
+
trace.expectationDriven = true;
|
|
246
|
+
trace.expectationId = result.expectationId;
|
|
247
|
+
trace.expectationOutcome = result.outcome;
|
|
248
|
+
// Ensure before screenshot path is set correctly
|
|
249
|
+
if (!trace.before.screenshot) {
|
|
250
|
+
trace.before.screenshot = `screenshots/exp-${expectationIndex}-before.png`;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
result.evidence = {
|
|
254
|
+
before: trace.before?.screenshot || `exp-${expectationIndex}-before.png`,
|
|
255
|
+
after: trace.after?.screenshot || null,
|
|
256
|
+
beforeUrl: beforeUrl,
|
|
257
|
+
afterUrl: trace.after?.url || page.url()
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Step 4: Evaluate outcome
|
|
261
|
+
const outcome = evaluateExpectationOutcome(expectation, trace, beforeUrl, projectDir);
|
|
262
|
+
result.outcome = outcome.outcome;
|
|
263
|
+
result.reason = outcome.reason;
|
|
264
|
+
|
|
265
|
+
if (outcome.evidence) {
|
|
266
|
+
result.evidence = { ...result.evidence, ...outcome.evidence };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
result.outcome = 'COVERAGE_GAP';
|
|
272
|
+
result.reason = 'execution_error';
|
|
273
|
+
result.evidence = {
|
|
274
|
+
error: error.message,
|
|
275
|
+
errorStack: error.stack
|
|
276
|
+
};
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Determine interaction type from expectation type.
|
|
283
|
+
*/
|
|
284
|
+
function determineInteractionType(expectationType) {
|
|
285
|
+
if (expectationType === 'navigation' || expectationType === 'spa_navigation') {
|
|
286
|
+
return 'link';
|
|
287
|
+
}
|
|
288
|
+
if (expectationType === 'form_submission' || expectationType === 'validation_block') {
|
|
289
|
+
return 'form';
|
|
290
|
+
}
|
|
291
|
+
if (expectationType === 'network_action' || expectationType === 'state_action') {
|
|
292
|
+
return 'button';
|
|
293
|
+
}
|
|
294
|
+
return 'button'; // Default
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Extract label from element.
|
|
299
|
+
*/
|
|
300
|
+
async function extractLabel(page, selector) {
|
|
301
|
+
try {
|
|
302
|
+
const locator = page.locator(selector).first();
|
|
303
|
+
const text = await locator.textContent();
|
|
304
|
+
if (text) return text.trim().substring(0, 100);
|
|
305
|
+
|
|
306
|
+
const ariaLabel = await locator.getAttribute('aria-label');
|
|
307
|
+
if (ariaLabel) return ariaLabel.trim().substring(0, 100);
|
|
308
|
+
|
|
309
|
+
return '';
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return '';
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Evaluate if expectation was VERIFIED or SILENT_FAILURE.
|
|
317
|
+
*/
|
|
318
|
+
function evaluateExpectationOutcome(expectation, trace, beforeUrl, projectDir) {
|
|
319
|
+
const afterUrl = trace.after?.url || '';
|
|
320
|
+
const sensors = trace.sensors || {};
|
|
321
|
+
|
|
322
|
+
if (expectation.type === 'navigation' || expectation.type === 'spa_navigation') {
|
|
323
|
+
const targetPath = expectation.targetPath || expectation.expectedTarget || '';
|
|
324
|
+
const afterPath = getUrlPath(afterUrl);
|
|
325
|
+
const normalizedTarget = targetPath.replace(/\/$/, '') || '/';
|
|
326
|
+
const normalizedAfter = afterPath ? afterPath.replace(/\/$/, '') || '/' : '';
|
|
327
|
+
|
|
328
|
+
if (normalizedAfter === normalizedTarget) {
|
|
329
|
+
return { outcome: 'VERIFIED', reason: null };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check for UI feedback or DOM changes
|
|
333
|
+
const hasVisibleChangeResult = trace.before?.screenshot && trace.after?.screenshot ?
|
|
334
|
+
hasVisibleChange(trace.before.screenshot, trace.after.screenshot, projectDir) : false;
|
|
335
|
+
const hasDomChangeResult = hasDomChange(trace);
|
|
336
|
+
const hasUIFeedback = sensors.uiSignals?.diff?.changed === true;
|
|
337
|
+
|
|
338
|
+
if (hasVisibleChangeResult || hasDomChangeResult || hasUIFeedback) {
|
|
339
|
+
// Partial success - navigation didn't reach target but something changed
|
|
340
|
+
return { outcome: 'SILENT_FAILURE', reason: 'target_not_reached' };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { outcome: 'SILENT_FAILURE', reason: 'no_effect' };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (expectation.type === 'network_action') {
|
|
347
|
+
const networkData = sensors.network || {};
|
|
348
|
+
const hasRequest = networkData.totalRequests > 0;
|
|
349
|
+
const hasFailed = networkData.failedRequests > 0;
|
|
350
|
+
const hasUIFeedback = sensors.uiSignals?.diff?.changed === true;
|
|
351
|
+
|
|
352
|
+
if (!hasRequest) {
|
|
353
|
+
return { outcome: 'SILENT_FAILURE', reason: 'network_request_missing' };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (hasFailed && !hasUIFeedback) {
|
|
357
|
+
return { outcome: 'SILENT_FAILURE', reason: 'network_failure_silent' };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (hasRequest && !hasFailed) {
|
|
361
|
+
return { outcome: 'VERIFIED', reason: null };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { outcome: 'SILENT_FAILURE', reason: 'network_error' };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (expectation.type === 'validation_block') {
|
|
368
|
+
const networkData = sensors.network || {};
|
|
369
|
+
const uiSignals = sensors.uiSignals || {};
|
|
370
|
+
const validationFeedback = uiSignals.after?.validationFeedbackDetected === true;
|
|
371
|
+
const urlChanged = getUrlPath(beforeUrl) !== getUrlPath(afterUrl);
|
|
372
|
+
const hasNetworkRequest = networkData.totalRequests > 0;
|
|
373
|
+
|
|
374
|
+
// Validation block should prevent submission
|
|
375
|
+
if (!urlChanged && !hasNetworkRequest) {
|
|
376
|
+
// Submission was blocked - check for feedback
|
|
377
|
+
if (validationFeedback) {
|
|
378
|
+
return { outcome: 'VERIFIED', reason: null };
|
|
379
|
+
} else {
|
|
380
|
+
return { outcome: 'SILENT_FAILURE', reason: 'validation_feedback_missing' };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// If submission occurred, validation didn't block (might be a different issue)
|
|
385
|
+
return { outcome: 'SILENT_FAILURE', reason: 'validation_did_not_block' };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (expectation.type === 'state_action') {
|
|
389
|
+
const stateDiff = sensors.state || {};
|
|
390
|
+
const expectedKey = expectation.expectedTarget;
|
|
391
|
+
const stateChanged = stateDiff.available && stateDiff.changed.length > 0;
|
|
392
|
+
const expectedKeyChanged = stateChanged && stateDiff.changed.includes(expectedKey);
|
|
393
|
+
const hasUIFeedback = sensors.uiSignals?.diff?.changed === true;
|
|
394
|
+
|
|
395
|
+
if (expectedKeyChanged && hasUIFeedback) {
|
|
396
|
+
return { outcome: 'VERIFIED', reason: null };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!stateChanged || !expectedKeyChanged) {
|
|
400
|
+
return { outcome: 'SILENT_FAILURE', reason: 'state_not_updated' };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (expectedKeyChanged && !hasUIFeedback) {
|
|
404
|
+
return { outcome: 'SILENT_FAILURE', reason: 'state_updated_no_ui_feedback' };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { outcome: 'SILENT_FAILURE', reason: 'state_update_failed' };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Default: check for any visible effect
|
|
411
|
+
const hasUIFeedback = sensors.uiSignals?.diff?.changed === true;
|
|
412
|
+
const hasDomChangeResult = hasDomChange(trace);
|
|
413
|
+
const urlChanged = hasMeaningfulUrlChange(beforeUrl, afterUrl);
|
|
414
|
+
const hasVisibleChangeResult = trace.before?.screenshot && trace.after?.screenshot ?
|
|
415
|
+
hasVisibleChange(trace.before.screenshot, trace.after.screenshot, projectDir) : false;
|
|
416
|
+
|
|
417
|
+
if (hasUIFeedback || hasDomChangeResult || urlChanged || hasVisibleChangeResult) {
|
|
418
|
+
return { outcome: 'VERIFIED', reason: null };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { outcome: 'SILENT_FAILURE', reason: 'no_effect' };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Execute all PROVEN expectations from manifest.
|
|
426
|
+
* @param {Page} page - Playwright page
|
|
427
|
+
* @param {Object} manifest - Manifest with staticExpectations
|
|
428
|
+
* @param {string} baseUrl - Base URL
|
|
429
|
+
* @param {string} screenshotsDir - Screenshots directory
|
|
430
|
+
* @param {Object} scanBudget - Scan budget
|
|
431
|
+
* @param {number} startTime - Scan start time
|
|
432
|
+
* @param {string} projectDir - Project directory
|
|
433
|
+
* @returns {Promise<Object>} { results: [], executedCount, coverageGaps: [] }
|
|
434
|
+
*/
|
|
435
|
+
export async function executeProvenExpectations(page, manifest, baseUrl, screenshotsDir, scanBudget, startTime, projectDir) {
|
|
436
|
+
const provenExpectations = (manifest.staticExpectations || []).filter(exp => isProvenExpectation(exp));
|
|
437
|
+
const results = [];
|
|
438
|
+
const coverageGaps = [];
|
|
439
|
+
|
|
440
|
+
const timestamp = Date.now();
|
|
441
|
+
|
|
442
|
+
for (let i = 0; i < provenExpectations.length; i++) {
|
|
443
|
+
const expectation = provenExpectations[i];
|
|
444
|
+
|
|
445
|
+
// Check budget before each expectation
|
|
446
|
+
if (Date.now() - startTime > scanBudget.maxScanDurationMs) {
|
|
447
|
+
// Mark remaining expectations as coverage gaps
|
|
448
|
+
for (let j = i; j < provenExpectations.length; j++) {
|
|
449
|
+
coverageGaps.push({
|
|
450
|
+
expectationId: provenExpectations[j].id || `exp-${j}`,
|
|
451
|
+
type: provenExpectations[j].type,
|
|
452
|
+
reason: 'budget_exceeded',
|
|
453
|
+
fromPath: provenExpectations[j].fromPath,
|
|
454
|
+
source: provenExpectations[j].sourceRef || provenExpectations[j].evidence?.source || null
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const result = await executeExpectation(
|
|
461
|
+
page,
|
|
462
|
+
expectation,
|
|
463
|
+
baseUrl,
|
|
464
|
+
screenshotsDir,
|
|
465
|
+
timestamp,
|
|
466
|
+
i,
|
|
467
|
+
scanBudget,
|
|
468
|
+
startTime,
|
|
469
|
+
projectDir
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
results.push(result);
|
|
473
|
+
|
|
474
|
+
// If outcome is COVERAGE_GAP, add to coverageGaps array
|
|
475
|
+
// NOTE: budget_exceeded is NEVER in results - only unattempted expectations get budget_exceeded
|
|
476
|
+
// All results here are executed expectations with real outcomes
|
|
477
|
+
if (result.outcome === 'COVERAGE_GAP') {
|
|
478
|
+
coverageGaps.push({
|
|
479
|
+
expectationId: result.expectationId,
|
|
480
|
+
type: result.type,
|
|
481
|
+
reason: result.reason,
|
|
482
|
+
fromPath: result.fromPath,
|
|
483
|
+
source: expectation.sourceRef || expectation.evidence?.source || null,
|
|
484
|
+
evidence: result.evidence
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// FINAL ASSERTION: Every PROVEN expectation must have been accounted for
|
|
490
|
+
// Results array contains ALL expectations that were attempted (with real outcomes: VERIFIED/SILENT_FAILURE/COVERAGE_GAP)
|
|
491
|
+
// Coverage gaps array contains:
|
|
492
|
+
// - COVERAGE_GAP outcomes from executed expectations (element_not_found, page_unreachable, etc.)
|
|
493
|
+
// - budget_exceeded outcomes from unattempted expectations (only from loop break)
|
|
494
|
+
// Budget-exceeded expectations are NEVER in results - they are only in coverageGaps and were never attempted
|
|
495
|
+
const budgetExceededCount = coverageGaps.filter(cg => cg.reason === 'budget_exceeded').length;
|
|
496
|
+
const totalAccounted = results.length + budgetExceededCount;
|
|
497
|
+
|
|
498
|
+
if (totalAccounted !== provenExpectations.length) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`CORRECTNESS VIOLATION: Expected ${provenExpectations.length} PROVEN expectations to be accounted for, ` +
|
|
501
|
+
`but got ${results.length} executed + ${budgetExceededCount} budget-exceeded (not attempted) = ${totalAccounted} total. ` +
|
|
502
|
+
`Missing ${provenExpectations.length - totalAccounted} expectations.`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
results,
|
|
508
|
+
executedCount: results.length,
|
|
509
|
+
coverageGaps,
|
|
510
|
+
totalProvenExpectations: provenExpectations.length
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLOW INTELLIGENCE v1 — Flow Matcher
|
|
3
|
+
*
|
|
4
|
+
* Matches discovered interactions to PROVEN flow steps.
|
|
5
|
+
* NO HEURISTICS: Only executes flows with explicit PROVEN expectations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Match an interaction to a flow step expectation.
|
|
10
|
+
* Uses same logic as expectation-model but for flow context.
|
|
11
|
+
*/
|
|
12
|
+
function matchesFlowStep(interaction, step) {
|
|
13
|
+
// Navigation: match by type
|
|
14
|
+
if (step.expectationType === 'navigation' && interaction.type === 'link') {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Network action: match form or button
|
|
19
|
+
if (step.expectationType === 'network_action') {
|
|
20
|
+
if (interaction.type === 'form' || interaction.type === 'button') {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// State action: match button
|
|
26
|
+
if (step.expectationType === 'state_action' && interaction.type === 'button') {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find which flow a set of interactions matches.
|
|
35
|
+
* Returns the best matching flow or null.
|
|
36
|
+
*/
|
|
37
|
+
function findMatchingFlow(interactions, flows) {
|
|
38
|
+
if (!flows || flows.length === 0) return null;
|
|
39
|
+
if (!interactions || interactions.length < 2) return null;
|
|
40
|
+
|
|
41
|
+
// Try to match each flow
|
|
42
|
+
for (const flow of flows) {
|
|
43
|
+
let matchedSteps = 0;
|
|
44
|
+
|
|
45
|
+
// Check if we have enough interactions for this flow
|
|
46
|
+
if (interactions.length < flow.stepCount) continue;
|
|
47
|
+
|
|
48
|
+
// Try to match flow steps to interactions in order
|
|
49
|
+
for (let i = 0; i < flow.stepCount; i++) {
|
|
50
|
+
const step = flow.steps[i];
|
|
51
|
+
const interaction = interactions[i];
|
|
52
|
+
|
|
53
|
+
if (matchesFlowStep(interaction, step)) {
|
|
54
|
+
matchedSteps++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If all steps matched, return this flow
|
|
59
|
+
if (matchedSteps === flow.stepCount) {
|
|
60
|
+
return flow;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build flow-aware execution plan from discovered interactions and PROVEN flows.
|
|
69
|
+
*
|
|
70
|
+
* @param {Array} interactions - Discovered interactions
|
|
71
|
+
* @param {Array} flows - PROVEN flows from manifest
|
|
72
|
+
* @returns {Array} Array of flow execution plans
|
|
73
|
+
*/
|
|
74
|
+
export function buildFlowExecutionPlan(interactions, flows = []) {
|
|
75
|
+
const executionPlan = [];
|
|
76
|
+
|
|
77
|
+
// If no PROVEN flows, fall back to simple grouping (original behavior)
|
|
78
|
+
if (!flows || flows.length === 0) {
|
|
79
|
+
let cursor = 0;
|
|
80
|
+
while (cursor < interactions.length && executionPlan.length < 3) {
|
|
81
|
+
const remaining = interactions.length - cursor;
|
|
82
|
+
const size = Math.min(Math.max(2, Math.min(5, remaining)), remaining);
|
|
83
|
+
const flowInteractions = interactions.slice(cursor, cursor + size);
|
|
84
|
+
executionPlan.push({
|
|
85
|
+
flowId: `flow-${executionPlan.length + 1}`,
|
|
86
|
+
stepCount: flowInteractions.length,
|
|
87
|
+
interactions: flowInteractions,
|
|
88
|
+
proven: false
|
|
89
|
+
});
|
|
90
|
+
cursor += size;
|
|
91
|
+
}
|
|
92
|
+
return executionPlan;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// PROVEN FLOW EXECUTION: Match interactions to PROVEN flows
|
|
96
|
+
const usedInteractions = new Set();
|
|
97
|
+
|
|
98
|
+
for (const flow of flows) {
|
|
99
|
+
if (executionPlan.length >= 3) break; // Max 3 flows per run
|
|
100
|
+
|
|
101
|
+
// Find unused interactions that match this flow
|
|
102
|
+
const availableInteractions = interactions.filter((_, idx) => !usedInteractions.has(idx));
|
|
103
|
+
|
|
104
|
+
if (availableInteractions.length < flow.stepCount) continue;
|
|
105
|
+
|
|
106
|
+
const matchingFlow = findMatchingFlow(availableInteractions, [flow]);
|
|
107
|
+
|
|
108
|
+
if (matchingFlow) {
|
|
109
|
+
const flowInteractions = availableInteractions.slice(0, flow.stepCount);
|
|
110
|
+
|
|
111
|
+
// Mark these interactions as used
|
|
112
|
+
flowInteractions.forEach((interaction) => {
|
|
113
|
+
const idx = interactions.indexOf(interaction);
|
|
114
|
+
if (idx >= 0) usedInteractions.add(idx);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
executionPlan.push({
|
|
118
|
+
flowId: flow.flowId,
|
|
119
|
+
stepCount: flow.stepCount,
|
|
120
|
+
interactions: flowInteractions,
|
|
121
|
+
proven: true,
|
|
122
|
+
provenFlow: flow
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// If no PROVEN flows matched, execute remaining interactions as fallback flows
|
|
128
|
+
if (executionPlan.length === 0) {
|
|
129
|
+
const unusedInteractions = interactions.filter((_, idx) => !usedInteractions.has(idx));
|
|
130
|
+
if (unusedInteractions.length >= 2) {
|
|
131
|
+
const size = Math.min(5, unusedInteractions.length);
|
|
132
|
+
const flowInteractions = unusedInteractions.slice(0, size);
|
|
133
|
+
executionPlan.push({
|
|
134
|
+
flowId: `flow-fallback-1`,
|
|
135
|
+
stepCount: flowInteractions.length,
|
|
136
|
+
interactions: flowInteractions,
|
|
137
|
+
proven: false
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return executionPlan;
|
|
143
|
+
}
|