@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.
Files changed (135) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +24 -36
  4. package/src/cli/commands/default.js +681 -0
  5. package/src/cli/commands/doctor.js +197 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +586 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +297 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +110 -0
  14. package/src/cli/util/expectation-extractor.js +388 -0
  15. package/src/cli/util/findings-writer.js +32 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +412 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +30 -0
  21. package/src/cli/util/project-discovery.js +297 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/runtime-budget.js +147 -0
  26. package/src/cli/util/summary-writer.js +43 -0
  27. package/src/types/global.d.ts +28 -0
  28. package/src/types/ts-ast.d.ts +24 -0
  29. package/src/verax/cli/ci-summary.js +35 -0
  30. package/src/verax/cli/context-explanation.js +89 -0
  31. package/src/verax/cli/doctor.js +277 -0
  32. package/src/verax/cli/error-normalizer.js +154 -0
  33. package/src/verax/cli/explain-output.js +105 -0
  34. package/src/verax/cli/finding-explainer.js +130 -0
  35. package/src/verax/cli/init.js +237 -0
  36. package/src/verax/cli/run-overview.js +163 -0
  37. package/src/verax/cli/url-safety.js +111 -0
  38. package/src/verax/cli/wizard.js +109 -0
  39. package/src/verax/cli/zero-findings-explainer.js +57 -0
  40. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  41. package/src/verax/core/action-classifier.js +86 -0
  42. package/src/verax/core/budget-engine.js +218 -0
  43. package/src/verax/core/canonical-outcomes.js +157 -0
  44. package/src/verax/core/decision-snapshot.js +335 -0
  45. package/src/verax/core/determinism-model.js +432 -0
  46. package/src/verax/core/incremental-store.js +245 -0
  47. package/src/verax/core/invariants.js +356 -0
  48. package/src/verax/core/promise-model.js +230 -0
  49. package/src/verax/core/replay-validator.js +350 -0
  50. package/src/verax/core/replay.js +222 -0
  51. package/src/verax/core/run-id.js +175 -0
  52. package/src/verax/core/run-manifest.js +99 -0
  53. package/src/verax/core/silence-impact.js +369 -0
  54. package/src/verax/core/silence-model.js +523 -0
  55. package/src/verax/detect/comparison.js +7 -34
  56. package/src/verax/detect/confidence-engine.js +764 -329
  57. package/src/verax/detect/detection-engine.js +293 -0
  58. package/src/verax/detect/evidence-index.js +127 -0
  59. package/src/verax/detect/expectation-model.js +241 -168
  60. package/src/verax/detect/explanation-helpers.js +187 -0
  61. package/src/verax/detect/finding-detector.js +450 -0
  62. package/src/verax/detect/findings-writer.js +41 -12
  63. package/src/verax/detect/flow-detector.js +366 -0
  64. package/src/verax/detect/index.js +200 -288
  65. package/src/verax/detect/interactive-findings.js +612 -0
  66. package/src/verax/detect/signal-mapper.js +308 -0
  67. package/src/verax/detect/skip-classifier.js +4 -4
  68. package/src/verax/detect/verdict-engine.js +561 -0
  69. package/src/verax/evidence-index-writer.js +61 -0
  70. package/src/verax/flow/flow-engine.js +3 -2
  71. package/src/verax/flow/flow-spec.js +1 -2
  72. package/src/verax/index.js +103 -15
  73. package/src/verax/intel/effect-detector.js +368 -0
  74. package/src/verax/intel/handler-mapper.js +249 -0
  75. package/src/verax/intel/index.js +281 -0
  76. package/src/verax/intel/route-extractor.js +280 -0
  77. package/src/verax/intel/ts-program.js +256 -0
  78. package/src/verax/intel/vue-navigation-extractor.js +642 -0
  79. package/src/verax/intel/vue-router-extractor.js +325 -0
  80. package/src/verax/learn/action-contract-extractor.js +338 -104
  81. package/src/verax/learn/ast-contract-extractor.js +148 -6
  82. package/src/verax/learn/flow-extractor.js +172 -0
  83. package/src/verax/learn/index.js +36 -2
  84. package/src/verax/learn/manifest-writer.js +122 -58
  85. package/src/verax/learn/project-detector.js +40 -0
  86. package/src/verax/learn/route-extractor.js +28 -97
  87. package/src/verax/learn/route-validator.js +8 -7
  88. package/src/verax/learn/state-extractor.js +212 -0
  89. package/src/verax/learn/static-extractor-navigation.js +114 -0
  90. package/src/verax/learn/static-extractor-validation.js +88 -0
  91. package/src/verax/learn/static-extractor.js +119 -10
  92. package/src/verax/learn/truth-assessor.js +24 -21
  93. package/src/verax/learn/ts-contract-resolver.js +14 -12
  94. package/src/verax/observe/aria-sensor.js +211 -0
  95. package/src/verax/observe/browser.js +30 -6
  96. package/src/verax/observe/console-sensor.js +2 -18
  97. package/src/verax/observe/domain-boundary.js +10 -1
  98. package/src/verax/observe/expectation-executor.js +513 -0
  99. package/src/verax/observe/flow-matcher.js +143 -0
  100. package/src/verax/observe/focus-sensor.js +196 -0
  101. package/src/verax/observe/human-driver.js +660 -273
  102. package/src/verax/observe/index.js +910 -26
  103. package/src/verax/observe/interaction-discovery.js +378 -15
  104. package/src/verax/observe/interaction-runner.js +562 -197
  105. package/src/verax/observe/loading-sensor.js +145 -0
  106. package/src/verax/observe/navigation-sensor.js +255 -0
  107. package/src/verax/observe/network-sensor.js +55 -7
  108. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  109. package/src/verax/observe/observed-expectation.js +305 -0
  110. package/src/verax/observe/page-frontier.js +234 -0
  111. package/src/verax/observe/settle.js +38 -17
  112. package/src/verax/observe/state-sensor.js +393 -0
  113. package/src/verax/observe/state-ui-sensor.js +7 -1
  114. package/src/verax/observe/timing-sensor.js +228 -0
  115. package/src/verax/observe/traces-writer.js +73 -21
  116. package/src/verax/observe/ui-signal-sensor.js +143 -17
  117. package/src/verax/scan-summary-writer.js +80 -15
  118. package/src/verax/shared/artifact-manager.js +111 -9
  119. package/src/verax/shared/budget-profiles.js +136 -0
  120. package/src/verax/shared/caching.js +1 -1
  121. package/src/verax/shared/ci-detection.js +39 -0
  122. package/src/verax/shared/config-loader.js +169 -0
  123. package/src/verax/shared/dynamic-route-utils.js +224 -0
  124. package/src/verax/shared/expectation-coverage.js +44 -0
  125. package/src/verax/shared/expectation-prover.js +81 -0
  126. package/src/verax/shared/expectation-tracker.js +201 -0
  127. package/src/verax/shared/expectations-writer.js +60 -0
  128. package/src/verax/shared/first-run.js +44 -0
  129. package/src/verax/shared/progress-reporter.js +171 -0
  130. package/src/verax/shared/retry-policy.js +9 -1
  131. package/src/verax/shared/root-artifacts.js +49 -0
  132. package/src/verax/shared/scan-budget.js +86 -0
  133. package/src/verax/shared/url-normalizer.js +162 -0
  134. package/src/verax/shared/zip-artifacts.js +66 -0
  135. 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 { HumanBehaviorDriver } from './human-driver.js';
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 > MAX_INTERACTIONS_PER_PAGE;
249
- const selected = sorted.slice(0, MAX_INTERACTIONS_PER_PAGE);
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: MAX_INTERACTIONS_PER_PAGE,
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
+