@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,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')) {
71
- result.push({
72
- type: 'button',
73
- selector: generateSelector(el),
74
- text: el.textContent.trim().slice(0, 100),
75
- visible: true
76
- });
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')) {
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;
95
72
  result.push({
96
- type: 'button',
73
+ type,
97
74
  selector: generateSelector(el),
98
- text: el.textContent.trim().slice(0, 100),
75
+ href: el.getAttribute('href') || '',
76
+ text: (el.textContent || '').trim().slice(0, 100),
99
77
  visible: true
100
78
  });
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,645 @@ 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 = [];
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
+ }
174
130
 
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;
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
+ }
180
139
 
181
- const textA = (a.text || a.href || '').toLowerCase();
182
- const textB = (b.text || b.href || '').toLowerCase();
183
- return textA.localeCompare(textB);
184
- });
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
146
+ }
147
+ }
185
148
 
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);
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
+ // Ignore config errors
191
163
  }
192
164
 
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);
165
+ const waitForUiIdle = async () => {
166
+ const start = Date.now();
167
+ while (Date.now() - start < timeoutMs) {
168
+ const busy = await page.evaluate(() => {
169
+ const loading = document.querySelector('[aria-busy="true"], .loading, .spinner, [data-loading="true"]');
170
+ return Boolean(loading);
171
+ }).catch(() => false);
172
+ if (!busy) {
173
+ await page.waitForTimeout(120);
174
+ const stillBusy = await page.evaluate(() => {
175
+ const loading = document.querySelector('[aria-busy="true"], .loading, .spinner, [data-loading="true"]');
176
+ return Boolean(loading);
177
+ }).catch(() => false);
178
+ if (!stillBusy) return;
179
+ }
180
+ await page.waitForTimeout(120);
181
+ }
182
+ };
183
+
184
+ // Wait for network idle with longer timeout to catch slow requests
185
+ await Promise.race([
186
+ page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {}),
187
+ waitForUiIdle(),
188
+ waitForSettle(page, this.scanBudget)
189
+ ]);
190
+ }
191
+
192
+ async clickElement(page, locator) {
193
+ await this.scrollIntoView(page, locator);
194
+ await this.hover(page, locator);
195
+ await this.focus(page, locator);
196
+ await locator.click({ timeout: CLICK_TIMEOUT_MS });
197
+ await this.waitAfterAction(page);
198
+ return { clicked: true };
199
+ }
200
+
201
+ async fillFormFields(page, submitLocator) {
202
+ const submitHandle = await submitLocator.elementHandle();
203
+ if (!submitHandle) return { filled: [], submitted: false, reason: 'SUBMIT_NOT_FOUND' };
204
+
205
+ const result = await submitHandle.evaluate(
206
+ (submitEl, payload) => {
207
+ const { dummyValues, dangerous, safeTypes } = payload;
208
+ const form = submitEl.closest('form');
209
+ if (!form) return { filled: [], submitted: false, reason: 'FORM_NOT_FOUND' };
210
+
211
+ const combinedText = (form.textContent || '').toLowerCase();
212
+ if (dangerous.some((kw) => combinedText.includes(kw))) {
213
+ return { filled: [], submitted: false, reason: 'FORM_DANGEROUS' };
214
+ }
215
+
216
+ const inputs = Array.from(form.querySelectorAll('input, textarea')).filter((input) => {
217
+ const type = (input.getAttribute('type') || input.tagName || '').toLowerCase();
218
+ if (['submit', 'button', 'hidden', 'file'].includes(type)) return false;
219
+ if (!safeTypes.includes(type) && !(type === '' && input.tagName.toLowerCase() === 'input')) return false;
220
+ if (input.disabled || input.readOnly) return false;
221
+ const style = window.getComputedStyle(input);
222
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
223
+ const name = (input.name || input.id || '').toLowerCase();
224
+ const placeholder = (input.getAttribute('placeholder') || '').toLowerCase();
225
+ if (dangerous.some((kw) => name.includes(kw) || placeholder.includes(kw))) return false;
226
+ return true;
227
+ });
228
+
229
+ const filled = [];
230
+ for (const input of inputs) {
231
+ const type = (input.getAttribute('type') || input.tagName || '').toLowerCase();
232
+ const valueKey = type === '' || type === 'input' ? 'text' : type === 'textarea' ? 'textarea' : type;
233
+ const value = dummyValues[valueKey] || dummyValues.text;
234
+ input.focus();
235
+ input.value = value;
236
+ input.dispatchEvent(new Event('input', { bubbles: true }));
237
+ input.dispatchEvent(new Event('change', { bubbles: true }));
238
+ filled.push({ name: input.name || input.id || valueKey, value });
239
+ }
240
+
241
+ return {
242
+ filled,
243
+ submitted: false,
244
+ reason: inputs.length === 0 ? 'NO_SAFE_FIELDS' : null
245
+ };
246
+ },
247
+ {
248
+ dummyValues: DUMMY_VALUES,
249
+ dangerous: DANGEROUS_KEYWORDS,
250
+ safeTypes: SAFE_INPUT_TYPES
251
+ }
252
+ );
253
+
254
+ return result;
255
+ }
197
256
 
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]);
257
+ async submitForm(page, submitLocator) {
258
+ let attempts = 0;
259
+ while (attempts <= FORM_RETRY_LIMIT) {
260
+ attempts += 1;
261
+ try {
262
+ await this.clickElement(page, submitLocator);
263
+ return { submitted: true, attempts };
264
+ } catch (error) {
265
+ if (attempts > FORM_RETRY_LIMIT) {
266
+ return { submitted: false, attempts, error: error.message };
267
+ }
268
+ }
201
269
  }
270
+ return { submitted: false, attempts };
271
+ }
272
+
273
+ selectByBudget(discovered) {
274
+ const budget = this.interactionBudgetPerPage;
275
+ const sorted = [...discovered].sort((a, b) => (a.selector || '').localeCompare(b.selector || ''));
276
+
277
+ const links = sorted.filter(item => item.type === 'link');
278
+ const buttons = sorted.filter(item => item.type === 'button');
279
+ const forms = sorted.filter(item => item.type === 'form');
280
+ const others = sorted.filter(item => !['link', 'button', 'form'].includes(item.type));
281
+
282
+ const allocation = {
283
+ links: Math.floor(budget * 0.4),
284
+ buttons: Math.floor(budget * 0.4),
285
+ forms: Math.min(forms.length, Math.max(0, budget - Math.floor(budget * 0.4) - Math.floor(budget * 0.4)))
286
+ };
202
287
 
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]);
288
+ const selected = [];
289
+ const take = (list, count) => list.slice(0, Math.max(0, Math.min(count, list.length)));
290
+
291
+ selected.push(...take(links, allocation.links));
292
+ selected.push(...take(buttons, allocation.buttons));
293
+ selected.push(...take(forms, allocation.forms));
294
+
295
+ const remainingBudget = Math.max(0, budget - selected.length);
296
+ if (remainingBudget > 0) {
297
+ const alreadySelected = new Set(selected.map(item => item.selector));
298
+ const filler = [...links, ...buttons, ...forms, ...others].filter(item => !alreadySelected.has(item.selector));
299
+ selected.push(...take(filler, remainingBudget));
206
300
  }
