@veraxhq/verax 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +14 -36
  4. package/src/cli/commands/default.js +523 -0
  5. package/src/cli/commands/doctor.js +165 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +402 -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 +296 -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 +34 -0
  14. package/src/cli/util/expectation-extractor.js +378 -0
  15. package/src/cli/util/findings-writer.js +31 -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 +366 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +29 -0
  21. package/src/cli/util/project-discovery.js +277 -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/summary-writer.js +32 -0
  26. package/src/verax/cli/ci-summary.js +35 -0
  27. package/src/verax/cli/context-explanation.js +89 -0
  28. package/src/verax/cli/doctor.js +277 -0
  29. package/src/verax/cli/error-normalizer.js +154 -0
  30. package/src/verax/cli/explain-output.js +105 -0
  31. package/src/verax/cli/finding-explainer.js +130 -0
  32. package/src/verax/cli/init.js +237 -0
  33. package/src/verax/cli/run-overview.js +163 -0
  34. package/src/verax/cli/url-safety.js +101 -0
  35. package/src/verax/cli/wizard.js +98 -0
  36. package/src/verax/cli/zero-findings-explainer.js +57 -0
  37. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  38. package/src/verax/core/action-classifier.js +86 -0
  39. package/src/verax/core/budget-engine.js +218 -0
  40. package/src/verax/core/canonical-outcomes.js +157 -0
  41. package/src/verax/core/decision-snapshot.js +335 -0
  42. package/src/verax/core/determinism-model.js +403 -0
  43. package/src/verax/core/incremental-store.js +237 -0
  44. package/src/verax/core/invariants.js +356 -0
  45. package/src/verax/core/promise-model.js +230 -0
  46. package/src/verax/core/replay-validator.js +350 -0
  47. package/src/verax/core/replay.js +222 -0
  48. package/src/verax/core/run-id.js +175 -0
  49. package/src/verax/core/run-manifest.js +99 -0
  50. package/src/verax/core/silence-impact.js +369 -0
  51. package/src/verax/core/silence-model.js +521 -0
  52. package/src/verax/detect/comparison.js +2 -34
  53. package/src/verax/detect/confidence-engine.js +764 -329
  54. package/src/verax/detect/detection-engine.js +293 -0
  55. package/src/verax/detect/evidence-index.js +177 -0
  56. package/src/verax/detect/expectation-model.js +194 -172
  57. package/src/verax/detect/explanation-helpers.js +187 -0
  58. package/src/verax/detect/finding-detector.js +450 -0
  59. package/src/verax/detect/findings-writer.js +44 -8
  60. package/src/verax/detect/flow-detector.js +366 -0
  61. package/src/verax/detect/index.js +172 -286
  62. package/src/verax/detect/interactive-findings.js +613 -0
  63. package/src/verax/detect/signal-mapper.js +308 -0
  64. package/src/verax/detect/verdict-engine.js +563 -0
  65. package/src/verax/evidence-index-writer.js +61 -0
  66. package/src/verax/index.js +90 -14
  67. package/src/verax/intel/effect-detector.js +368 -0
  68. package/src/verax/intel/handler-mapper.js +249 -0
  69. package/src/verax/intel/index.js +281 -0
  70. package/src/verax/intel/route-extractor.js +280 -0
  71. package/src/verax/intel/ts-program.js +256 -0
  72. package/src/verax/intel/vue-navigation-extractor.js +579 -0
  73. package/src/verax/intel/vue-router-extractor.js +323 -0
  74. package/src/verax/learn/action-contract-extractor.js +335 -101
  75. package/src/verax/learn/ast-contract-extractor.js +95 -5
  76. package/src/verax/learn/flow-extractor.js +172 -0
  77. package/src/verax/learn/manifest-writer.js +97 -47
  78. package/src/verax/learn/project-detector.js +40 -0
  79. package/src/verax/learn/route-extractor.js +27 -96
  80. package/src/verax/learn/state-extractor.js +212 -0
  81. package/src/verax/learn/static-extractor-navigation.js +114 -0
  82. package/src/verax/learn/static-extractor-validation.js +88 -0
  83. package/src/verax/learn/static-extractor.js +112 -4
  84. package/src/verax/learn/truth-assessor.js +24 -21
  85. package/src/verax/observe/aria-sensor.js +211 -0
  86. package/src/verax/observe/browser.js +10 -5
  87. package/src/verax/observe/console-sensor.js +1 -17
  88. package/src/verax/observe/domain-boundary.js +10 -1
  89. package/src/verax/observe/expectation-executor.js +512 -0
  90. package/src/verax/observe/flow-matcher.js +143 -0
  91. package/src/verax/observe/focus-sensor.js +196 -0
  92. package/src/verax/observe/human-driver.js +643 -275
  93. package/src/verax/observe/index.js +908 -27
  94. package/src/verax/observe/index.js.backup +1 -0
  95. package/src/verax/observe/interaction-discovery.js +365 -14
  96. package/src/verax/observe/interaction-runner.js +563 -198
  97. package/src/verax/observe/loading-sensor.js +139 -0
  98. package/src/verax/observe/navigation-sensor.js +255 -0
  99. package/src/verax/observe/network-sensor.js +55 -7
  100. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  101. package/src/verax/observe/observed-expectation.js +305 -0
  102. package/src/verax/observe/page-frontier.js +234 -0
  103. package/src/verax/observe/settle.js +37 -17
  104. package/src/verax/observe/state-sensor.js +389 -0
  105. package/src/verax/observe/timing-sensor.js +228 -0
  106. package/src/verax/observe/traces-writer.js +61 -20
  107. package/src/verax/observe/ui-signal-sensor.js +136 -17
  108. package/src/verax/scan-summary-writer.js +77 -15
  109. package/src/verax/shared/artifact-manager.js +110 -8
  110. package/src/verax/shared/budget-profiles.js +136 -0
  111. package/src/verax/shared/ci-detection.js +39 -0
  112. package/src/verax/shared/config-loader.js +170 -0
  113. package/src/verax/shared/dynamic-route-utils.js +218 -0
  114. package/src/verax/shared/expectation-coverage.js +44 -0
  115. package/src/verax/shared/expectation-prover.js +81 -0
  116. package/src/verax/shared/expectation-tracker.js +201 -0
  117. package/src/verax/shared/expectations-writer.js +60 -0
  118. package/src/verax/shared/first-run.js +44 -0
  119. package/src/verax/shared/progress-reporter.js +171 -0
  120. package/src/verax/shared/retry-policy.js +14 -1
  121. package/src/verax/shared/root-artifacts.js +49 -0
  122. package/src/verax/shared/scan-budget.js +86 -0
  123. package/src/verax/shared/url-normalizer.js +162 -0
  124. package/src/verax/shared/zip-artifacts.js +65 -0
  125. package/src/verax/validate/context-validator.js +244 -0
  126. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -0,0 +1 @@
