@veraxhq/verax 0.1.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/LICENSE +21 -0
- package/README.md +237 -0
- package/bin/verax.js +452 -0
- package/package.json +57 -0
- package/src/verax/detect/comparison.js +69 -0
- package/src/verax/detect/confidence-engine.js +498 -0
- package/src/verax/detect/evidence-validator.js +33 -0
- package/src/verax/detect/expectation-model.js +204 -0
- package/src/verax/detect/findings-writer.js +31 -0
- package/src/verax/detect/index.js +397 -0
- package/src/verax/detect/skip-classifier.js +202 -0
- package/src/verax/flow/flow-engine.js +265 -0
- package/src/verax/flow/flow-spec.js +145 -0
- package/src/verax/flow/redaction.js +74 -0
- package/src/verax/index.js +97 -0
- package/src/verax/learn/action-contract-extractor.js +281 -0
- package/src/verax/learn/ast-contract-extractor.js +255 -0
- package/src/verax/learn/index.js +18 -0
- package/src/verax/learn/manifest-writer.js +97 -0
- package/src/verax/learn/project-detector.js +87 -0
- package/src/verax/learn/react-router-extractor.js +73 -0
- package/src/verax/learn/route-extractor.js +122 -0
- package/src/verax/learn/route-validator.js +215 -0
- package/src/verax/learn/source-instrumenter.js +214 -0
- package/src/verax/learn/static-extractor.js +222 -0
- package/src/verax/learn/truth-assessor.js +96 -0
- package/src/verax/learn/ts-contract-resolver.js +395 -0
- package/src/verax/observe/browser.js +22 -0
- package/src/verax/observe/console-sensor.js +166 -0
- package/src/verax/observe/dom-signature.js +23 -0
- package/src/verax/observe/domain-boundary.js +38 -0
- package/src/verax/observe/evidence-capture.js +5 -0
- package/src/verax/observe/human-driver.js +376 -0
- package/src/verax/observe/index.js +67 -0
- package/src/verax/observe/interaction-discovery.js +269 -0
- package/src/verax/observe/interaction-runner.js +410 -0
- package/src/verax/observe/network-sensor.js +173 -0
- package/src/verax/observe/selector-generator.js +74 -0
- package/src/verax/observe/settle.js +155 -0
- package/src/verax/observe/state-ui-sensor.js +200 -0
- package/src/verax/observe/traces-writer.js +82 -0
- package/src/verax/observe/ui-signal-sensor.js +197 -0
- package/src/verax/resolve-workspace-root.js +173 -0
- package/src/verax/scan-summary-writer.js +41 -0
- package/src/verax/shared/artifact-manager.js +139 -0
- package/src/verax/shared/caching.js +104 -0
- package/src/verax/shared/expectation-proof.js +4 -0
- package/src/verax/shared/redaction.js +227 -0
- package/src/verax/shared/retry-policy.js +89 -0
- package/src/verax/shared/timing-metrics.js +44 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAVE 2: Human Behavior Driver
|
|
3
|
+
* Realistic interaction execution with scrolling, form filling, and budgeting
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { waitForSettle } from './settle.js';
|
|
7
|
+
|
|
8
|
+
export class HumanBehaviorDriver {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.maxScrollSteps = options.maxScrollSteps || 5;
|
|
11
|
+
this.interactionBudgetPerPage = options.interactionBudgetPerPage || 50;
|
|
12
|
+
this.scrollPauseMs = options.scrollPauseMs || 500;
|
|
13
|
+
}
|
|
14
|
+
|
|
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
|
+
async discoverInteractionsWithScroll(page) {
|
|
20
|
+
const discovered = new Map(); // key: selector, value: element data
|
|
21
|
+
|
|
22
|
+
// Initial discovery before any scrolling
|
|
23
|
+
await this.discoverElements(page, discovered);
|
|
24
|
+
|
|
25
|
+
// Progressive scrolling discovery
|
|
26
|
+
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
|
27
|
+
const maxScrollDistance = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
28
|
+
|
|
29
|
+
for (let step = 0; step < this.maxScrollSteps; step++) {
|
|
30
|
+
// Calculate scroll position (0%, 25%, 50%, 75%, 100%)
|
|
31
|
+
const scrollPercent = (step + 1) / this.maxScrollSteps;
|
|
32
|
+
const targetScroll = Math.min(maxScrollDistance, scrollPercent * maxScrollDistance);
|
|
33
|
+
|
|
34
|
+
await page.evaluate((scroll) => window.scrollTo(0, scroll), targetScroll);
|
|
35
|
+
await page.waitForTimeout(this.scrollPauseMs);
|
|
36
|
+
|
|
37
|
+
// Rediscover after scroll
|
|
38
|
+
await this.discoverElements(page, discovered);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Return to top
|
|
42
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
43
|
+
|
|
44
|
+
return Array.from(discovered.values());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Discover interactive elements and merge into stable map.
|
|
49
|
+
* Handles: links, buttons, forms, role=button elements
|
|
50
|
+
*/
|
|
51
|
+
async discoverElements(page, discovered) {
|
|
52
|
+
const elements = await page.evaluate(() => {
|
|
53
|
+
const result = [];
|
|
54
|
+
|
|
55
|
+
// Links
|
|
56
|
+
document.querySelectorAll('a[href]').forEach((el) => {
|
|
57
|
+
if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
|
|
58
|
+
result.push({
|
|
59
|
+
type: 'link',
|
|
60
|
+
selector: generateSelector(el),
|
|
61
|
+
href: el.getAttribute('href'),
|
|
62
|
+
text: el.textContent.trim().slice(0, 100),
|
|
63
|
+
visible: true
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Buttons
|
|
69
|
+
document.querySelectorAll('button').forEach((el) => {
|
|
70
|
+
if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
|
|
71
|
+
result.push({
|
|
72
|
+
type: 'button',
|
|
73
|
+
selector: generateSelector(el),
|
|
74
|
+
text: el.textContent.trim().slice(0, 100),
|
|
75
|
+
visible: true
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Form inputs
|
|
81
|
+
document.querySelectorAll('input[type="submit"], input[type="button"]').forEach((el) => {
|
|
82
|
+
if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
|
|
83
|
+
result.push({
|
|
84
|
+
type: 'button',
|
|
85
|
+
selector: generateSelector(el),
|
|
86
|
+
text: el.value || el.getAttribute('title') || 'Submit',
|
|
87
|
+
visible: true
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Role=button elements
|
|
93
|
+
document.querySelectorAll('[role="button"]').forEach((el) => {
|
|
94
|
+
if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
|
|
95
|
+
result.push({
|
|
96
|
+
type: 'button',
|
|
97
|
+
selector: generateSelector(el),
|
|
98
|
+
text: el.textContent.trim().slice(0, 100),
|
|
99
|
+
visible: true
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
|
|
106
|
+
// Helper: Check if element is visible
|
|
107
|
+
function isVisible(el) {
|
|
108
|
+
if (!el.offsetParent) return false;
|
|
109
|
+
const style = window.getComputedStyle(el);
|
|
110
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Helper: Generate stable selector
|
|
115
|
+
function generateSelector(el) {
|
|
116
|
+
// Try ID first
|
|
117
|
+
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
|
|
123
|
+
const path = [];
|
|
124
|
+
let current = el;
|
|
125
|
+
while (current && current !== document.documentElement) {
|
|
126
|
+
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
|
+
if (current.className) {
|
|
135
|
+
const classes = current.className
|
|
136
|
+
.split(' ')
|
|
137
|
+
.filter((c) => c && !c.startsWith('__'))
|
|
138
|
+
.join('.');
|
|
139
|
+
if (classes) selector += `.${classes}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Add nth-child for uniqueness
|
|
143
|
+
let sibling = current;
|
|
144
|
+
let index = 0;
|
|
145
|
+
while (sibling) {
|
|
146
|
+
if (sibling.tagName === current.tagName) index++;
|
|
147
|
+
sibling = sibling.previousElementSibling;
|
|
148
|
+
}
|
|
149
|
+
if (index > 0) selector += `:nth-of-type(${index})`;
|
|
150
|
+
|
|
151
|
+
path.unshift(selector);
|
|
152
|
+
current = current.parentElement;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return path.join(' > ');
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Merge into stable map (deduplicate by selector)
|
|
160
|
+
for (const el of elements) {
|
|
161
|
+
if (!discovered.has(el.selector)) {
|
|
162
|
+
discovered.set(el.selector, el);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Select interactions according to budget with deterministic prioritization.
|
|
169
|
+
* Returns { selected, skipped, budget }
|
|
170
|
+
*/
|
|
171
|
+
selectByBudget(discovered) {
|
|
172
|
+
const selected = [];
|
|
173
|
+
const skipped = [];
|
|
174
|
+
|
|
175
|
+
// Sort deterministically: by priority category, then text/href for stability
|
|
176
|
+
const sorted = discovered.sort((a, b) => {
|
|
177
|
+
const priorityA = getPriority(a);
|
|
178
|
+
const priorityB = getPriority(b);
|
|
179
|
+
if (priorityA !== priorityB) return priorityA - priorityB;
|
|
180
|
+
|
|
181
|
+
const textA = (a.text || a.href || '').toLowerCase();
|
|
182
|
+
const textB = (b.text || b.href || '').toLowerCase();
|
|
183
|
+
return textA.localeCompare(textB);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Allocate budget across categories
|
|
187
|
+
const categories = { link: [], button: [], form: [] };
|
|
188
|
+
for (const el of sorted) {
|
|
189
|
+
const cat = el.type === 'link' ? 'link' : 'button';
|
|
190
|
+
categories[cat].push(el);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Distribute budget: 40% links, 40% buttons, 20% forms
|
|
194
|
+
const linkBudget = Math.floor(this.interactionBudgetPerPage * 0.4);
|
|
195
|
+
const buttonBudget = Math.floor(this.interactionBudgetPerPage * 0.4);
|
|
196
|
+
const formBudget = Math.floor(this.interactionBudgetPerPage * 0.2);
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < categories.link.length; i++) {
|
|
199
|
+
if (i < linkBudget) selected.push(categories.link[i]);
|
|
200
|
+
else skipped.push(categories.link[i]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < categories.button.length; i++) {
|
|
204
|
+
if (i < buttonBudget) selected.push(categories.button[i]);
|
|
205
|
+
else skipped.push(categories.button[i]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
selected,
|
|
210
|
+
skipped,
|
|
211
|
+
discoveredCount: discovered.length,
|
|
212
|
+
selectedCount: selected.length,
|
|
213
|
+
skippedDueToBudgetCount: skipped.length,
|
|
214
|
+
budgetUsed: selected.length,
|
|
215
|
+
budgetAvailable: this.interactionBudgetPerPage
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Fill and submit a form with realistic dummy data.
|
|
221
|
+
* Respects safety rules: skips payment, checkout, delete, etc.
|
|
222
|
+
*/
|
|
223
|
+
async fillAndSubmitForm(page, formSelector) {
|
|
224
|
+
// Check safety rules first
|
|
225
|
+
const isDangerous = await page.evaluate((sel) => {
|
|
226
|
+
const form = document.querySelector(sel);
|
|
227
|
+
if (!form) return true;
|
|
228
|
+
|
|
229
|
+
const text = form.textContent.toLowerCase();
|
|
230
|
+
const dangerousKeywords = ['pay', 'checkout', 'delete', 'remove', 'unsubscribe', 'billing', 'credit card'];
|
|
231
|
+
if (dangerousKeywords.some((kw) => text.includes(kw))) return true;
|
|
232
|
+
|
|
233
|
+
// Check buttons
|
|
234
|
+
const buttons = form.querySelectorAll('button, input[type="submit"]');
|
|
235
|
+
for (const btn of buttons) {
|
|
236
|
+
const btnText = btn.textContent.toLowerCase();
|
|
237
|
+
if (dangerousKeywords.some((kw) => btnText.includes(kw))) return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return false;
|
|
241
|
+
}, formSelector);
|
|
242
|
+
|
|
243
|
+
if (isDangerous) {
|
|
244
|
+
return { success: false, reason: 'DANGEROUS_FORM_SKIPPED' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Fill required fields
|
|
248
|
+
const filled = await page.evaluate((sel) => {
|
|
249
|
+
const form = document.querySelector(sel);
|
|
250
|
+
if (!form) return { filled: [], errors: [] };
|
|
251
|
+
|
|
252
|
+
const filled = [];
|
|
253
|
+
const errors = [];
|
|
254
|
+
|
|
255
|
+
// Find all input elements
|
|
256
|
+
const inputs = form.querySelectorAll('input, textarea, select');
|
|
257
|
+
|
|
258
|
+
for (const input of inputs) {
|
|
259
|
+
if (input.disabled || input.readOnly) continue;
|
|
260
|
+
if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') continue;
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
if (input.tagName === 'SELECT') {
|
|
264
|
+
// Choose first non-empty option
|
|
265
|
+
const options = input.querySelectorAll('option');
|
|
266
|
+
if (options.length > 1) {
|
|
267
|
+
input.value = options[1].value;
|
|
268
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
269
|
+
filled.push({ name: input.name || input.id || 'select', value: input.value });
|
|
270
|
+
}
|
|
271
|
+
} else if (input.type === 'checkbox' || input.type === 'radio') {
|
|
272
|
+
// Choose first option
|
|
273
|
+
input.checked = true;
|
|
274
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
275
|
+
filled.push({ name: input.name || input.id || 'checkbox', value: 'checked' });
|
|
276
|
+
} else if (input.type === 'email') {
|
|
277
|
+
input.value = 'test@example.com';
|
|
278
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
279
|
+
filled.push({ name: input.name || input.id || 'email', value: 'test@example.com' });
|
|
280
|
+
} else if (input.type === 'tel') {
|
|
281
|
+
input.value = '+1-555-0123';
|
|
282
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
283
|
+
filled.push({ name: input.name || input.id || 'tel', value: '+1-555-0123' });
|
|
284
|
+
} else if (input.type === 'url') {
|
|
285
|
+
input.value = 'https://example.com';
|
|
286
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
287
|
+
filled.push({ name: input.name || input.id || 'url', value: 'https://example.com' });
|
|
288
|
+
} else if (input.tagName === 'TEXTAREA') {
|
|
289
|
+
input.value = 'Test message';
|
|
290
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
291
|
+
filled.push({ name: input.name || input.id || 'textarea', value: 'Test message' });
|
|
292
|
+
} else if (input.type === 'text' || input.type === '') {
|
|
293
|
+
// Guess based on name/label
|
|
294
|
+
const name = (input.name || input.id || '').toLowerCase();
|
|
295
|
+
let value = 'John Doe';
|
|
296
|
+
if (name.includes('email')) value = 'test@example.com';
|
|
297
|
+
else if (name.includes('name')) value = 'John Doe';
|
|
298
|
+
else if (name.includes('phone') || name.includes('tel')) value = '+1-555-0123';
|
|
299
|
+
else if (name.includes('address') || name.includes('street')) value = '123 Main St';
|
|
300
|
+
else if (name.includes('city')) value = 'Anytown';
|
|
301
|
+
else if (name.includes('state') || name.includes('province')) value = 'CA';
|
|
302
|
+
else if (name.includes('zip') || name.includes('postal')) value = '12345';
|
|
303
|
+
|
|
304
|
+
input.value = value;
|
|
305
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
306
|
+
filled.push({ name: input.name || input.id || 'text', value });
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
errors.push({ name: input.name || input.id, error: err.message });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { filled, errors };
|
|
314
|
+
}, formSelector);
|
|
315
|
+
|
|
316
|
+
// Submit form
|
|
317
|
+
const submitResult = await page.evaluate((sel) => {
|
|
318
|
+
const form = document.querySelector(sel);
|
|
319
|
+
if (!form) return { submitted: false, reason: 'FORM_NOT_FOUND' };
|
|
320
|
+
|
|
321
|
+
// Find submit button
|
|
322
|
+
let submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
|
|
323
|
+
|
|
324
|
+
if (!submitBtn) {
|
|
325
|
+
// Try click form submit
|
|
326
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
327
|
+
return { submitted: true, method: 'form_submit_event' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Click submit button
|
|
331
|
+
submitBtn.click();
|
|
332
|
+
return { submitted: true, method: 'button_click' };
|
|
333
|
+
}, formSelector);
|
|
334
|
+
|
|
335
|
+
// Wait for navigation or settle
|
|
336
|
+
try {
|
|
337
|
+
await Promise.race([
|
|
338
|
+
page.waitForNavigation({ waitUntil: 'networkidle', timeout: 5000 }).catch(() => {}),
|
|
339
|
+
page.waitForTimeout(2000)
|
|
340
|
+
]);
|
|
341
|
+
} catch {
|
|
342
|
+
// Navigation may not happen, that's okay
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await waitForSettle(page, { timeoutMs: 10000 });
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
success: true,
|
|
349
|
+
filled: filled.filled,
|
|
350
|
+
submitted: submitResult.submitted,
|
|
351
|
+
method: submitResult.method
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get deterministic priority for interaction.
|
|
358
|
+
* Lower number = higher priority
|
|
359
|
+
*/
|
|
360
|
+
function getPriority(element) {
|
|
361
|
+
const text = (element.text || element.href || '').toLowerCase();
|
|
362
|
+
|
|
363
|
+
// Primary navigation
|
|
364
|
+
if (text.includes('home') || text.includes('/')) return 0;
|
|
365
|
+
if (text.includes('about')) return 1;
|
|
366
|
+
if (text.includes('contact')) return 2;
|
|
367
|
+
if (text.includes('service') || text.includes('product')) return 3;
|
|
368
|
+
if (text.includes('blog') || text.includes('news')) return 4;
|
|
369
|
+
|
|
370
|
+
// Footer/secondary
|
|
371
|
+
if (text.includes('footer') || text.includes('copyright')) return 100;
|
|
372
|
+
if (text.includes('privacy') || text.includes('terms')) return 101;
|
|
373
|
+
|
|
374
|
+
// Default
|
|
375
|
+
return 50;
|
|
376
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolve, dirname } from 'path';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
import { createBrowser, navigateToUrl, closeBrowser } from './browser.js';
|
|
4
|
+
import { discoverInteractions } from './interaction-discovery.js';
|
|
5
|
+
import { captureScreenshot } from './evidence-capture.js';
|
|
6
|
+
import { runInteraction } from './interaction-runner.js';
|
|
7
|
+
import { writeTraces } from './traces-writer.js';
|
|
8
|
+
import { getBaseOrigin } from './domain-boundary.js';
|
|
9
|
+
|
|
10
|
+
const MAX_SCAN_DURATION_MS = 60000;
|
|
11
|
+
|
|
12
|
+
export async function observe(url, manifestPath = null, artifactPaths = null) {
|
|
13
|
+
const { browser, page } = await createBrowser();
|
|
14
|
+
const startTime = Date.now();
|
|
15
|
+
const baseOrigin = getBaseOrigin(url);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await navigateToUrl(page, url);
|
|
19
|
+
|
|
20
|
+
const projectDir = manifestPath ? dirname(dirname(dirname(manifestPath))) : process.cwd();
|
|
21
|
+
let screenshotsDir;
|
|
22
|
+
if (artifactPaths) {
|
|
23
|
+
screenshotsDir = resolve(artifactPaths.evidence, 'screenshots');
|
|
24
|
+
} else {
|
|
25
|
+
const observeDir = resolve(projectDir, '.veraxverax', 'observe');
|
|
26
|
+
screenshotsDir = resolve(observeDir, 'screenshots');
|
|
27
|
+
}
|
|
28
|
+
mkdirSync(screenshotsDir, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const timestamp = Date.now();
|
|
31
|
+
const initialScreenshot = resolve(screenshotsDir, `initial-${timestamp}.png`);
|
|
32
|
+
await captureScreenshot(page, initialScreenshot);
|
|
33
|
+
|
|
34
|
+
const { interactions, coverage } = await discoverInteractions(page, baseOrigin);
|
|
35
|
+
const traces = [];
|
|
36
|
+
const observeWarnings = [];
|
|
37
|
+
if (coverage && coverage.capped) {
|
|
38
|
+
observeWarnings.push({
|
|
39
|
+
code: 'INTERACTIONS_CAPPED',
|
|
40
|
+
message: 'Interaction discovery reached the cap (30). Scan coverage is incomplete.'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < interactions.length; i++) {
|
|
45
|
+
if (Date.now() - startTime > MAX_SCAN_DURATION_MS) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const trace = await runInteraction(page, interactions[i], timestamp, i, screenshotsDir, baseOrigin, startTime, MAX_SCAN_DURATION_MS);
|
|
50
|
+
if (trace) {
|
|
51
|
+
traces.push(trace);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const observation = writeTraces(projectDir, url, traces, coverage, observeWarnings, artifactPaths);
|
|
56
|
+
|
|
57
|
+
await closeBrowser(browser);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
...observation,
|
|
61
|
+
screenshotsDir: screenshotsDir
|
|
62
|
+
};
|
|
63
|
+
} catch (error) {
|
|
64
|
+
await closeBrowser(browser);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|