207
301
 
208
302
  return {
209
303
  selected,
210
- skipped,
211
- discoveredCount: discovered.length,
212
304
  selectedCount: selected.length,
213
- skippedDueToBudgetCount: skipped.length,
214
- budgetUsed: selected.length,
215
- budgetAvailable: this.interactionBudgetPerPage
305
+ discoveredCount: discovered.length,
306
+ budgetAvailable: budget,
307
+ skippedDueToBudgetCount: Math.max(0, discovered.length - selected.length)
216
308
  };
217
309
  }
218
310
 
219
- /**
220
- * Fill and submit a form with realistic dummy data.
221
- * Respects safety rules: skips payment, checkout, delete, etc.
222
- */
223
311
  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;
312
+ const form = page.locator(formSelector).first();
313
+ if (await form.count() === 0) {
314
+ return { success: false, reason: 'FORM_NOT_FOUND', filled: [], submitted: false };
315
+ }
316
+
317
+ const submitLocator = form.locator('button[type="submit"], input[type="submit"]').first();
318
+ if (await submitLocator.count() === 0) {
319
+ return { success: false, reason: 'SUBMIT_NOT_FOUND', filled: [], submitted: false };
320
+ }
321
+
322
+ const fillResult = await this.fillFormFields(page, submitLocator);
323
+ const submitResult = await this.submitForm(page, submitLocator);
324
+ return {
325
+ success: submitResult.submitted,
326
+ reason: fillResult.reason || (submitResult.submitted ? null : 'SUBMIT_FAILED'),
327
+ filled: fillResult.filled || [],
328
+ submitted: submitResult.submitted,
329
+ attempts: submitResult.attempts
330
+ };
331
+ }
332
+
333
+ async performKeyboardNavigation(page, maxTabs = 12) {
334
+ const actions = [];
335
+ const focusOrder = [];
336
+
337
+ await page.focus('body').catch(() => {});
338
+
339
+ const focusableSelectors = await page.evaluate(() => {
340
+ const focusables = Array.from(document.querySelectorAll('a[href], button, input, select, textarea, [tabindex], [role="button"], [role="menuitem"], [contenteditable="true"]'))
341
+ .filter(el => {
342
+ const htmlEl = /** @type {HTMLElement} */ (el);
343
+ return !el.hasAttribute('disabled') && htmlEl.tabIndex >= 0 && htmlEl.offsetParent !== null;
344
+ });
345
+ const describe = (el) => {
346
+ if (!el) return 'body';
347
+ if (el.id) return `#${el.id}`;
348
+ if (el.getAttribute('data-testid')) return `[data-testid="${el.getAttribute('data-testid')}"]`;
349
+ const tag = el.tagName.toLowerCase();
350
+ const cls = (el.className || '').toString().trim().split(/\s+/).filter(Boolean).slice(0, 3).join('.');
351
+ return cls ? `${tag}.${cls}` : tag;
352
+ };
353
+ return focusables.map(describe).slice(0, 50);
354
+ });
355
+
356
+ const tabLimit = Math.min(maxTabs, focusableSelectors.length || maxTabs);
357
+
358
+ for (let i = 0; i < tabLimit; i++) {
359
+ await page.keyboard.press('Tab');
360
+ await page.waitForTimeout(FOCUS_PAUSE_MS);
361
+ const active = await page.evaluate(() => {
362
+ const el = document.activeElement;
363
+ if (!el) return { selector: 'body', tag: 'body', role: '', type: '', modal: false };
364
+ const tag = el.tagName.toLowerCase();
365
+ const role = el.getAttribute('role') || '';
366
+ const type = el.getAttribute('type') || '';
367
+ const id = el.id ? `#${el.id}` : '';
368
+ const testId = el.getAttribute('data-testid') ? `[data-testid="${el.getAttribute('data-testid')}"]` : '';
369
+ const cls = (el.className || '').toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join('.');
370
+ const selector = id || testId || (cls ? `${tag}.${cls}` : tag);
371
+ const modal = Boolean(el.closest('[role="dialog"], [aria-modal="true"], .modal'));
372
+ return { selector, tag, role, type, modal };
373
+ });
374
+
375
+ focusOrder.push(active.selector);
376
+ actions.push({ action: 'tab', target: active.selector });
377
+ const isActionable = ['a', 'button'].includes(active.tag) || active.role === 'button' || active.role === 'link' || ['submit', 'button'].includes((active.type || '').toLowerCase());
378
+ if (isActionable) {
379
+ await page.keyboard.press('Enter');
380
+ actions.push({ action: 'enter', target: active.selector });
381
+ await this.waitAfterAction(page, 150);
238
382
  }
239
383
 
240
- return false;
241
- }, formSelector);
384
+ if (active.modal) {
385
+ await page.keyboard.press('Escape');
386
+ actions.push({ action: 'escape', target: active.selector });
387
+ await this.waitAfterAction(page, 120);
388
+ }
389
+ }
390
+
391
+ return {
392
+ focusOrder,
393
+ actions,
394
+ attemptedTabs: tabLimit
395
+ };
396
+ }
397
+
398
+ async hoverAndObserve(page, locator) {
399
+ await this.scrollIntoView(page, locator);
400
+ await this.hover(page, locator);
401
+ await this.waitAfterAction(page, 200);
402
+ const hoveredSelector = await locator.evaluate(el => {
403
+ const id = el.id ? `#${el.id}` : '';
404
+ const testId = el.getAttribute('data-testid') ? `[data-testid="${el.getAttribute('data-testid')}"]` : '';
405
+ const tag = el.tagName.toLowerCase();
406
+ const cls = (el.className || '').toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join('.');
407
+ return id || testId || (cls ? `${tag}.${cls}` : tag);
408
+ }).catch(() => locator.selector());
409
+
410
+ const revealState = await page.evaluate(() => {
411
+ const revealed = document.querySelector('[data-hovered="true"], [data-menu-open="true"], [data-hover-visible="true"]');
412
+ return Boolean(revealed);
413
+ }).catch(() => false);
414
+
415
+ return {
416
+ hovered: true,
417
+ selector: hoveredSelector,
418
+ revealed: revealState
419
+ };
420
+ }
421
+
422
+ async uploadFile(page, locator, filePath = null) {
423
+ const uploadPath = this.ensureUploadFixture(filePath);
424
+ await this.scrollIntoView(page, locator);
425
+ try {
426
+ await locator.setInputFiles(uploadPath);
427
+ } catch {
428
+ return { attached: false, filePath: uploadPath, submitted: false, attempts: 0, submitSelector: null };
429
+ }
242
430
 
243
- if (isDangerous) {
244
- return { success: false, reason: 'DANGEROUS_FORM_SKIPPED' };
431
+ // Attempt to submit via nearest form if present
432
+ const submitSelector = await locator.evaluate((inputEl) => {
433
+ const form = inputEl.closest('form');
434
+ if (!form) return null;
435
+ const submit = form.querySelector('button[type="submit"], input[type="submit"], button');
436
+ if (!submit) return null;
437
+ if (submit.id) return `#${submit.id}`;
438
+ if (submit.getAttribute('data-testid')) return `[data-testid="${submit.getAttribute('data-testid')}"]`;
439
+ return 'form button[type="submit"], form input[type="submit"], form button';
440
+ }).catch(() => null);
441
+
442
+ let submitResult = { submitted: false, attempts: 0 };
443
+ if (submitSelector) {
444
+ const submitLocator = page.locator(submitSelector).first();
445
+ if (await submitLocator.count() > 0) {
446
+ submitResult = await this.submitForm(page, submitLocator);
447
+ }
245
448
  }
246
449
 
247
- // Fill required fields
248
- const filled = await page.evaluate((sel) => {
249
- const form = document.querySelector(sel);
250
- if (!form) return { filled: [], errors: [] };
450
+ await this.waitAfterAction(page, 200);
251
451
 
252
- const filled = [];
253
- const errors = [];
452
+ return {
453
+ attached: true,
454
+ filePath: uploadPath,
455
+ submitted: submitResult.submitted,
456
+ attempts: submitResult.attempts,
457
+ submitSelector: submitSelector || null
458
+ };
459
+ }
254
460
 
255
- // Find all input elements
256
- const inputs = form.querySelectorAll('input, textarea, select');
461
+ ensureUploadFixture(filePath) {
462
+ if (filePath) {
463
+ return filePath;
464
+ }
465
+ const tmpDir = mkdtempSync(join(tmpdir(), 'verax-upload-'));
466
+ const resolved = join(tmpDir, DEFAULT_UPLOAD_NAME);
467
+ writeFileSync(resolved, DEFAULT_UPLOAD_CONTENT, 'utf-8');
468
+ return resolved;
469
+ }
257
470
 
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;
471
+ async navigateWithKeyboard(page, targetLocator) {
472
+ try {
473
+ await targetLocator.focus({ timeout: CLICK_TIMEOUT_MS });
474
+ await page.waitForTimeout(FOCUS_PAUSE_MS);
475
+ await page.keyboard.press('Enter');
476
+ await this.waitAfterAction(page);
477
+ return { navigated: true, method: 'keyboard' };
478
+ } catch (error) {
479
+ return { navigated: false, method: 'keyboard', error: error.message };
480
+ }
481
+ }
261
482
 
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 });
483
+ async tabThroughFocusableElements(page) {
484
+ const focusableElements = await page.evaluate(() => {
485
+ const selectors = [
486
+ 'a[href]',
487
+ 'button:not([disabled])',
488
+ 'input:not([disabled]):not([type="hidden"])',
489
+ 'textarea:not([disabled])',
490
+ 'select:not([disabled])',
491
+ '[tabindex]:not([tabindex="-1"])',
492
+ '[contenteditable="true"]'
493
+ ];
494
+
495
+ const elements = [];
496
+ for (const selector of selectors) {
497
+ document.querySelectorAll(selector).forEach(el => {
498
+ const rect = el.getBoundingClientRect();
499
+ const style = window.getComputedStyle(el);
500
+ if (rect.width > 0 && rect.height > 0 &&
501
+ style.visibility !== 'hidden' &&
502
+ style.display !== 'none' &&
503
+ !elements.includes(el)) {
504
+ elements.push(el);
307
505
  }
308
- } catch (err) {
309
- errors.push({ name: input.name || input.id, error: err.message });
310
- }
506
+ });
507
+ }
508
+
509
+ return elements.map(el => {
510
+ const rect = el.getBoundingClientRect();
511
+ return {
512
+ tagName: el.tagName.toLowerCase(),
513
+ type: el.type || '',
514
+ role: el.getAttribute('role') || '',
515
+ id: el.id || '',
516
+ text: (el.textContent || el.value || '').trim().slice(0, 50),
517
+ boundingY: rect.y
518
+ };
519
+ });
520
+ });
521
+
522
+ return focusableElements;
523
+ }
524
+
525
+ async executeLogin(page, submitLocator) {
526
+ const creds = { email: 'verax@example.com', password: 'VeraxPass123!' };
527
+ const beforeState = await this.captureSessionState(page);
528
+ const beforeUrl = page.url();
529
+
530
+ try {
531
+ // Fill login form fields
532
+ const formHandle = await submitLocator.evaluateHandle(el => el.closest('form'));
533
+ if (!formHandle.asElement()) {
534
+ return { submitted: false, found: false, redirected: false, url: beforeUrl, storageChanged: false, cookiesChanged: false };
311
535
  }
312
536
 
313
- return { filled, errors };
314
- }, formSelector);
537
+ // Find and fill email/username input
538
+ 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();
539
+ if (await emailInput.count() > 0) {
540
+ await emailInput.fill(creds.email);
541
+ }
315
542
 
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' };
543
+ // Find and fill password input
544
+ const passwordInput = await page.locator('form input[type="password"]').first();
545
+ if (await passwordInput.count() > 0) {
546
+ await passwordInput.fill(creds.password);
547
+ }
320
548
 
