@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.
- package/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +14 -36
- package/src/cli/commands/default.js +523 -0
- package/src/cli/commands/doctor.js +165 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +402 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +296 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +34 -0
- package/src/cli/util/expectation-extractor.js +378 -0
- package/src/cli/util/findings-writer.js +31 -0
- package/src/cli/util/idgen.js +87 -0
- package/src/cli/util/learn-writer.js +39 -0
- package/src/cli/util/observation-engine.js +366 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +29 -0
- package/src/cli/util/project-discovery.js +277 -0
- package/src/cli/util/project-writer.js +26 -0
- package/src/cli/util/redact.js +128 -0
- package/src/cli/util/run-id.js +30 -0
- package/src/cli/util/summary-writer.js +32 -0
- package/src/verax/cli/ci-summary.js +35 -0
- package/src/verax/cli/context-explanation.js +89 -0
- package/src/verax/cli/doctor.js +277 -0
- package/src/verax/cli/error-normalizer.js +154 -0
- package/src/verax/cli/explain-output.js +105 -0
- package/src/verax/cli/finding-explainer.js +130 -0
- package/src/verax/cli/init.js +237 -0
- package/src/verax/cli/run-overview.js +163 -0
- package/src/verax/cli/url-safety.js +101 -0
- package/src/verax/cli/wizard.js +98 -0
- package/src/verax/cli/zero-findings-explainer.js +57 -0
- package/src/verax/cli/zero-interaction-explainer.js +127 -0
- package/src/verax/core/action-classifier.js +86 -0
- package/src/verax/core/budget-engine.js +218 -0
- package/src/verax/core/canonical-outcomes.js +157 -0
- package/src/verax/core/decision-snapshot.js +335 -0
- package/src/verax/core/determinism-model.js +403 -0
- package/src/verax/core/incremental-store.js +237 -0
- package/src/verax/core/invariants.js +356 -0
- package/src/verax/core/promise-model.js +230 -0
- package/src/verax/core/replay-validator.js +350 -0
- package/src/verax/core/replay.js +222 -0
- package/src/verax/core/run-id.js +175 -0
- package/src/verax/core/run-manifest.js +99 -0
- package/src/verax/core/silence-impact.js +369 -0
- package/src/verax/core/silence-model.js +521 -0
- package/src/verax/detect/comparison.js +2 -34
- package/src/verax/detect/confidence-engine.js +764 -329
- package/src/verax/detect/detection-engine.js +293 -0
- package/src/verax/detect/evidence-index.js +177 -0
- package/src/verax/detect/expectation-model.js +194 -172
- package/src/verax/detect/explanation-helpers.js +187 -0
- package/src/verax/detect/finding-detector.js +450 -0
- package/src/verax/detect/findings-writer.js +44 -8
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +172 -286
- package/src/verax/detect/interactive-findings.js +613 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/verdict-engine.js +563 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/index.js +90 -14
- package/src/verax/intel/effect-detector.js +368 -0
- package/src/verax/intel/handler-mapper.js +249 -0
- package/src/verax/intel/index.js +281 -0
- package/src/verax/intel/route-extractor.js +280 -0
- package/src/verax/intel/ts-program.js +256 -0
- package/src/verax/intel/vue-navigation-extractor.js +579 -0
- package/src/verax/intel/vue-router-extractor.js +323 -0
- package/src/verax/learn/action-contract-extractor.js +335 -101
- package/src/verax/learn/ast-contract-extractor.js +95 -5
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/manifest-writer.js +97 -47
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +27 -96
- package/src/verax/learn/state-extractor.js +212 -0
- package/src/verax/learn/static-extractor-navigation.js +114 -0
- package/src/verax/learn/static-extractor-validation.js +88 -0
- package/src/verax/learn/static-extractor.js +112 -4
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +10 -5
- package/src/verax/observe/console-sensor.js +1 -17
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +512 -0
- package/src/verax/observe/flow-matcher.js +143 -0
- package/src/verax/observe/focus-sensor.js +196 -0
- package/src/verax/observe/human-driver.js +643 -275
- package/src/verax/observe/index.js +908 -27
- package/src/verax/observe/index.js.backup +1 -0
- package/src/verax/observe/interaction-discovery.js +365 -14
- package/src/verax/observe/interaction-runner.js +563 -198
- package/src/verax/observe/loading-sensor.js +139 -0
- package/src/verax/observe/navigation-sensor.js +255 -0
- package/src/verax/observe/network-sensor.js +55 -7
- package/src/verax/observe/observed-expectation-deriver.js +186 -0
- package/src/verax/observe/observed-expectation.js +305 -0
- package/src/verax/observe/page-frontier.js +234 -0
- package/src/verax/observe/settle.js +37 -17
- package/src/verax/observe/state-sensor.js +389 -0
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +61 -20
- package/src/verax/observe/ui-signal-sensor.js +136 -17
- package/src/verax/scan-summary-writer.js +77 -15
- package/src/verax/shared/artifact-manager.js +110 -8
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +170 -0
- package/src/verax/shared/dynamic-route-utils.js +218 -0
- package/src/verax/shared/expectation-coverage.js +44 -0
- package/src/verax/shared/expectation-prover.js +81 -0
- package/src/verax/shared/expectation-tracker.js +201 -0
- package/src/verax/shared/expectations-writer.js +60 -0
- package/src/verax/shared/first-run.js +44 -0
- package/src/verax/shared/progress-reporter.js +171 -0
- package/src/verax/shared/retry-policy.js +14 -1
- package/src/verax/shared/root-artifacts.js +49 -0
- package/src/verax/shared/scan-budget.js +86 -0
- package/src/verax/shared/url-normalizer.js +162 -0
- package/src/verax/shared/zip-artifacts.js +65 -0
- package/src/verax/validate/context-validator.js +244 -0
- package/src/verax/validate/context-validator.js.bak +0 -0
|
@@ -1,162 +1,117 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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 ||
|
|
11
|
-
this.
|
|
12
|
-
this.
|
|
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();
|
|
42
|
+
const discovered = new Map();
|
|
21
43
|
|
|
22
|
-
|
|
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
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
73
|
+
type,
|
|
73
74
|
selector: generateSelector(el),
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
136
|
-
.split(' ')
|
|
100
|
+
const cls = Array.from(current.classList || [])
|
|
137
101
|
.filter((c) => c && !c.startsWith('__'))
|
|
138
102
|
.join('.');
|
|
139
|
-
if (
|
|
103
|
+
if (cls) selector += `.${cls}`;
|
|
140
104
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
//
|
|
248
|
-
const
|
|
249
|
-
const form =
|
|
250
|
-
if (!form) return
|
|
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
|
-
|
|
253
|
-
const errors = [];
|
|
445
|
+
await this.waitAfterAction(page, 200);
|
|
254
446
|
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
if (!
|
|
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
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
339
|
-
page.waitForTimeout(
|
|
608
|
+
submitButton.click().catch(() => null),
|
|
609
|
+
page.waitForTimeout(CLICK_TIMEOUT_MS)
|
|
340
610
|
]);
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
676
|
+
found: true,
|
|
677
|
+
clicked,
|
|
678
|
+
url: afterUrl,
|
|
679
|
+
redirected,
|
|
680
|
+
storageChanged,
|
|
681
|
+
cookiesChanged
|
|
352
682
|
};
|
|
353
683
|
}
|
|
354
|
-
}
|
|
355
684
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
}
|