@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
@@ -1,162 +1,117 @@
1
1
  /**
2
- * WAVE 2: Human Behavior Driver
3
- * Realistic interaction execution with scrolling, form filling, and budgeting
2
+ * Human Behavior Driver v1
3
+ * Deterministic, safe, human-like interaction executor for observation.
4
4
  */
5
5
 
6
6
  import { waitForSettle } from './settle.js';
7
+ import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
8
+ import { mkdtempSync, writeFileSync } from 'fs';
9
+ import { tmpdir } from 'os';
10
+ import { join } from 'path';
11
+
12
+ const DEFAULT_SCROLL_STEPS = 5;
13
+ const DEFAULT_SCROLL_PAUSE_MS = 400;
14
+ const HOVER_MS = 120;
15
+ const FOCUS_PAUSE_MS = 40;
16
+ const CLICK_TIMEOUT_MS = 2000;
17
+ const POST_ACTION_TIMEOUT_MS = 1500; // Keep post-action waits short for fast coverage
18
+ const FORM_RETRY_LIMIT = 1;
19
+ const SAFE_INPUT_TYPES = ['text', 'email', 'password', 'number', 'textarea'];
20
+ const DANGEROUS_KEYWORDS = ['delete', 'drop', 'destroy', 'payment', 'card', 'checkout', 'billing'];
21
+ const DEFAULT_UPLOAD_CONTENT = 'verax-upload-fixture';
22
+ const DEFAULT_UPLOAD_NAME = 'verax-upload.txt';
23
+
24
+ const DUMMY_VALUES = {
25
+ text: 'verax-user',
26
+ email: 'verax@example.com',
27
+ password: 'VeraxPass123!',
28
+ number: '7',
29
+ textarea: 'verax message'
30
+ };
7
31
 