321
- // Find submit button
322
- let submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
549
+ // Submit form
550
+ await this.scrollIntoView(page, submitLocator);
551
+ await this.hover(page, submitLocator);
552
+ await submitLocator.click({ timeout: CLICK_TIMEOUT_MS });
553
+ await this.waitAfterAction(page, 600);
554
+
555
+ const afterState = await this.captureSessionState(page);
556
+ const afterUrl = page.url();
557
+
558
+ const storageChanged = JSON.stringify(Object.keys(beforeState.localStorage)) !== JSON.stringify(Object.keys(afterState.localStorage));
559
+ const cookiesChanged = beforeState.cookies.length !== afterState.cookies.length;
560
+ const redirected = beforeUrl !== afterUrl;
561
+
562
+ return {
563
+ submitted: true,
564
+ found: true,
565
+ redirected,
566
+ url: afterUrl,
567
+ storageChanged,
568
+ cookiesChanged
569
+ };
570
+ } catch (error) {
571
+ return { submitted: false, found: true, redirected: false, url: beforeUrl, storageChanged: false, cookiesChanged: false, error: error.message };
572
+ }
573
+ }
323
574
 
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' };
575
+ async performLogin(page, credentials = null) {
576
+ const creds = credentials || { email: 'verax@example.com', password: 'VeraxPass123!' };
577
+ const beforeUrl = page.url();
578
+ const beforeStorageKeys = await page.evaluate(() => Object.keys(localStorage));
579
+ const beforeCookies = await page.context().cookies();
580
+
581
+ // Find login form
582
+ const loginForm = await page.evaluate(() => {
583
+ const forms = Array.from(document.querySelectorAll('form'));
584
+ for (const form of forms) {
585
+ const hasPassword = form.querySelector('input[type="password"]');
586
+ if (hasPassword) return { found: true, selector: 'form' };
328
587
  }
588
+ return { found: false };
589
+ });
590
+
591
+ if (!loginForm.found) {
592
+ return { submitted: false, found: false, redirected: false, url: beforeUrl, storageChanged: false, cookiesChanged: false };
593
+ }
594
+
595
+ // Fill and submit form
596
+ const emailInput = await page.$('input[type="email"], input[name*="email"], input[name*="user"], input[name*="login"]');
597
+ const passwordInput = await page.$('input[type="password"]');
598
+
599
+ if (emailInput) {
600
+ await emailInput.fill(creds.email);
601
+ }
602
+ if (passwordInput) {
603
+ await passwordInput.fill(creds.password);
604
+ }
329
605
 
330
- // Click submit button
331
- submitBtn.click();
332
- return { submitted: true, method: 'button_click' };
333
- }, formSelector);
606
+ const submitButton = await page.$('form button[type="submit"], form input[type="submit"], form button');
607
+ if (!submitButton) {
608
+ return { submitted: false, found: true, redirected: false, url: beforeUrl, storageChanged: false, cookiesChanged: false };
609
+ }
334
610
 
