@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
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { generateSelector } from './selector-generator.js';
|
|
2
2
|
import { isExternalHref } from './domain-boundary.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const MAX_INTERACTIONS_PER_PAGE = 30;
|
|
3
|
+
import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
|
|
6
4
|
|
|
7
5
|
function computePriority(candidate, viewportHeight) {
|
|
8
6
|
const hasAboveFold = candidate.boundingAvailable && typeof viewportHeight === 'number' && candidate.boundingY < viewportHeight;
|
|
9
7
|
const isFooter = candidate.boundingAvailable && typeof viewportHeight === 'number' && candidate.boundingY >= viewportHeight;
|
|
10
8
|
const isInternalLink = candidate.type === 'link' && candidate.href && candidate.href !== '#' && (!candidate.isExternal || candidate.href.startsWith('/'));
|
|
11
9
|
|
|
10
|
+
if (candidate.type === 'file_upload') return 2;
|
|
11
|
+
if (candidate.type === 'keyboard') return 2.5;
|
|
12
|
+
if (candidate.type === 'hover') return 3;
|
|
13
|
+
if (candidate.type === 'login') return 1;
|
|
14
|
+
if (candidate.type === 'logout') return 1.5;
|
|
15
|
+
if (candidate.type === 'auth_guard') return 1.8;
|
|
12
16
|
if (candidate.type === 'form') return 1;
|
|
13
17
|
if (candidate.type === 'link' && isFooter) return 6;
|
|
14
18
|
if (isInternalLink) return 2;
|
|
@@ -67,14 +71,8 @@ async function extractLabel(element) {
|
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
export async function discoverInteractions(page, baseOrigin) {
|
|
71
|
-
// Wave 2: Apply scrolling before discovery to reveal lazy-loaded elements
|
|
72
|
-
const driver = new HumanBehaviorDriver({ maxScrollSteps: 5 });
|
|
73
|
-
await driver.discoverInteractionsWithScroll(page);
|
|
74
|
-
|
|
75
|
-
// Now run the full discovery with all elements visible
|
|
74
|
+
export async function discoverInteractions(page, baseOrigin, scanBudget = DEFAULT_SCAN_BUDGET) {
|
|
76
75
|
const currentUrl = page.url();
|
|
77
|
-
const interactions = [];
|
|
78
76
|
const seenElements = new Set();
|
|
79
77
|
|
|
80
78
|
const allInteractions = [];
|
|
@@ -124,6 +122,8 @@ export async function discoverInteractions(page, baseOrigin) {
|
|
|
124
122
|
const text = await button.evaluate(el => el.textContent?.trim() || '');
|
|
125
123
|
const dataHref = await button.getAttribute('data-href');
|
|
126
124
|
const dataTestId = await button.getAttribute('data-testid');
|
|
125
|
+
const dataDanger = await button.getAttribute('data-danger');
|
|
126
|
+
const dataDestructive = await button.getAttribute('data-destructive');
|
|
127
127
|
|
|
128
128
|
allInteractions.push({
|
|
129
129
|
type: isLangToggle ? 'toggle' : 'button',
|
|
@@ -135,6 +135,8 @@ export async function discoverInteractions(page, baseOrigin) {
|
|
|
135
135
|
text: text,
|
|
136
136
|
dataHref: dataHref || '',
|
|
137
137
|
dataTestId: dataTestId || '',
|
|
138
|
+
dataDanger: dataDanger !== null,
|
|
139
|
+
dataDestructive: dataDestructive !== null,
|
|
138
140
|
isRoleButton: false
|
|
139
141
|
});
|
|
140
142
|
}
|
|
@@ -202,9 +204,17 @@ export async function discoverInteractions(page, baseOrigin) {
|
|
|
202
204
|
for (const form of forms) {
|
|
203
205
|
const submitButton = form.locator('button[type="submit"], input[type="submit"]').first();
|
|
204
206
|
if (await submitButton.count() > 0) {
|
|
207
|
+
const formAction = await form.getAttribute('action');
|
|
205
208
|
const selector = await generateSelector(submitButton);
|
|
206
209
|
const selectorKey = `form:${selector}`;
|
|
207
210
|
|
|
211
|
+
// Check if this is a login form (has password input)
|
|
212
|
+
const hasPasswordInput = await form.locator('input[type="password"]').count() > 0;
|
|
213
|
+
const formText = await form.evaluate(el => el.textContent?.toLowerCase() || '');
|
|
214
|
+
const isLoginForm = hasPasswordInput ||
|
|
215
|
+
/login|signin|sign.in|authenticate/i.test(formText) ||
|
|
216
|
+
/email|username|user/i.test(formText) && hasPasswordInput;
|
|
217
|
+
|
|
208
218
|
if (!seenElements.has(selectorKey)) {
|
|
209
219
|
seenElements.add(selectorKey);
|
|
210
220
|
const label = await extractLabel(submitButton);
|
|
@@ -213,7 +223,7 @@ export async function discoverInteractions(page, baseOrigin) {
|
|
|
213
223
|
const text = await submitButton.evaluate(el => el.textContent?.trim() || el.getAttribute('value') || '');
|
|
214
224
|
|
|
215
225
|
allInteractions.push({
|
|
216
|
-
type: 'form',
|
|
226
|
+
type: isLoginForm ? 'login' : 'form',
|
|
217
227
|
selector: selector,
|
|
218
228
|
label: label || text,
|
|
219
229
|
element: submitButton,
|
|
@@ -222,12 +232,184 @@ export async function discoverInteractions(page, baseOrigin) {
|
|
|
222
232
|
text: text,
|
|
223
233
|
dataHref: '',
|
|
224
234
|
dataTestId: '',
|
|
235
|
+
isRoleButton: false,
|
|
236
|
+
formAction: formAction || '',
|
|
237
|
+
hasPasswordInput: hasPasswordInput
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Detect logout actions (buttons/links with logout/signout patterns) - check existing buttons/links first
|
|
244
|
+
for (const item of allInteractions) {
|
|
245
|
+
if (item.type === 'button' || item.type === 'link') {
|
|
246
|
+
const text = (item.text || '').trim().toLowerCase();
|
|
247
|
+
const label = (item.label || '').trim().toLowerCase();
|
|
248
|
+
|
|
249
|
+
const isLogout = /^(logout|sign\s*out|signout|log\s*out)$/i.test(text) ||
|
|
250
|
+
/^(logout|sign\s*out|signout|log\s*out)$/i.test(label) ||
|
|
251
|
+
(text.includes('logout') || text.includes('sign out') || text.includes('signout'));
|
|
252
|
+
|
|
253
|
+
if (isLogout) {
|
|
254
|
+
const selectorKey = `logout:${item.selector}`;
|
|
255
|
+
if (!seenElements.has(selectorKey)) {
|
|
256
|
+
seenElements.add(selectorKey);
|
|
257
|
+
allInteractions.push({
|
|
258
|
+
type: 'logout',
|
|
259
|
+
selector: item.selector,
|
|
260
|
+
label: item.label,
|
|
261
|
+
element: item.element,
|
|
262
|
+
tagName: item.tagName,
|
|
263
|
+
id: item.id,
|
|
264
|
+
text: item.text,
|
|
265
|
+
dataHref: item.dataHref || '',
|
|
266
|
+
dataTestId: item.dataTestId || '',
|
|
267
|
+
isRoleButton: item.isRoleButton || false
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Detect potential protected routes by looking for links/buttons with typical protected path patterns
|
|
275
|
+
const internalLinks = await page.locator('a[href]:not([href*="://"])').all();
|
|
276
|
+
for (const link of internalLinks) {
|
|
277
|
+
const href = await link.getAttribute('href');
|
|
278
|
+
const text = await link.evaluate(el => el.textContent?.trim() || '');
|
|
279
|
+
const id = await link.getAttribute('id') || '';
|
|
280
|
+
const combined = `${href} ${text} ${id}`.toLowerCase();
|
|
281
|
+
|
|
282
|
+
const isProtectedPath = /admin|dashboard|profile|account|settings|private|protected|secure/i.test(href || '') ||
|
|
283
|
+
/admin|dashboard|profile|account|settings/i.test(combined);
|
|
284
|
+
|
|
285
|
+
if (isProtectedPath && href && !href.startsWith('#')) {
|
|
286
|
+
const selector = await generateSelector(link);
|
|
287
|
+
const selectorKey = `auth_guard:${selector}`;
|
|
288
|
+
|
|
289
|
+
if (!seenElements.has(selectorKey)) {
|
|
290
|
+
seenElements.add(selectorKey);
|
|
291
|
+
const label = await extractLabel(link);
|
|
292
|
+
const tagName = await link.evaluate(el => el.tagName.toLowerCase());
|
|
293
|
+
|
|
294
|
+
allInteractions.push({
|
|
295
|
+
type: 'auth_guard',
|
|
296
|
+
selector: selector,
|
|
297
|
+
label: label || text,
|
|
298
|
+
element: link,
|
|
299
|
+
tagName: tagName,
|
|
300
|
+
id: id || '',
|
|
301
|
+
text: text,
|
|
302
|
+
href: href || '',
|
|
303
|
+
dataHref: '',
|
|
304
|
+
dataTestId: '',
|
|
225
305
|
isRoleButton: false
|
|
226
306
|
});
|
|
227
307
|
}
|
|
228
308
|
}
|
|
229
309
|
}
|
|
230
310
|
|
|
311
|
+
const fileInputs = await page.locator('input[type="file"]:not([disabled])').all();
|
|
312
|
+
for (const fileInput of fileInputs) {
|
|
313
|
+
const selector = await generateSelector(fileInput);
|
|
314
|
+
const selectorKey = `file:${selector}`;
|
|
315
|
+
|
|
316
|
+
if (!seenElements.has(selectorKey)) {
|
|
317
|
+
seenElements.add(selectorKey);
|
|
318
|
+
const label = await extractLabel(fileInput);
|
|
319
|
+
const tagName = await fileInput.evaluate(el => el.tagName.toLowerCase());
|
|
320
|
+
const id = await fileInput.getAttribute('id');
|
|
321
|
+
const accept = await fileInput.getAttribute('accept');
|
|
322
|
+
|
|
323
|
+
allInteractions.push({
|
|
324
|
+
type: 'file_upload',
|
|
325
|
+
selector,
|
|
326
|
+
label: label || 'File upload',
|
|
327
|
+
element: fileInput,
|
|
328
|
+
tagName,
|
|
329
|
+
id: id || '',
|
|
330
|
+
text: accept || '',
|
|
331
|
+
dataHref: '',
|
|
332
|
+
dataTestId: '',
|
|
333
|
+
isRoleButton: false
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const hoverableCandidates = await page.locator('[aria-haspopup], [data-hover], [role="menu"], [role="menuitem"]').all();
|
|
339
|
+
for (const hoverEl of hoverableCandidates) {
|
|
340
|
+
try {
|
|
341
|
+
const selector = await generateSelector(hoverEl);
|
|
342
|
+
const selectorKey = `hover:${selector}`;
|
|
343
|
+
|
|
344
|
+
if (!seenElements.has(selectorKey)) {
|
|
345
|
+
seenElements.add(selectorKey);
|
|
346
|
+
const label = await extractLabel(hoverEl);
|
|
347
|
+
const tagName = await hoverEl.evaluate(el => el.tagName.toLowerCase());
|
|
348
|
+
const id = await hoverEl.getAttribute('id');
|
|
349
|
+
const text = await hoverEl.evaluate(el => el.textContent?.trim() || '');
|
|
350
|
+
const ariaHasPopup = await hoverEl.getAttribute('aria-haspopup') || '';
|
|
351
|
+
const role = await hoverEl.getAttribute('role') || '';
|
|
352
|
+
const dataHover = await hoverEl.getAttribute('data-hover') || '';
|
|
353
|
+
|
|
354
|
+
const box = await hoverEl.boundingBox();
|
|
355
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
356
|
+
allInteractions.push({
|
|
357
|
+
type: 'hover',
|
|
358
|
+
selector: selector,
|
|
359
|
+
label: label || text || role || 'hoverable',
|
|
360
|
+
element: hoverEl,
|
|
361
|
+
tagName: tagName,
|
|
362
|
+
id: id || '',
|
|
363
|
+
text: text,
|
|
364
|
+
ariaHasPopup: ariaHasPopup,
|
|
365
|
+
role: role,
|
|
366
|
+
dataHover: dataHover,
|
|
367
|
+
dataHref: '',
|
|
368
|
+
dataTestId: '',
|
|
369
|
+
isRoleButton: false
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
// Skip if element is invalid
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const keyboardFocusableElements = await page.locator('button, a[href], input[type="submit"], input[type="button"]').all();
|
|
379
|
+
for (const focusableEl of keyboardFocusableElements) {
|
|
380
|
+
try {
|
|
381
|
+
const selector = await generateSelector(focusableEl);
|
|
382
|
+
const selectorKey = `keyboard:${selector}`;
|
|
383
|
+
|
|
384
|
+
if (!seenElements.has(selectorKey)) {
|
|
385
|
+
seenElements.add(selectorKey);
|
|
386
|
+
const label = await extractLabel(focusableEl);
|
|
387
|
+
const tagName = await focusableEl.evaluate(el => el.tagName.toLowerCase());
|
|
388
|
+
const id = await focusableEl.getAttribute('id');
|
|
389
|
+
const text = await focusableEl.evaluate(el => el.textContent?.trim() || el.value || '');
|
|
390
|
+
|
|
391
|
+
const box = await focusableEl.boundingBox();
|
|
392
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
393
|
+
allInteractions.push({
|
|
394
|
+
type: 'keyboard',
|
|
395
|
+
selector: selector,
|
|
396
|
+
label: label || text,
|
|
397
|
+
element: focusableEl,
|
|
398
|
+
tagName: tagName,
|
|
399
|
+
id: id || '',
|
|
400
|
+
text: text,
|
|
401
|
+
dataHref: '',
|
|
402
|
+
dataTestId: '',
|
|
403
|
+
isRoleButton: false
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
// Skip if element is invalid
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
231
413
|
const viewport = page.viewportSize();
|
|
232
414
|
const viewportHeight = viewport ? viewport.height : undefined;
|
|
233
415
|
|
|
@@ -235,23 +417,27 @@ export async function discoverInteractions(page, baseOrigin) {
|
|
|
235
417
|
try {
|
|
236
418
|
const box = await item.element.boundingBox();
|
|
237
419
|
if (box) {
|
|
420
|
+
// @ts-expect-error - Adding runtime properties to interaction object
|
|
238
421
|
item.boundingY = box.y;
|
|
422
|
+
// @ts-expect-error - Adding runtime properties to interaction object
|
|
239
423
|
item.boundingAvailable = true;
|
|
240
424
|
}
|
|
241
425
|
} catch (error) {
|
|
426
|
+
// @ts-expect-error - Adding runtime properties to interaction object
|
|
242
427
|
item.boundingAvailable = false;
|
|
243
428
|
}
|
|
429
|
+
// @ts-expect-error - Adding runtime properties to interaction object
|
|
244
430
|
item.priority = computePriority(item, viewportHeight);
|
|
245
431
|
}
|
|
246
432
|
|
|
247
433
|
const sorted = sortCandidates(allInteractions);
|
|
248
|
-
const capped = sorted.length >
|
|
249
|
-
const selected = sorted.slice(0,
|
|
434
|
+
const capped = sorted.length > scanBudget.maxInteractionsPerPage;
|
|
435
|
+
const selected = sorted.slice(0, scanBudget.maxInteractionsPerPage);
|
|
250
436
|
|
|
251
437
|
const coverage = {
|
|
252
438
|
candidatesDiscovered: sorted.length,
|
|
253
439
|
candidatesSelected: selected.length,
|
|
254
|
-
cap:
|
|
440
|
+
cap: scanBudget.maxInteractionsPerPage,
|
|
255
441
|
capped
|
|
256
442
|
};
|
|
257
443
|
|
|
@@ -261,9 +447,186 @@ export async function discoverInteractions(page, baseOrigin) {
|
|
|
261
447
|
selector: item.selector,
|
|
262
448
|
label: item.label,
|
|
263
449
|
element: item.element,
|
|
264
|
-
isExternal: item.isExternal || false
|
|
450
|
+
isExternal: item.isExternal || false,
|
|
451
|
+
href: item.href,
|
|
452
|
+
text: item.text
|
|
265
453
|
})),
|
|
266
454
|
coverage
|
|
267
455
|
};
|
|
268
456
|
}
|
|
269
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Discover ALL interactions on a page (no priority cap).
|
|
460
|
+
* Used for full-site coverage traversal.
|
|
461
|
+
*/
|
|
462
|
+
export async function discoverAllInteractions(page, baseOrigin, _scanBudget = DEFAULT_SCAN_BUDGET) {
|
|
463
|
+
const currentUrl = page.url();
|
|
464
|
+
const seenElements = new Set();
|
|
465
|
+
const allInteractions = [];
|
|
466
|
+
|
|
467
|
+
const links = await page.locator('a[href]').all();
|
|
468
|
+
for (const link of links) {
|
|
469
|
+
const href = await link.getAttribute('href');
|
|
470
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
471
|
+
const isExternal = isExternalHref(href, baseOrigin, currentUrl);
|
|
472
|
+
const selector = await generateSelector(link);
|
|
473
|
+
const selectorKey = `link:${selector}`;
|
|
474
|
+
|
|
475
|
+
if (!seenElements.has(selectorKey)) {
|
|
476
|
+
seenElements.add(selectorKey);
|
|
477
|
+
const label = await extractLabel(link);
|
|
478
|
+
const text = await link.evaluate(el => el.textContent?.trim() || '');
|
|
479
|
+
|
|
480
|
+
allInteractions.push({
|
|
481
|
+
type: 'link',
|
|
482
|
+
selector: selector,
|
|
483
|
+
label: label,
|
|
484
|
+
element: link,
|
|
485
|
+
isExternal: isExternal,
|
|
486
|
+
href: href,
|
|
487
|
+
text: text
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const buttons = await page.locator('button:not([disabled])').all();
|
|
494
|
+
for (const button of buttons) {
|
|
495
|
+
const selector = await generateSelector(button);
|
|
496
|
+
const selectorKey = `button:${selector}`;
|
|
497
|
+
|
|
498
|
+
if (!seenElements.has(selectorKey)) {
|
|
499
|
+
seenElements.add(selectorKey);
|
|
500
|
+
const label = await extractLabel(button);
|
|
501
|
+
const elementHandle = await button.elementHandle();
|
|
502
|
+
const isLangToggle = elementHandle ? await isLanguageToggle(elementHandle) : false;
|
|
503
|
+
const text = await button.evaluate(el => el.textContent?.trim() || '');
|
|
504
|
+
const dataHref = await button.getAttribute('data-href');
|
|
505
|
+
|
|
506
|
+
allInteractions.push({
|
|
507
|
+
type: isLangToggle ? 'toggle' : 'button',
|
|
508
|
+
selector: selector,
|
|
509
|
+
label: label,
|
|
510
|
+
element: button,
|
|
511
|
+
text: text,
|
|
512
|
+
dataHref: dataHref || ''
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const submitInputs = await page.locator('input[type="submit"]:not([disabled]), input[type="button"]:not([disabled])').all();
|
|
518
|
+
for (const input of submitInputs) {
|
|
519
|
+
const selector = await generateSelector(input);
|
|
520
|
+
const selectorKey = `input:${selector}`;
|
|
521
|
+
|
|
522
|
+
if (!seenElements.has(selectorKey)) {
|
|
523
|
+
seenElements.add(selectorKey);
|
|
524
|
+
const label = await extractLabel(input);
|
|
525
|
+
const text = await input.getAttribute('value') || '';
|
|
526
|
+
const dataHref = await input.getAttribute('data-href');
|
|
527
|
+
|
|
528
|
+
allInteractions.push({
|
|
529
|
+
type: 'button',
|
|
530
|
+
selector: selector,
|
|
531
|
+
label: label || text,
|
|
532
|
+
element: input,
|
|
533
|
+
text: text,
|
|
534
|
+
dataHref: dataHref || ''
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const roleButtons = await page.locator('[role="button"]:not([disabled])').all();
|
|
540
|
+
for (const roleButton of roleButtons) {
|
|
541
|
+
const selector = await generateSelector(roleButton);
|
|
542
|
+
const selectorKey = `role-button:${selector}`;
|
|
543
|
+
|
|
544
|
+
if (!seenElements.has(selectorKey)) {
|
|
545
|
+
seenElements.add(selectorKey);
|
|
546
|
+
const label = await extractLabel(roleButton);
|
|
547
|
+
const text = await roleButton.evaluate(el => el.textContent?.trim() || '');
|
|
548
|
+
const dataHref = await roleButton.getAttribute('data-href');
|
|
549
|
+
|
|
550
|
+
allInteractions.push({
|
|
551
|
+
type: 'button',
|
|
552
|
+
selector: selector,
|
|
553
|
+
label: label,
|
|
554
|
+
element: roleButton,
|
|
555
|
+
text: text,
|
|
556
|
+
dataHref: dataHref || ''
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const forms = await page.locator('form').all();
|
|
562
|
+
for (const form of forms) {
|
|
563
|
+
const submitButton = form.locator('button[type="submit"], input[type="submit"]').first();
|
|
564
|
+
if (await submitButton.count() > 0) {
|
|
565
|
+
const formAction = await form.getAttribute('action');
|
|
566
|
+
const selector = await generateSelector(submitButton);
|
|
567
|
+
const selectorKey = `form:${selector}`;
|
|
568
|
+
|
|
569
|
+
if (!seenElements.has(selectorKey)) {
|
|
570
|
+
seenElements.add(selectorKey);
|
|
571
|
+
const label = await extractLabel(submitButton);
|
|
572
|
+
const text = await submitButton.evaluate(el => el.textContent?.trim() || el.getAttribute('value') || '');
|
|
573
|
+
|
|
574
|
+
allInteractions.push({
|
|
575
|
+
type: 'form',
|
|
576
|
+
selector: selector,
|
|
577
|
+
label: label || text,
|
|
578
|
+
element: submitButton,
|
|
579
|
+
text: text,
|
|
580
|
+
formAction: formAction || ''
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Prioritize non-navigating interactions so navigation doesn't starve buttons/forms
|
|
587
|
+
const priority = {
|
|
588
|
+
form: 0,
|
|
589
|
+
button: 1,
|
|
590
|
+
toggle: 1,
|
|
591
|
+
link: 2
|
|
592
|
+
};
|
|
593
|
+
const ordered = allInteractions.sort((a, b) => {
|
|
594
|
+
const pa = priority[a.type] ?? 3;
|
|
595
|
+
const pb = priority[b.type] ?? 3;
|
|
596
|
+
if (pa !== pb) return pa - pb;
|
|
597
|
+
return (a.selector || '').localeCompare(b.selector || '');
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// Return ALL interactions (no priority cap)
|
|
601
|
+
return {
|
|
602
|
+
interactions: ordered.map(item => {
|
|
603
|
+
const mapped = {
|
|
604
|
+
type: item.type,
|
|
605
|
+
selector: item.selector,
|
|
606
|
+
label: item.label,
|
|
607
|
+
element: item.element,
|
|
608
|
+
isExternal: item.isExternal || false,
|
|
609
|
+
href: item.href,
|
|
610
|
+
text: item.text,
|
|
611
|
+
dataHref: item.dataHref,
|
|
612
|
+
// @ts-expect-error - dataDanger and dataDestructive are optional runtime properties on interaction objects
|
|
613
|
+
dataDanger: item.dataDanger || false,
|
|
614
|
+
// @ts-expect-error - dataDestructive is an optional runtime property on interaction objects
|
|
615
|
+
dataDestructive: item.dataDestructive || false
|
|
616
|
+
};
|
|
617
|
+
// hasPasswordInput only exists on form types
|
|
618
|
+
if (item.type === 'form' || item.type === 'login') {
|
|
619
|
+
// @ts-expect-error - hasPasswordInput is only on form/login types at runtime
|
|
620
|
+
mapped.hasPasswordInput = item.hasPasswordInput || false;
|
|
621
|
+
}
|
|
622
|
+
return mapped;
|
|
623
|
+
}),
|
|
624
|
+
coverage: {
|
|
625
|
+
candidatesDiscovered: allInteractions.length,
|
|
626
|
+
candidatesSelected: allInteractions.length,
|
|
627
|
+
cap: Infinity,
|
|
628
|
+
capped: false
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|