8
32
  export class HumanBehaviorDriver {
9
- constructor(options = {}) {
10
- this.maxScrollSteps = options.maxScrollSteps || 5;
11
- this.interactionBudgetPerPage = options.interactionBudgetPerPage || 50;
12
- this.scrollPauseMs = options.scrollPauseMs || 500;
33
+ constructor(options = {}, scanBudget = DEFAULT_SCAN_BUDGET) {
34
+ this.maxScrollSteps = options.maxScrollSteps || DEFAULT_SCROLL_STEPS;
35
+ this.scrollPauseMs = options.scrollPauseMs || DEFAULT_SCROLL_PAUSE_MS;
36
+ this.postActionTimeoutMs = options.postActionTimeoutMs || POST_ACTION_TIMEOUT_MS;
37
+ this.scanBudget = scanBudget;
38
+ this.interactionBudgetPerPage = options.interactionBudgetPerPage || 20;
13
39
  }
14
40
 
15
- /**
16
- * Discover all interactive elements on current page with scrolling.
17
- * Returns stable set of unique elements after scroll-and-rediscover passes.
18
- */
19
41
  async discoverInteractionsWithScroll(page) {
20
- const discovered = new Map(); // key: selector, value: element data
42
+ const discovered = new Map();
21
43
 
22
- // Initial discovery before any scrolling
23
- await this.discoverElements(page, discovered);
24
-
25
- // Progressive scrolling discovery
26
- const viewportHeight = await page.evaluate(() => window.innerHeight);
27
- const maxScrollDistance = await page.evaluate(() => document.documentElement.scrollHeight);
44
+ await this.captureElements(page, discovered);
28
45
 
46
+ const maxScrollDistance = await page.evaluate(() => document.documentElement.scrollHeight || 0);
29
47
  for (let step = 0; step < this.maxScrollSteps; step++) {
30
- // Calculate scroll position (0%, 25%, 50%, 75%, 100%)
31
- const scrollPercent = (step + 1) / this.maxScrollSteps;
32
- const targetScroll = Math.min(maxScrollDistance, scrollPercent * maxScrollDistance);
33
-
34
- await page.evaluate((scroll) => window.scrollTo(0, scroll), targetScroll);
48
+ const pct = (step + 1) / this.maxScrollSteps;
49
+ const target = Math.min(maxScrollDistance, Math.floor(maxScrollDistance * pct));
50
+ await page.evaluate((scrollY) => window.scrollTo(0, scrollY), target);
35
51
  await page.waitForTimeout(this.scrollPauseMs);
36
-
37
- // Rediscover after scroll
38
- await this.discoverElements(page, discovered);
52
+ await this.captureElements(page, discovered);
39
53
  }
40
54
 
41
- // Return to top
42
55
  await page.evaluate(() => window.scrollTo(0, 0));
43
-
44
56
  return Array.from(discovered.values());
45
57
  }
46
58
 
47
- /**
48
- * Discover interactive elements and merge into stable map.
49
- * Handles: links, buttons, forms, role=button elements
50
- */
51
- async discoverElements(page, discovered) {
59
+ async captureElements(page, discovered) {
52
60
  const elements = await page.evaluate(() => {
53
61
  const result = [];
54
-
55
- // Links
56
- document.querySelectorAll('a[href]').forEach((el) => {
57
- if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
58
- result.push({
59
- type: 'link',
60
- selector: generateSelector(el),
61
- href: el.getAttribute('href'),
62
- text: el.textContent.trim().slice(0, 100),
63
- visible: true
64
- });
65
- }
66
- });
67
-
68
- // Buttons
69
- document.querySelectorAll('button').forEach((el) => {
70
- if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
62
+ const candidates = [
63
+ ['a[href]', 'link'],
64
+ ['button', 'button'],
65
+ ['input[type="submit"], input[type="button"]', 'button'],
66
+ ['[role="button"]', 'button']
67
+ ];
68
+
69
+ for (const [selector, type] of candidates) {
70
+ document.querySelectorAll(selector).forEach((el) => {
71
+ if (!isVisible(el) || el.hasAttribute('data-skip-verify')) return;
71
72
  result.push({
72
- type: 'button',
73
+ type,
73
74
  selector: generateSelector(el),
74
- text: el.textContent.trim().slice(0, 100),
75
+ href: el.getAttribute('href') || '',
76
+ text: (el.textContent || '').trim().slice(0, 100),
75
77
  visible: true
76
78
  });
77
- }
78
- });
79
-
80
- // Form inputs
81
- document.querySelectorAll('input[type="submit"], input[type="button"]').forEach((el) => {
82
- if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
83
- result.push({
84
- type: 'button',
85
- selector: generateSelector(el),
86
- text: el.value || el.getAttribute('title') || 'Submit',
87
- visible: true
88
- });
89
- }
90
- });
91
-
92
- // Role=button elements
93
- document.querySelectorAll('[role="button"]').forEach((el) => {
94
- if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
95
- result.push({
96
- type: 'button',
97
- selector: generateSelector(el),
98
- text: el.textContent.trim().slice(0, 100),
99
- visible: true
100
- });
101
- }
102
- });
79
+ });
80
+ }
103
81
 
104
82
  return result;
105
83
 
106
- // Helper: Check if element is visible
107
84
  function isVisible(el) {
108
- if (!el.offsetParent) return false;
85
+ if (!el) return false;
86
+ const rect = el.getBoundingClientRect();
87
+ if (rect.width === 0 || rect.height === 0) return false;
109
88
  const style = window.getComputedStyle(el);
110
- if (style.display === 'none' || style.visibility === 'hidden') return false;
111
- return true;
89
+ return style.visibility !== 'hidden' && style.display !== 'none';
112
90
  }
113
91
 
114
- // Helper: Generate stable selector
115
92
  function generateSelector(el) {
116
- // Try ID first
117
93
  if (el.id) return `#${el.id}`;
118
-
119
- // Try data attributes
120
- if (el.dataset.testid) return `[data-testid="${el.dataset.testid}"]`;
121
-
122
- // Use CSS selector generation
94
+ if (el.dataset && el.dataset.testid) return `[data-testid="${el.dataset.testid}"]`;
123
95
  const path = [];
124
96
  let current = el;
125
97
  while (current && current !== document.documentElement) {
126
98
  let selector = current.tagName.toLowerCase();
127
- if (current.id) {
128
- selector += `#${current.id}`;
129
- path.unshift(selector);
130
- break;
131
- }
132
-
133
- // Add class if present
134
99
  if (current.className) {
135
- const classes = current.className
136
- .split(' ')
100
+ const cls = Array.from(current.classList || [])
137
101
  .filter((c) => c && !c.startsWith('__'))
138
102
  .join('.');
139
- if (classes) selector += `.${classes}`;
103
+ if (cls) selector += `.${cls}`;
140
104
  }
141
-
142
- // Add nth-child for uniqueness
143
- let sibling = current;
144
- let index = 0;
145
- while (sibling) {
146
- if (sibling.tagName === current.tagName) index++;
147
- sibling = sibling.previousElementSibling;
148
- }
149
- if (index > 0) selector += `:nth-of-type(${index})`;
150
-
105
+ const siblings = Array.from(current.parentElement ? current.parentElement.children : []);
106
+ const index = siblings.filter((sib) => sib.tagName === current.tagName).indexOf(current);
107
+ if (index >= 0) selector += `:nth-of-type(${index + 1})`;
151
108
  path.unshift(selector);
152
109
  current = current.parentElement;
153
110
  }
154
-
155
111
  return path.join(' > ');
156
112
  }
157
113
  });
158
114
 