335
- // Wait for navigation or settle
336
611
  try {
337
612
  await Promise.race([
338
- page.waitForNavigation({ waitUntil: 'networkidle', timeout: 5000 }).catch(() => {}),
339
- page.waitForTimeout(2000)
613
+ submitButton.click().catch(() => null),
614
+ page.waitForTimeout(CLICK_TIMEOUT_MS)
340
615
  ]);
616
+ await this.waitAfterAction(page, 600);
341
617
  } catch {
342
- // Navigation may not happen, that's okay
618
+ // Ignore form submission errors
343
619
  }
344
620
 
345
- await waitForSettle(page, { timeoutMs: 10000 });
621
+ const afterUrl = page.url();
622
+ const afterStorageKeys = await page.evaluate(() => Object.keys(localStorage));
623
+ const afterCookies = await page.context().cookies();
624
+
625
+ const storageChanged = JSON.stringify(beforeStorageKeys) !== JSON.stringify(afterStorageKeys);
626
+ const cookiesChanged = beforeCookies.length !== afterCookies.length;
627
+ const redirected = beforeUrl !== afterUrl;
346
628
 
347
629
  return {
348
- success: true,
349
- filled: filled.filled,
350
- submitted: submitResult.submitted,
351
- method: submitResult.method
630
+ submitted: true,
631
+ found: true,
632
+ redirected,
633
+ url: afterUrl,
634
+ storageChanged,
635
+ cookiesChanged
352
636
  };
