@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.
- package/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +24 -36
- package/src/cli/commands/default.js +681 -0
- package/src/cli/commands/doctor.js +197 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +586 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +297 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +110 -0
- package/src/cli/util/expectation-extractor.js +388 -0
- package/src/cli/util/findings-writer.js +32 -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 +412 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +30 -0
- package/src/cli/util/project-discovery.js +297 -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/runtime-budget.js +147 -0
- package/src/cli/util/summary-writer.js +43 -0
- package/src/types/global.d.ts +28 -0
- package/src/types/ts-ast.d.ts +24 -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 +111 -0
- package/src/verax/cli/wizard.js +109 -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 +432 -0
- package/src/verax/core/incremental-store.js +245 -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 +523 -0
- package/src/verax/detect/comparison.js +7 -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 +127 -0
- package/src/verax/detect/expectation-model.js +241 -168
- 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 +41 -12
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +200 -288
- package/src/verax/detect/interactive-findings.js +612 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/skip-classifier.js +4 -4
- package/src/verax/detect/verdict-engine.js +561 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/flow/flow-engine.js +3 -2
- package/src/verax/flow/flow-spec.js +1 -2
- package/src/verax/index.js +103 -15
- 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 +642 -0
- package/src/verax/intel/vue-router-extractor.js +325 -0
- package/src/verax/learn/action-contract-extractor.js +338 -104
- package/src/verax/learn/ast-contract-extractor.js +148 -6
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/index.js +36 -2
- package/src/verax/learn/manifest-writer.js +122 -58
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +28 -97
- package/src/verax/learn/route-validator.js +8 -7
- 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 +119 -10
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/learn/ts-contract-resolver.js +14 -12
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +30 -6
- package/src/verax/observe/console-sensor.js +2 -18
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +513 -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 +660 -273
- package/src/verax/observe/index.js +910 -26
- package/src/verax/observe/interaction-discovery.js +378 -15
- package/src/verax/observe/interaction-runner.js +562 -197
- package/src/verax/observe/loading-sensor.js +145 -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 +38 -17
- package/src/verax/observe/state-sensor.js +393 -0
- package/src/verax/observe/state-ui-sensor.js +7 -1
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +73 -21
- package/src/verax/observe/ui-signal-sensor.js +143 -17
- package/src/verax/scan-summary-writer.js +80 -15
- package/src/verax/shared/artifact-manager.js +111 -9
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/caching.js +1 -1
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +169 -0
- package/src/verax/shared/dynamic-route-utils.js +224 -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 +9 -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 +66 -0
- package/src/verax/validate/context-validator.js +244 -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')) {
|
|
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
|
|
73
|
+
type,
|
|
97
74
|
selector: generateSelector(el),
|
|
98
|
-
|
|
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
|
|
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,645 @@ export class HumanBehaviorDriver {
|
|
|
164
119
|
}
|
|
165
120
|
}
|
|
166
121
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
}
|
|
309
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
return { submitted: true,
|
|
333
|
-
}
|
|
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
|
-
|
|
339
|
-
page.waitForTimeout(
|
|
613
|
+
submitButton.click().catch(() => null),
|
|
614
|
+
page.waitForTimeout(CLICK_TIMEOUT_MS)
|
|
340
615
|
]);
|
|
616
|
+
await this.waitAfterAction(page, 600);
|
|
341
617
|
} catch {
|
|
342
|
-
//
|
|
618
|
+
// Ignore form submission errors
|
|
343
619
|
}
|
|
344
620
|
|
|
345
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
630
|
+
submitted: true,
|
|
631
|
+
found: true,
|
|
632
|
+
redirected,
|
|
633
|
+
url: afterUrl,
|
|
634
|
+
storageChanged,
|
|
635
|
+
cookiesChanged
|
|
352
636
|
};
|
|
353
637
|
}
|
|
354
|
-
}
|
|
355
638
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
}
|