159
- // Merge into stable map (deduplicate by selector)
160
115
  for (const el of elements) {
161
116
  if (!discovered.has(el.selector)) {
162
117
  discovered.set(el.selector, el);
@@ -164,213 +119,626 @@ export class HumanBehaviorDriver {
164
119
  }
165
120
  }
166
121
 
167
- /**
168
- * Select interactions according to budget with deterministic prioritization.
169
- * Returns { selected, skipped, budget }
170
- */
171
- selectByBudget(discovered) {
172
- const selected = [];
173
- const skipped = [];
174
-
175
- // Sort deterministically: by priority category, then text/href for stability
176
- const sorted = discovered.sort((a, b) => {
177
- const priorityA = getPriority(a);
178
- const priorityB = getPriority(b);
179
- if (priorityA !== priorityB) return priorityA - priorityB;
122
+ async scrollIntoView(page, locator) {
123
+ try {
124
+ await locator.scrollIntoViewIfNeeded();
125
+ await page.waitForTimeout(FOCUS_PAUSE_MS);
126
+ } catch {
127
+ // Best-effort scroll
128
+ }
129
+ }
180
130
 
181
- const textA = (a.text || a.href || '').toLowerCase();
182
- const textB = (b.text || b.href || '').toLowerCase();
183
- return textA.localeCompare(textB);
184
- });
131
+ async hover(page, locator) {
132
+ try {
133
+ await locator.hover({ timeout: CLICK_TIMEOUT_MS });
134
+ await page.waitForTimeout(HOVER_MS);
135
+ } catch {
136
+ // Hover is optional; do not fail the interaction
137
+ }
138
+ }
185
139
 
186
- // Allocate budget across categories
187
- const categories = { link: [], button: [], form: [] };
188
- for (const el of sorted) {
189
- const cat = el.type === 'link' ? 'link' : 'button';
190
- categories[cat].push(el);
140
+ async focus(page, locator) {
141
+ try {
142
+ await locator.focus({ timeout: CLICK_TIMEOUT_MS });
143
+ await page.waitForTimeout(FOCUS_PAUSE_MS);
144
+ } catch {
145
+ // Focus best-effort
191
146
  }
147
+ }
148
+
149
+ async waitAfterAction(page, timeoutMs = this.postActionTimeoutMs) {
150
+ // Use shorter waits on local fixture pages to keep tests fast
151
+ try {
152
+ const url = page.url() || '';
153
+ if (url.startsWith('file:')) {
154
+ timeoutMs = 50; // Minimal wait for file:// fixtures
155
+ // Skip settle wait entirely for file://
156
+ await page.waitForTimeout(timeoutMs);
157
+ return;
158
+ } else if (url.includes('localhost:') || url.includes('127.0.0.1')) {
159
+ timeoutMs = 200; // Short wait for local http fixtures
160
+ }
161
+ } catch {}
162
+
163
+ const waitForUiIdle = async () => {
164
+ const start = Date.now();
165
+ while (Date.now() - start < timeoutMs) {
166
+ const busy = await page.evaluate(() => {
167
+ const loading = document.querySelector('[aria-busy="true"], .loading, .spinner, [data-loading="true"]');
168
+ return Boolean(loading);
169
+ }).catch(() => false);
170
+ if (!busy) {
171
+ await page.waitForTimeout(120);
172
+ const stillBusy = await page.evaluate(() => {
173
+ const loading = document.querySelector('[aria-busy="true"], .loading, .spinner, [data-loading="true"]');
174
+ return Boolean(loading);
175
+ }).catch(() => false);
176
+ if (!stillBusy) return;
177
+ }
178
+ await page.waitForTimeout(120);
179
+ }
180
+ };
181
+
182
+ // Wait for network idle with longer timeout to catch slow requests
183
+ await Promise.race([
184
+ page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {}),
185
+ waitForUiIdle(),
186
+ waitForSettle(page, this.scanBudget)
187
+ ]);
188
+ }
189
+
190
+ async clickElement(page, locator) {
191
+ await this.scrollIntoView(page, locator);
192
+ await this.hover(page, locator);
193
+ await this.focus(page, locator);
194
+ await locator.click({ timeout: CLICK_TIMEOUT_MS });
195
+ await this.waitAfterAction(page);
196
+ return { clicked: true };
197
+ }
198
+
199
+ async fillFormFields(page, submitLocator) {
200
+ const submitHandle = await submitLocator.elementHandle();
201
+ if (!submitHandle) return { filled: [], submitted: false, reason: 'SUBMIT_NOT_FOUND' };
202
+
203
+ const result = await submitHandle.evaluate(
204
+ (submitEl, payload) => {
205
+ const { dummyValues, dangerous, safeTypes } = payload;
206
+ const form = submitEl.closest('form');
207
+ if (!form) return { filled: [], submitted: false, reason: 'FORM_NOT_FOUND' };
208
+
209
+ const combinedText = (form.textContent || '').toLowerCase();
210
+ if (dangerous.some((kw) => combinedText.includes(kw))) {
211
+ return { filled: [], submitted: false, reason: 'FORM_DANGEROUS' };
212
+ }
213
+
214
+ const inputs = Array.from(form.querySelectorAll('input, textarea')).filter((input) => {
215
+ const type = (input.getAttribute('type') || input.tagName || '').toLowerCase();
216
+ if (['submit', 'button', 'hidden', 'file'].includes(type)) return false;
217
+ if (!safeTypes.includes(type) && !(type === '' && input.tagName.toLowerCase() === 'input')) return false;
218
+ if (input.disabled || input.readOnly) return false;
219
+ const style = window.getComputedStyle(input);
220
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
221
+ const name = (input.name || input.id || '').toLowerCase();
222
+ const placeholder = (input.getAttribute('placeholder') || '').toLowerCase();
223
+ if (dangerous.some((kw) => name.includes(kw) || placeholder.includes(kw))) return false;
224
+ return true;
225
+ });
226
+
227
+ const filled = [];
228
+ for (const input of inputs) {
229
+ const type = (input.getAttribute('type') || input.tagName || '').toLowerCase();
230
+ const valueKey = type === '' || type === 'input' ? 'text' : type === 'textarea' ? 'textarea' : type;
231
+ const value = dummyValues[valueKey] || dummyValues.text;
232
+ input.focus();
233
+ input.value = value;
234
+ input.dispatchEvent(new Event('input', { bubbles: true }));
235
+ input.dispatchEvent(new Event('change', { bubbles: true }));
236
+ filled.push({ name: input.name || input.id || valueKey, value });
237
+ }
238
+
239
+ return {
240
+ filled,
241
+ submitted: false,
242
+ reason: inputs.length === 0 ? 'NO_SAFE_FIELDS' : null
243
+ };
244
+ },
245
+ {
246
+ dummyValues: DUMMY_VALUES,
247
+ dangerous: DANGEROUS_KEYWORDS,
248
+ safeTypes: SAFE_INPUT_TYPES
249
+ }
250
+ );
192
251
 
193
- // Distribute budget: 40% links, 40% buttons, 20% forms
194
- const linkBudget = Math.floor(this.interactionBudgetPerPage * 0.4);
195
- const buttonBudget = Math.floor(this.interactionBudgetPerPage * 0.4);
196
- const formBudget = Math.floor(this.interactionBudgetPerPage * 0.2);
252
+ return result;
253
+ }
197
254
 
198
- for (let i = 0; i < categories.link.length; i++) {
199
- if (i < linkBudget) selected.push(categories.link[i]);
200
- else skipped.push(categories.link[i]);
255
+ async submitForm(page, submitLocator) {
256
+ let attempts = 0;
257
+ while (attempts <= FORM_RETRY_LIMIT) {
258
+ attempts += 1;
259
+ try {
260
+ await this.clickElement(page, submitLocator);
261
+ return { submitted: true, attempts };
262
+ } catch (error) {
263
+ if (attempts > FORM_RETRY_LIMIT) {
264
+ return { submitted: false, attempts, error: error.message };
265
+ }
266
+ }
201
267
  }
268
+ return { submitted: false, attempts };
269
+ }
270
+
271
+ selectByBudget(discovered) {
272
+ const budget = this.interactionBudgetPerPage;
273
+ const sorted = [...discovered].sort((a, b) => (a.selector || '').localeCompare(b.selector || ''));
274
+
275
+ const links = sorted.filter(item => item.type === 'link');
276
+ const buttons = sorted.filter(item => item.type === 'button');
277
+ const forms = sorted.filter(item => item.type === 'form');
278
+ const others = sorted.filter(item => !['link', 'button', 'form'].includes(item.type));
279
+
280
+ const allocation = {
281
+ links: Math.floor(budget * 0.4),
282
+ buttons: Math.floor(budget * 0.4),
283
+ forms: Math.min(forms.length, Math.max(0, budget - Math.floor(budget * 0.4) - Math.floor(budget * 0.4)))
284
+ };
285
+
286
+ const selected = [];
287
+ const take = (list, count) => list.slice(0, Math.max(0, Math.min(count, list.length)));
202
288
 
203
- for (let i = 0; i < categories.button.length; i++) {
204
- if (i < buttonBudget) selected.push(categories.button[i]);
205
- else skipped.push(categories.button[i]);
289
+ selected.push(...take(links, allocation.links));
290
+ selected.push(...take(buttons, allocation.buttons));
291
+ selected.push(...take(forms, allocation.forms));
292
+
293
+ const remainingBudget = Math.max(0, budget - selected.length);
294
+ if (remainingBudget > 0) {
295
+ const alreadySelected = new Set(selected.map(item => item.selector));
296
+ const filler = [...links, ...buttons, ...forms, ...others].filter(item => !alreadySelected.has(item.selector));
297
+ selected.push(...take(filler, remainingBudget));
206
298
  }
207
299
 
208
300
  return {
209
301
  selected,
210
- skipped,
211
- discoveredCount: discovered.length,
212
302
  selectedCount: selected.length,
213
- skippedDueToBudgetCount: skipped.length,
214
- budgetUsed: selected.length,
215
- budgetAvailable: this.interactionBudgetPerPage
303
+ discoveredCount: discovered.length,
304
+ budgetAvailable: budget,
305
+ skippedDueToBudgetCount: Math.max(0, discovered.length - selected.length)
216
306
  };
217
307
  }
218
308
 
219
- /**
220
- * Fill and submit a form with realistic dummy data.
221
- * Respects safety rules: skips payment, checkout, delete, etc.
222
- */
223
309
  async fillAndSubmitForm(page, formSelector) {
224
- // Check safety rules first
225
- const isDangerous = await page.evaluate((sel) => {
226
- const form = document.querySelector(sel);
227
- if (!form) return true;
228
-
229
- const text = form.textContent.toLowerCase();
230
- const dangerousKeywords = ['pay', 'checkout', 'delete', 'remove', 'unsubscribe', 'billing', 'credit card'];
231
- if (dangerousKeywords.some((kw) => text.includes(kw))) return true;
232
-
233
- // Check buttons
234
- const buttons = form.querySelectorAll('button, input[type="submit"]');
235
- for (const btn of buttons) {
236
- const btnText = btn.textContent.toLowerCase();
237
- if (dangerousKeywords.some((kw) => btnText.includes(kw))) return true;
310
+ const form = page.locator(formSelector).first();
311
+ if (await form.count() === 0) {
312
+ return { success: false, reason: 'FORM_NOT_FOUND', filled: [], submitted: false };
313
+ }
314
+
315
+ const submitLocator = form.locator('button[type="submit"], input[type="submit"]').first();
316
+ if (await submitLocator.count() === 0) {
317
+ return { success: false, reason: 'SUBMIT_NOT_FOUND', filled: [], submitted: false };
318
+ }
319
+
320
+ const fillResult = await this.fillFormFields(page, submitLocator);
321
+ const submitResult = await this.submitForm(page, submitLocator);
322
+ return {
323
+ success: submitResult.submitted,
324
+ reason: fillResult.reason || (submitResult.submitted ? null : 'SUBMIT_FAILED'),
325
+ filled: fillResult.filled || [],
326
+ submitted: submitResult.submitted,
327
+ attempts: submitResult.attempts
328
+ };
329
+ }
330
+
331
+ async performKeyboardNavigation(page, maxTabs = 12) {
332
+ const actions = [];
333
+ const focusOrder = [];
334
+
335
+ await page.focus('body').catch(() => {});
336
+
337
+ const focusableSelectors = await page.evaluate(() => {
338
+ const focusables = Array.from(document.querySelectorAll('a[href], button, input, select, textarea, [tabindex], [role="button"], [role="menuitem"], [contenteditable="true"]'))
339
+ .filter(el => !el.hasAttribute('disabled') && el.tabIndex >= 0 && el.offsetParent !== null);
340
+ const describe = (el) => {
341
+ if (!el) return 'body';
342
+ if (el.id) return `#${el.id}`;
343
+ if (el.getAttribute('data-testid')) return `[data-testid="${el.getAttribute('data-testid')}"]`;
344
+ const tag = el.tagName.toLowerCase();
345
+ const cls = (el.className || '').toString().trim().split(/\s+/).filter(Boolean).slice(0, 3).join('.');
346
+ return cls ? `${tag}.${cls}` : tag;
347
+ };
348
+ return focusables.map(describe).slice(0, 50);
349
+ });
350
+
351
+ const tabLimit = Math.min(maxTabs, focusableSelectors.length || maxTabs);
352
+
353
+ for (let i = 0; i < tabLimit; i++) {
354
+ await page.keyboard.press('Tab');
355
+ await page.waitForTimeout(FOCUS_PAUSE_MS);
356
+ const active = await page.evaluate(() => {
357
+ const el = document.activeElement;
358
+ if (!el) return { selector: 'body', tag: 'body', role: '', type: '', modal: false };
359
+ const tag = el.tagName.toLowerCase();
360
+ const role = el.getAttribute('role') || '';
361
+ const type = el.getAttribute('type') || '';
362
+ const id = el.id ? `#${el.id}` : '';
363
+ const testId = el.getAttribute('data-testid') ? `[data-testid="${el.getAttribute('data-testid')}"]` : '';
364
+ const cls = (el.className || '').toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join('.');
365
+ const selector = id || testId || (cls ? `${tag}.${cls}` : tag);
366
+ const modal = Boolean(el.closest('[role="dialog"], [aria-modal="true"], .modal'));
367
+ return { selector, tag, role, type, modal };
368
+ });
369
+
370
+ focusOrder.push(active.selector);
371
+ actions.push({ action: 'tab', target: active.selector });
372
+ const isActionable = ['a', 'button'].includes(active.tag) || active.role === 'button' || active.role === 'link' || ['submit', 'button'].includes((active.type || '').toLowerCase());
373
+ if (isActionable) {
374
+ await page.keyboard.press('Enter');
375
+ actions.push({ action: 'enter', target: active.selector });
376
+ await this.waitAfterAction(page, 150);
238
377
  }
239
378
 
240
- return false;
241
- }, formSelector);
379
+ if (active.modal) {
380
+ await page.keyboard.press('Escape');
381
+ actions.push({ action: 'escape', target: active.selector });
382
+ await this.waitAfterAction(page, 120);
383
+ }
384
+ }
385
+
386
+ return {
387
+ focusOrder,
388
+ actions,
389
+ attemptedTabs: tabLimit
390
+ };
391
+ }
242
392
 
243
- if (isDangerous) {
244
- return { success: false, reason: 'DANGEROUS_FORM_SKIPPED' };
393
+ async hoverAndObserve(page, locator) {
394
+ await this.scrollIntoView(page, locator);
395
+ await this.hover(page, locator);
396
+ await this.waitAfterAction(page, 200);
397
+ const hoveredSelector = await locator.evaluate(el => {
398
+ const id = el.id ? `#${el.id}` : '';
399
+ const testId = el.getAttribute('data-testid') ? `[data-testid="${el.getAttribute('data-testid')}"]` : '';
400
+ const tag = el.tagName.toLowerCase();
401
+ const cls = (el.className || '').toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join('.');
402
+ return id || testId || (cls ? `${tag}.${cls}` : tag);
403
+ }).catch(() => locator.selector());
404
+
405
+ const revealState = await page.evaluate(() => {
406
+ const revealed = document.querySelector('[data-hovered="true"], [data-menu-open="true"], [data-hover-visible="true"]');
407
+ return Boolean(revealed);
408
+ }).catch(() => false);
409
+
410
+ return {
411
+ hovered: true,
412
+ selector: hoveredSelector,
413
+ revealed: revealState
414
+ };
415
+ }
416
+
417
+ async uploadFile(page, locator, filePath = null) {
418
+ const uploadPath = this.ensureUploadFixture(filePath);
419
+ await this.scrollIntoView(page, locator);
420
+ try {
421
+ await locator.setInputFiles(uploadPath);
422
+ } catch {
423
+ return { attached: false, filePath: uploadPath, submitted: false, attempts: 0, submitSelector: null };
245
424
  }
246
425
 
247
- // Fill required fields
248
- const filled = await page.evaluate((sel) => {
249
- const form = document.querySelector(sel);
250
- if (!form) return { filled: [], errors: [] };
426
+ // Attempt to submit via nearest form if present
427
+ const submitSelector = await locator.evaluate((inputEl) => {
428
+ const form = inputEl.closest('form');
429
+ if (!form) return null;
430
+ const submit = form.querySelector('button[type="submit"], input[type="submit"], button');
431
+ if (!submit) return null;
432
+ if (submit.id) return `#${submit.id}`;
433
+ if (submit.getAttribute('data-testid')) return `[data-testid="${submit.getAttribute('data-testid')}"]`;
434
+ return 'form button[type="submit"], form input[type="submit"], form button';
435
+ }).catch(() => null);
436
+
437
+ let submitResult = { submitted: false, attempts: 0 };
438
+ if (submitSelector) {
439
+ const submitLocator = page.locator(submitSelector).first();
440
+ if (await submitLocator.count() > 0) {
441
+ submitResult = await this.submitForm(page, submitLocator);
442
+ }
443
+ }
251
444
 
252
- const filled = [];
253
- const errors = [];
445
+ await this.waitAfterAction(page, 200);
254
446
 
255
- // Find all input elements
256
- const inputs = form.querySelectorAll('input, textarea, select');
447
+ return {
448
+ attached: true,
449
+ filePath: uploadPath,
450
+ submitted: submitResult.submitted,
451
+ attempts: submitResult.attempts,
452
+ submitSelector: submitSelector || null
453
+ };
454
+ }
257
455
 
258
- for (const input of inputs) {
259
- if (input.disabled || input.readOnly) continue;
260
- if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') continue;
456
+ ensureUploadFixture(filePath) {
457
+ if (filePath) {
458
+ return filePath;
459
+ }
460
+ const tmpDir = mkdtempSync(join(tmpdir(), 'verax-upload-'));
461
+ const resolved = join(tmpDir, DEFAULT_UPLOAD_NAME);
462
+ writeFileSync(resolved, DEFAULT_UPLOAD_CONTENT, 'utf-8');
463
+ return resolved;
464
+ }
261
465
 
262
- try {
263
- if (input.tagName === 'SELECT') {
264
- // Choose first non-empty option
265
- const options = input.querySelectorAll('option');
266
- if (options.length > 1) {
267
- input.value = options[1].value;
268
- input.dispatchEvent(new Event('change', { bubbles: true }));
269
- filled.push({ name: input.name || input.id || 'select', value: input.value });
270
- }
271
- } else if (input.type === 'checkbox' || input.type === 'radio') {
272
- // Choose first option
273
- input.checked = true;
274
- input.dispatchEvent(new Event('change', { bubbles: true }));
275
- filled.push({ name: input.name || input.id || 'checkbox', value: 'checked' });
276
- } else if (input.type === 'email') {
277
- input.value = 'test@example.com';
278
- input.dispatchEvent(new Event('change', { bubbles: true }));
279
- filled.push({ name: input.name || input.id || 'email', value: 'test@example.com' });
280
- } else if (input.type === 'tel') {
281
- input.value = '+1-555-0123';
282
- input.dispatchEvent(new Event('change', { bubbles: true }));
283
- filled.push({ name: input.name || input.id || 'tel', value: '+1-555-0123' });
284
- } else if (input.type === 'url') {
285
- input.value = 'https://example.com';
286
- input.dispatchEvent(new Event('change', { bubbles: true }));
287
- filled.push({ name: input.name || input.id || 'url', value: 'https://example.com' });
288
- } else if (input.tagName === 'TEXTAREA') {
289
- input.value = 'Test message';
290
- input.dispatchEvent(new Event('change', { bubbles: true }));
291
- filled.push({ name: input.name || input.id || 'textarea', value: 'Test message' });
292
- } else if (input.type === 'text' || input.type === '') {
293
- // Guess based on name/label
294
- const name = (input.name || input.id || '').toLowerCase();
295
- let value = 'John Doe';
296
- if (name.includes('email')) value = 'test@example.com';
297
- else if (name.includes('name')) value = 'John Doe';
298
- else if (name.includes('phone') || name.includes('tel')) value = '+1-555-0123';
299
- else if (name.includes('address') || name.includes('street')) value = '123 Main St';
300
- else if (name.includes('city')) value = 'Anytown';
301
- else if (name.includes('state') || name.includes('province')) value = 'CA';
302
- else if (name.includes('zip') || name.includes('postal')) value = '12345';
303
-
304
- input.value = value;
305
- input.dispatchEvent(new Event('change', { bubbles: true }));
306
- filled.push({ name: input.name || input.id || 'text', value });
466
+ async navigateWithKeyboard(page, targetLocator) {
467
+ try {
468
+ await targetLocator.focus({ timeout: CLICK_TIMEOUT_MS });
469
+ await page.waitForTimeout(FOCUS_PAUSE_MS);
470
+ await page.keyboard.press('Enter');
471
+ await this.waitAfterAction(page);
472
+ return { navigated: true, method: 'keyboard' };
473
+ } catch (error) {
474
+ return { navigated: false, method: 'keyboard', error: error.message };
475
+ }
476
+ }
477
+
478
+ async tabThroughFocusableElements(page) {
479
+ const focusableElements = await page.evaluate(() => {
480
+ const selectors = [
481
+ 'a[href]',
482
+ 'button:not([disabled])',
483
+ 'input:not([disabled]):not([type="hidden"])',
484
+ 'textarea:not([disabled])',
485
+ 'select:not([disabled])',
486
+ '[tabindex]:not([tabindex="-1"])',
487
+ '[contenteditable="true"]'
488
+ ];
489
+
490
+ const elements = [];
491
+ for (const selector of selectors) {
492
+ document.querySelectorAll(selector).forEach(el => {
493
+ const rect = el.getBoundingClientRect();
494
+ const style = window.getComputedStyle(el);
495
+ if (rect.width > 0 && rect.height > 0 &&
496
+ style.visibility !== 'hidden' &&
497
+ style.display !== 'none' &&
498
+ !elements.includes(el)) {
499
+ elements.push(el);
307
500
  }
308
- } catch (err) {
309
- errors.push({ name: input.name || input.id, error: err.message });
310
- }
501
+ });
311
502
  }
503
+
504
+ return elements.map(el => {
505
+ const rect = el.getBoundingClientRect();
506
+ return {
507
+ tagName: el.tagName.toLowerCase(),
508
+ type: el.type || '',
509
+ role: el.getAttribute('role') || '',
510
+ id: el.id || '',
511
+ text: (el.textContent || el.value || '').trim().slice(0, 50),
512
+ boundingY: rect.y
513
+ };
514
+ });
515
+ });
516
+
517
+ return focusableElements;
518
+ }
312
519
 
313
- return { filled, errors };
314
- }, formSelector);
520
+ async executeLogin(page, submitLocator) {
521
+ const creds = { email: 'verax@example.com', password: 'VeraxPass123!' };
522
+ const beforeState = await this.captureSessionState(page);
523
+ const beforeUrl = page.url();
315
524
 
316
- // Submit form
317
- const submitResult = await page.evaluate((sel) => {
318
- const form = document.querySelector(sel);
319
- if (!form) return { submitted: false, reason: 'FORM_NOT_FOUND' };
525
+ try {
526
+ // Fill login form fields
527
+ const formHandle = await submitLocator.evaluateHandle(el => el.closest('form'));
528
+ if (!formHandle.asElement()) {
529
+ return { submitted: false, found: false, redirected: false, url: beforeUrl, storageChanged: false, cookiesChanged: false };
530
+ }
320
531
 
321
- // Find submit button
322
- let submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
532
+ // Find and fill email/username input
533
+ const emailInput = await page.locator('form input[type="email"], form input[name*="email" i], form input[name*="user" i], form input[name*="login" i]').first();
534
+ if (await emailInput.count() > 0) {
535
+ await emailInput.fill(creds.email);
536
+ }
323
537
 
324
- if (!submitBtn) {
325
- // Try click form submit
326
- form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
327
- return { submitted: true, method: 'form_submit_event' };
538
+ // Find and fill password input
539
+ const passwordInput = await page.locator('form input[type="password"]').first();
540
+ if (await passwordInput.count() > 0) {
541
+ await passwordInput.fill(creds.password);
328
542
  }
329
543
 
330
- // Click submit button
331
- submitBtn.click();
332
- return { submitted: true, method: 'button_click' };
333
- }, formSelector);
544
+ // Submit form
545
+ await this.scrollIntoView(page, submitLocator);
546
+ await this.hover(page, submitLocator);
547
+ await submitLocator.click({ timeout: CLICK_TIMEOUT_MS });
548
+ await this.waitAfterAction(page, 600);
549
+
550
+ const afterState = await this.captureSessionState(page);
551
+ const afterUrl = page.url();
552
+
553
+ const storageChanged = JSON.stringify(Object.keys(beforeState.localStorage)) !== JSON.stringify(Object.keys(afterState.localStorage));
554
+ const cookiesChanged = beforeState.cookies.length !== afterState.cookies.length;
555
+ const redirected = beforeUrl !== afterUrl;
556
+
557
+ return {
558
+ submitted: true,
559
+ found: true,
560
+ redirected,
561
+ url: afterUrl,
562
+ storageChanged,
563
+ cookiesChanged
564
+ };
565
+ } catch (error) {
566
+ return { submitted: false, found: true, redirected: false, url: beforeUrl, storageChanged: false, cookiesChanged: false, error: error.message };
567
+ }
568
+ }
569
+
570
+ async performLogin(page, credentials = null) {
571
+ const creds = credentials || { email: 'verax@example.com', password: 'VeraxPass123!' };
572
+ const beforeUrl = page.url();
573
+ const beforeStorageKeys = await page.evaluate(() => Object.keys(localStorage));
574
+ const beforeCookies = await page.context().cookies();
575
+
576
+ // Find login form
577
+ const loginForm = await page.evaluate(() => {
578
+ const forms = Array.from(document.querySelectorAll('form'));
579
+ for (const form of forms) {
580
+ const hasPassword = form.querySelector('input[type="password"]');
581
+ if (hasPassword) return { found: true, selector: 'form' };
582
+ }
583
+ return { found: false };
584
+ });
585
+
586
+ if (!loginForm.found) {
587
+ return { submitted: false, found: false, redirected: false, url: beforeUrl, storageChanged: false, cookiesChanged: false };
588
+ }
589
+
590
+ // Fill and submit form
591
+ const emailInput = await page.$('input[type="email"], input[name*="email"], input[name*="user"], input[name*="login"]');
592
+ const passwordInput = await page.$('input[type="password"]');
593
+
594
+ if (emailInput) {
595
+ await emailInput.fill(creds.email);
596
+ }
597
+ if (passwordInput) {
598
+ await passwordInput.fill(creds.password);
599
+ }
600
+
601
+ const submitButton = await page.$('form button[type="submit"], form input[type="submit"], form button');
602
+ if (!submitButton) {
603
+ return { submitted: false, found: true, redirected: false, url: beforeUrl, storageChanged: false, cookiesChanged: false };
604
+ }
334
605
 
335
- // Wait for navigation or settle
336
606
  try {
337
607
  await Promise.race([
338
- page.waitForNavigation({ waitUntil: 'networkidle', timeout: 5000 }).catch(() => {}),
339
- page.waitForTimeout(2000)
608
+ submitButton.click().catch(() => null),
609
+ page.waitForTimeout(CLICK_TIMEOUT_MS)
340
610
  ]);
341
- } catch {
342
- // Navigation may not happen, that's okay
611
+ await this.waitAfterAction(page, 600);
612
+ } catch {}
613
+
614
+ const afterUrl = page.url();
615
+ const afterStorageKeys = await page.evaluate(() => Object.keys(localStorage));
616
+ const afterCookies = await page.context().cookies();
617
+
618
+ const storageChanged = JSON.stringify(beforeStorageKeys) !== JSON.stringify(afterStorageKeys);
619
+ const cookiesChanged = beforeCookies.length !== afterCookies.length;
620
+ const redirected = beforeUrl !== afterUrl;
621
+
622
+ return {
623
+ submitted: true,
624
+ found: true,
625
+ redirected,
626
+ url: afterUrl,
627
+ storageChanged,
628
+ cookiesChanged
629
+ };
630
+ }
631
+
632
+ async performLogout(page) {
633
+ const beforeUrl = page.url();
634
+ const beforeStorageKeys = await page.evaluate(() => Object.keys(localStorage));
635
+ const beforeCookies = await page.context().cookies();
636
+
637
+ // Find logout button/link
638
+ const logoutElement = await page.evaluate(() => {
639
+ const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'));
640
+ for (const el of candidates) {
641
+ const text = (el.textContent || '').toLowerCase();
642
+ if (text.includes('logout') || text.includes('sign out') || text.includes('signout')) {
643
+ return true;
644
+ }
645
+ }
646
+ return false;
647
+ });
648
+
649
+ if (!logoutElement) {
650
+ return { found: false, clicked: false, url: beforeUrl, storageChanged: false, cookiesChanged: false };
343
651
  }
344
652
 
345
- await waitForSettle(page, { timeoutMs: 10000 });
653
+ let clicked = false;
654
+ const buttons = await page.$$('button, a, [role="button"]');
655
+ for (const btn of buttons) {
656
+ const text = await btn.textContent();
657
+ if (text && (text.toLowerCase().includes('logout') || text.toLowerCase().includes('sign out'))) {
658
+ try {
659
+ await btn.click();
660
+ clicked = true;
661
+ await this.waitAfterAction(page, 400);
662
+ break;
663
+ } catch {}
664
+ }
665
+ }
666
+
667
+ const afterUrl = page.url();
668
+ const afterStorageKeys = await page.evaluate(() => Object.keys(localStorage));
669
+ const afterCookies = await page.context().cookies();
670
+
671
+ const storageChanged = JSON.stringify(beforeStorageKeys) !== JSON.stringify(afterStorageKeys);
672
+ const cookiesChanged = beforeCookies.length !== afterCookies.length;
673
+ const redirected = beforeUrl !== afterUrl;
346
674
 
347
675
  return {
348
- success: true,
349
- filled: filled.filled,
350
- submitted: submitResult.submitted,
351
- method: submitResult.method
676
+ found: true,
677
+ clicked,
678
+ url: afterUrl,
679
+ redirected,
680
+ storageChanged,
681
+ cookiesChanged
352
682
  };
353
683
  }
354
- }
355
684
 
356
- /**
357
- * Get deterministic priority for interaction.
358
- * Lower number = higher priority
359
- */
360
- function getPriority(element) {
361
- const text = (element.text || element.href || '').toLowerCase();
362
-
363
- // Primary navigation
364
- if (text.includes('home') || text.includes('/')) return 0;
365
- if (text.includes('about')) return 1;
366
- if (text.includes('contact')) return 2;
367
- if (text.includes('service') || text.includes('product')) return 3;
368
- if (text.includes('blog') || text.includes('news')) return 4;
369
-
370
- // Footer/secondary
371
- if (text.includes('footer') || text.includes('copyright')) return 100;
372
- if (text.includes('privacy') || text.includes('terms')) return 101;
373
-
374
- // Default
375
- return 50;
685
+ async checkProtectedRoute(page, url) {
686
+ const beforeUrl = page.url();
687
+ try {
688
+ await page.goto(url, { waitUntil: 'load', timeout: CLICK_TIMEOUT_MS }).catch(() => null);
689
+ } catch {}
690
+
691
+ const afterUrl = page.url();
692
+ const blocked = beforeUrl !== afterUrl && (afterUrl.includes('/login') || afterUrl.includes('/signin'));
693
+ const content = await page.content();
694
+ const hasAccessDenied = content.includes('401') || content.includes('403') || content.includes('unauthorized') || content.includes('forbidden');
695
+
696
+ return {
697
+ url,
698
+ blocked: blocked || hasAccessDenied,
699
+ redirectedTo: afterUrl,
700
+ httpStatus: hasAccessDenied ? (content.includes('403') ? 403 : 401) : 200
701
+ };
702
+ }
703
+
704
+ async captureSessionState(page) {
705
+ try {
706
+ const localStorage = await page.evaluate(() => {
707
+ const result = {};
708
+ try {
709
+ for (let i = 0; i < window.localStorage.length; i++) {
710
+ const key = window.localStorage.key(i);
711
+ if (key) result[key] = window.localStorage.getItem(key);
712
+ }
713
+ } catch (e) {}
714
+ return result;
715
+ });
716
+
717
+ const sessionStorage = await page.evaluate(() => {
718
+ const result = {};
719
+ try {
720
+ for (let i = 0; i < window.sessionStorage.length; i++) {
721
+ const key = window.sessionStorage.key(i);
722
+ if (key) result[key] = window.sessionStorage.getItem(key);
723
+ }
724
+ } catch (e) {}
725
+ return result;
726
+ });
727
+
728
+ const cookies = await page.context().cookies();
729
+
730
+ return {
731
+ localStorage: localStorage || {},
732
+ sessionStorage: sessionStorage || {},
733
+ cookies: cookies.map(c => ({ name: c.name, domain: c.domain, path: c.path }))
734
+ };
735
+ } catch (error) {
736
+ return {
737
+ localStorage: {},
738
+ sessionStorage: {},
739
+ cookies: []
740
+ };
741
+ }
742
+ }
743
+
376
744
  }