1
+ This is a backup of the broken version
@@ -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,12 +71,7 @@ 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
76
  const interactions = [];
78
77
  const seenElements = new Set();
@@ -124,6 +123,8 @@ export async function discoverInteractions(page, baseOrigin) {
124
123
  const text = await button.evaluate(el => el.textContent?.trim() || '');
125
124
  const dataHref = await button.getAttribute('data-href');
126
125
  const dataTestId = await button.getAttribute('data-testid');
126
+ const dataDanger = await button.getAttribute('data-danger');
127
+ const dataDestructive = await button.getAttribute('data-destructive');
127
128
 
128
129
  allInteractions.push({
129
130
  type: isLangToggle ? 'toggle' : 'button',
@@ -135,6 +136,8 @@ export async function discoverInteractions(page, baseOrigin) {
135
136
  text: text,
136
137
  dataHref: dataHref || '',
137
138
  dataTestId: dataTestId || '',
139
+ dataDanger: dataDanger !== null,
140
+ dataDestructive: dataDestructive !== null,
138
141
  isRoleButton: false
139
142
  });
140
143
  }
@@ -202,9 +205,17 @@ export async function discoverInteractions(page, baseOrigin) {
202
205
  for (const form of forms) {
203
206
  const submitButton = form.locator('button[type="submit"], input[type="submit"]').first();
204
207
  if (await submitButton.count() > 0) {
208
+ const formAction = await form.getAttribute('action');
205
209
  const selector = await generateSelector(submitButton);
206
210
  const selectorKey = `form:${selector}`;
207
211
 
212
+ // Check if this is a login form (has password input)
213
+ const hasPasswordInput = await form.locator('input[type="password"]').count() > 0;
214
+ const formText = await form.evaluate(el => el.textContent?.toLowerCase() || '');
215
+ const isLoginForm = hasPasswordInput ||
216
+ /login|signin|sign.in|authenticate/i.test(formText) ||
217
+ /email|username|user/i.test(formText) && hasPasswordInput;
218
+
208
219
  if (!seenElements.has(selectorKey)) {
209
220
  seenElements.add(selectorKey);
210
221
  const label = await extractLabel(submitButton);
@@ -213,7 +224,7 @@ export async function discoverInteractions(page, baseOrigin) {
213
224
  const text = await submitButton.evaluate(el => el.textContent?.trim() || el.getAttribute('value') || '');
214
225
 
215
226
  allInteractions.push({
216
- type: 'form',
227
+ type: isLoginForm ? 'login' : 'form',
217
228
  selector: selector,
218
229
  label: label || text,
219
230
  element: submitButton,
@@ -222,12 +233,185 @@ export async function discoverInteractions(page, baseOrigin) {
222
233
  text: text,
223
234
  dataHref: '',
224
235
  dataTestId: '',
236
+ isRoleButton: false,
237
+ formAction: formAction || '',
238
+ hasPasswordInput: hasPasswordInput
239
+ });
240
+ }
241
+ }
242
+ }
243
+
244
+ // Detect logout actions (buttons/links with logout/signout patterns) - check existing buttons/links first
245
+ for (const item of allInteractions) {
246
+ if (item.type === 'button' || item.type === 'link') {
247
+ const text = (item.text || '').trim().toLowerCase();
248
+ const label = (item.label || '').trim().toLowerCase();
249
+ const combined = `${text} ${label}`;
250
+
251
+ const isLogout = /^(logout|sign\s*out|signout|log\s*out)$/i.test(text) ||
252
+ /^(logout|sign\s*out|signout|log\s*out)$/i.test(label) ||
253
+ (text.includes('logout') || text.includes('sign out') || text.includes('signout'));
254
+
255
+ if (isLogout) {
256
+ const selectorKey = `logout:${item.selector}`;
257
+ if (!seenElements.has(selectorKey)) {
258
+ seenElements.add(selectorKey);
259
+ allInteractions.push({
260
+ type: 'logout',
261
+ selector: item.selector,
262
+ label: item.label,
263
+ element: item.element,
264
+ tagName: item.tagName,
265
+ id: item.id,
266
+ text: item.text,
267
+ dataHref: item.dataHref || '',
268
+ dataTestId: item.dataTestId || '',
269
+ isRoleButton: item.isRoleButton || false
270
+ });
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ // Detect potential protected routes by looking for links/buttons with typical protected path patterns
277
+ const internalLinks = await page.locator('a[href]:not([href*="://"])').all();
278
+ for (const link of internalLinks) {
279
+ const href = await link.getAttribute('href');
280
+ const text = await link.evaluate(el => el.textContent?.trim() || '');
281
+ const id = await link.getAttribute('id') || '';
282
+ const combined = `${href} ${text} ${id}`.toLowerCase();
283
+
284
+ const isProtectedPath = /admin|dashboard|profile|account|settings|private|protected|secure/i.test(href || '') ||
285
+ /admin|dashboard|profile|account|settings/i.test(combined);
286
+
287
+ if (isProtectedPath && href && !href.startsWith('#')) {
288
+ const selector = await generateSelector(link);
289
+ const selectorKey = `auth_guard:${selector}`;
290
+
291
+ if (!seenElements.has(selectorKey)) {
292
+ seenElements.add(selectorKey);
293
+ const label = await extractLabel(link);
294
+ const tagName = await link.evaluate(el => el.tagName.toLowerCase());
295
+
296
+ allInteractions.push({
297
+ type: 'auth_guard',
298
+ selector: selector,
299
+ label: label || text,
300
+ element: link,
301
+ tagName: tagName,
302
+ id: id || '',
303
+ text: text,
304
+ href: href || '',
305
+ dataHref: '',
306
+ dataTestId: '',
225
307
  isRoleButton: false
226
308
  });
227
309
  }
228
310
  }
229
311
  }
230
312
 
313
+ const fileInputs = await page.locator('input[type="file"]:not([disabled])').all();
314
+ for (const fileInput of fileInputs) {
315
+ const selector = await generateSelector(fileInput);
316
+ const selectorKey = `file:${selector}`;
317
+
318
+ if (!seenElements.has(selectorKey)) {
319
+ seenElements.add(selectorKey);
320
+ const label = await extractLabel(fileInput);
321
+ const tagName = await fileInput.evaluate(el => el.tagName.toLowerCase());
322
+ const id = await fileInput.getAttribute('id');
323
+ const accept = await fileInput.getAttribute('accept');
324
+
325
+ allInteractions.push({
326
+ type: 'file_upload',
327
+ selector,
328
+ label: label || 'File upload',
329
+ element: fileInput,
330
+ tagName,
331
+ id: id || '',
332
+ text: accept || '',
333
+ dataHref: '',
334
+ dataTestId: '',
335
+ isRoleButton: false
336
+ });
337
+ }
338
+ }
339
+
340
+ const hoverableCandidates = await page.locator('[aria-haspopup], [data-hover], [role="menu"], [role="menuitem"]').all();
341
+ for (const hoverEl of hoverableCandidates) {
342
+ try {
343
+ const selector = await generateSelector(hoverEl);
344
+ const selectorKey = `hover:${selector}`;
345
+
346
+ if (!seenElements.has(selectorKey)) {
347
+ seenElements.add(selectorKey);
348
+ const label = await extractLabel(hoverEl);
349
+ const tagName = await hoverEl.evaluate(el => el.tagName.toLowerCase());
350
+ const id = await hoverEl.getAttribute('id');
351
+ const text = await hoverEl.evaluate(el => el.textContent?.trim() || '');
352
+ const ariaHasPopup = await hoverEl.getAttribute('aria-haspopup') || '';
353
+ const role = await hoverEl.getAttribute('role') || '';
354
+ const dataHover = await hoverEl.getAttribute('data-hover') || '';
355
+
356
+ const box = await hoverEl.boundingBox();
357
+ if (box && box.width > 0 && box.height > 0) {
358
+ allInteractions.push({
359
+ type: 'hover',
360
+ selector: selector,
361
+ label: label || text || role || 'hoverable',
362
+ element: hoverEl,
363
+ tagName: tagName,
364
+ id: id || '',
365
+ text: text,
366
+ ariaHasPopup: ariaHasPopup,
367
+ role: role,
368
+ dataHover: dataHover,
369
+ dataHref: '',
370
+ dataTestId: '',
371
+ isRoleButton: false
372
+ });
373
+ }
374
+ }
375
+ } catch (error) {
376
+ // Skip if element is invalid
377
+ }
378
+ }
379
+
380
+ const keyboardFocusableElements = await page.locator('button, a[href], input[type="submit"], input[type="button"]').all();
381
+ for (const focusableEl of keyboardFocusableElements) {
382
+ try {
383
+ const selector = await generateSelector(focusableEl);
384
+ const selectorKey = `keyboard:${selector}`;
385
+
386
+ if (!seenElements.has(selectorKey)) {
387
+ seenElements.add(selectorKey);
388
+ const label = await extractLabel(focusableEl);
389
+ const tagName = await focusableEl.evaluate(el => el.tagName.toLowerCase());
390
+ const id = await focusableEl.getAttribute('id');
391
+ const text = await focusableEl.evaluate(el => el.textContent?.trim() || el.value || '');
392
+
393
+ const box = await focusableEl.boundingBox();
394
+ if (box && box.width > 0 && box.height > 0) {
395
+ allInteractions.push({
396
+ type: 'keyboard',
397
+ selector: selector,
398
+ label: label || text,
399
+ element: focusableEl,
400
+ tagName: tagName,
401
+ id: id || '',
402
+ text: text,
403
+ dataHref: '',
404
+ dataTestId: '',
405
+ isRoleButton: false
406
+ });
407
+ }
408
+ }
409
+ } catch (error) {
410
+ // Skip if element is invalid
411
+ }
412
+ }
413
+
414
+
231
415
  const viewport = page.viewportSize();
232
416
  const viewportHeight = viewport ? viewport.height : undefined;
233
417
 
@@ -245,13 +429,13 @@ export async function discoverInteractions(page, baseOrigin) {
245
429
  }
246
430
 
247
431
  const sorted = sortCandidates(allInteractions);
248
- const capped = sorted.length > MAX_INTERACTIONS_PER_PAGE;
249
- const selected = sorted.slice(0, MAX_INTERACTIONS_PER_PAGE);
432
+ const capped = sorted.length > scanBudget.maxInteractionsPerPage;
433
+ const selected = sorted.slice(0, scanBudget.maxInteractionsPerPage);
250
434
 
251
435
  const coverage = {
252
436
  candidatesDiscovered: sorted.length,
253
437
  candidatesSelected: selected.length,
254
- cap: MAX_INTERACTIONS_PER_PAGE,
438
+ cap: scanBudget.maxInteractionsPerPage,
255
439
  capped
256
440
  };
257
441
 
@@ -261,9 +445,176 @@ export async function discoverInteractions(page, baseOrigin) {
261
445
  selector: item.selector,
262
446
  label: item.label,
263
447
  element: item.element,
264
- isExternal: item.isExternal || false
448
+ isExternal: item.isExternal || false,
449
+ href: item.href,
450
+ text: item.text
265
451
  })),
266
452
  coverage
267
453
  };
268
454
  }
269
455
 
456
+ /**
457
+ * Discover ALL interactions on a page (no priority cap).
458
+ * Used for full-site coverage traversal.
459
+ */
460
+ export async function discoverAllInteractions(page, baseOrigin, scanBudget = DEFAULT_SCAN_BUDGET) {
461
+ const currentUrl = page.url();
462
+ const seenElements = new Set();
463
+ const allInteractions = [];
464
+
465
+ const links = await page.locator('a[href]').all();
466
+ for (const link of links) {
467
+ const href = await link.getAttribute('href');
468
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
469
+ const isExternal = isExternalHref(href, baseOrigin, currentUrl);
470
+ const selector = await generateSelector(link);
471
+ const selectorKey = `link:${selector}`;
472
+
473
+ if (!seenElements.has(selectorKey)) {
474
+ seenElements.add(selectorKey);
475
+ const label = await extractLabel(link);
476
+ const text = await link.evaluate(el => el.textContent?.trim() || '');
477
+
478
+ allInteractions.push({
479
+ type: 'link',
480
+ selector: selector,
481
+ label: label,
482
+ element: link,
483
+ isExternal: isExternal,
484
+ href: href,
485
+ text: text
486
+ });
487
+ }
488
+ }
489
+ }
490
+
491
+ const buttons = await page.locator('button:not([disabled])').all();
492
+ for (const button of buttons) {
493
+ const selector = await generateSelector(button);
494
+ const selectorKey = `button:${selector}`;
495
+
496
+ if (!seenElements.has(selectorKey)) {
497
+ seenElements.add(selectorKey);
498
+ const label = await extractLabel(button);
499
+ const elementHandle = await button.elementHandle();
500
+ const isLangToggle = elementHandle ? await isLanguageToggle(elementHandle) : false;
501
+ const text = await button.evaluate(el => el.textContent?.trim() || '');
502
+ const dataHref = await button.getAttribute('data-href');
503
+
504
+ allInteractions.push({
505
+ type: isLangToggle ? 'toggle' : 'button',
506
+ selector: selector,
507
+ label: label,
508
+ element: button,
509
+ text: text,
510
+ dataHref: dataHref || ''
511
+ });
512
+ }
513
+ }
514
+
515
+ const submitInputs = await page.locator('input[type="submit"]:not([disabled]), input[type="button"]:not([disabled])').all();
516
+ for (const input of submitInputs) {
517
+ const selector = await generateSelector(input);
518
+ const selectorKey = `input:${selector}`;
519
+
520
+ if (!seenElements.has(selectorKey)) {
521
+ seenElements.add(selectorKey);
522
+ const label = await extractLabel(input);
523
+ const text = await input.getAttribute('value') || '';
524
+ const dataHref = await input.getAttribute('data-href');
525
+
526
+ allInteractions.push({
527
+ type: 'button',
528
+ selector: selector,
529
+ label: label || text,
530
+ element: input,
531
+ text: text,
532
+ dataHref: dataHref || ''
533
+ });
534
+ }
535
+ }
536
+
537
+ const roleButtons = await page.locator('[role="button"]:not([disabled])').all();
538
+ for (const roleButton of roleButtons) {
539
+ const selector = await generateSelector(roleButton);
540
+ const selectorKey = `role-button:${selector}`;
541
+
542
+ if (!seenElements.has(selectorKey)) {
543
+ seenElements.add(selectorKey);
544
+ const label = await extractLabel(roleButton);
545
+ const text = await roleButton.evaluate(el => el.textContent?.trim() || '');
546
+ const dataHref = await roleButton.getAttribute('data-href');
547
+
548
+ allInteractions.push({
549
+ type: 'button',
550
+ selector: selector,
551
+ label: label,
552
+ element: roleButton,
553
+ text: text,
554
+ dataHref: dataHref || ''
555
+ });
556
+ }
557
+ }
558
+
559
+ const forms = await page.locator('form').all();
560
+ for (const form of forms) {
561
+ const submitButton = form.locator('button[type="submit"], input[type="submit"]').first();
562
+ if (await submitButton.count() > 0) {
563
+ const formAction = await form.getAttribute('action');
564
+ const selector = await generateSelector(submitButton);
565
+ const selectorKey = `form:${selector}`;
566
+
567
+ if (!seenElements.has(selectorKey)) {
568
+ seenElements.add(selectorKey);
569
+ const label = await extractLabel(submitButton);
570
+ const text = await submitButton.evaluate(el => el.textContent?.trim() || el.getAttribute('value') || '');
571
+
572
+ allInteractions.push({
573
+ type: 'form',
574
+ selector: selector,
575
+ label: label || text,
576
+ element: submitButton,
577
+ text: text,
578
+ formAction: formAction || ''
579
+ });
580
+ }
581
+ }
582
+ }
583
+
584
+ // Prioritize non-navigating interactions so navigation doesn't starve buttons/forms
585
+ const priority = {
586
+ form: 0,
587
+ button: 1,
588
+ toggle: 1,
589
+ link: 2
590
+ };
591
+ const ordered = allInteractions.sort((a, b) => {
592
+ const pa = priority[a.type] ?? 3;
593
+ const pb = priority[b.type] ?? 3;
594
+ if (pa !== pb) return pa - pb;
595
+ return (a.selector || '').localeCompare(b.selector || '');
596
+ });
597
+
598
+ // Return ALL interactions (no priority cap)
599
+ return {
600
+ interactions: ordered.map(item => ({
601
+ type: item.type,
602
+ selector: item.selector,
603
+ label: item.label,
604
+ element: item.element,
605
+ isExternal: item.isExternal || false,
606
+ href: item.href,
607
+ text: item.text,
608
+ dataHref: item.dataHref,
609
+ dataDanger: item.dataDanger || false,
610
+ dataDestructive: item.dataDestructive || false
611
+ })),
612
+ coverage: {
613
+ candidatesDiscovered: allInteractions.length,
614
+ candidatesSelected: allInteractions.length,
615
+ cap: Infinity,
616
+ capped: false
617
+ }
618
+ };
619
+ }
620
+