353
637
  }
354
- }
355
638
 
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;
639
+ async performLogout(page) {
640
+ const beforeUrl = page.url();
641
+ const beforeStorageKeys = await page.evaluate(() => Object.keys(localStorage));
642
+ const beforeCookies = await page.context().cookies();
643
+
644
+ // Find logout button/link
645
+ const logoutElement = await page.evaluate(() => {
646
+ const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'));
647
+ for (const el of candidates) {
648
+ const text = (el.textContent || '').toLowerCase();
649
+ if (text.includes('logout') || text.includes('sign out') || text.includes('signout')) {
650
+ return true;
651
+ }
652
+ }
653
+ return false;
654
+ });
655
+
656
+ if (!logoutElement) {
657
+ return { found: false, clicked: false, url: beforeUrl, storageChanged: false, cookiesChanged: false };
658
+ }
659
+
660
+ let clicked = false;
661
+ const buttons = await page.$$('button, a, [role="button"]');
662
+ for (const btn of buttons) {
663
+ const text = await btn.textContent();
664
+ if (text && (text.toLowerCase().includes('logout') || text.toLowerCase().includes('sign out'))) {
665
+ try {
666
+ await btn.click();
667
+ clicked = true;
668
+ await this.waitAfterAction(page, 400);
669
+ break;
670
+ } catch {
671
+ // Ignore interaction errors
672
+ }
673
+ }
674
+ }
675
+
676
+ const afterUrl = page.url();
677
+ const afterStorageKeys = await page.evaluate(() => Object.keys(localStorage));
678
+ const afterCookies = await page.context().cookies();
679
+
680
+ const storageChanged = JSON.stringify(beforeStorageKeys) !== JSON.stringify(afterStorageKeys);
681
+ const cookiesChanged = beforeCookies.length !== afterCookies.length;
682
+ const redirected = beforeUrl !== afterUrl;
683
+
684
+ return {
685
+ found: true,
686
+ clicked,
687
+ url: afterUrl,
688
+ redirected,
689
+ storageChanged,
690
+ cookiesChanged
691
+ };
692
+ }
693
+
694
+ async checkProtectedRoute(page, url) {
695
+ const beforeUrl = page.url();
696
+ try {
697
+ await page.goto(url, { waitUntil: 'load', timeout: CLICK_TIMEOUT_MS }).catch(() => null);
698
+ } catch {
699
+ // Ignore navigation errors
700
+ }
701
+
702
+ const afterUrl = page.url();
703
+ const redirectedToLogin = beforeUrl !== afterUrl && (afterUrl.includes('/login') || afterUrl.includes('/signin'));
704
+ const content = await page.content();
705
+ const hasAccessDenied = content.includes('401') || content.includes('403') || content.includes('unauthorized') || content.includes('forbidden');
706
+ const isProtected = redirectedToLogin || hasAccessDenied;
707
+
708
+ return {
709
+ url,
710
+ beforeUrl,
711
+ afterUrl,
712
+ isProtected,
713
+ redirectedToLogin,
714
+ hasAccessDenied,
715
+ httpStatus: hasAccessDenied ? (content.includes('403') ? 403 : 401) : 200
716
+ };
717
+ }
718
+
719
+ async captureSessionState(page) {
720
+ try {
721
+ const localStorage = await page.evaluate(() => {
722
+ const result = {};
723
+ try {
724
+ for (let i = 0; i < window.localStorage.length; i++) {
725
+ const key = window.localStorage.key(i);
726
+ if (key) result[key] = window.localStorage.getItem(key);
727
+ }
728
+ } catch {
729
+ // Ignore localStorage access errors
730
+ }
731
+ return result;
732
+ });
733
+
734
+ const sessionStorage = await page.evaluate(() => {
735
+ const result = {};
736
+ try {
737
+ for (let i = 0; i < window.sessionStorage.length; i++) {
738
+ const key = window.sessionStorage.key(i);
739
+ if (key) result[key] = window.sessionStorage.getItem(key);
740
+ }
741
+ } catch {
742
+ // Ignore sessionStorage access errors
743
+ }
744
+ return result;
745
+ });
746
+
747
+ const cookies = await page.context().cookies();
748
+
749
+ return {
750
+ localStorage: localStorage || {},
751
+ sessionStorage: sessionStorage || {},
752
+ cookies: cookies.map(c => ({ name: c.name, domain: c.domain, path: c.path }))
753
+ };
754
+ } catch (error) {
755
+ return {
756
+ localStorage: {},
757
+ sessionStorage: {},
758
+ cookies: []
759
+ };
760
+ }
761
+ }
762
+
376
763
  }