@rester159/blacktip 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.
Files changed (63) hide show
  1. package/AGENTS.md +249 -0
  2. package/LICENSE +38 -0
  3. package/README.md +234 -0
  4. package/dist/behavioral/calibration.d.ts +145 -0
  5. package/dist/behavioral/calibration.d.ts.map +1 -0
  6. package/dist/behavioral/calibration.js +242 -0
  7. package/dist/behavioral/calibration.js.map +1 -0
  8. package/dist/behavioral-engine.d.ts +156 -0
  9. package/dist/behavioral-engine.d.ts.map +1 -0
  10. package/dist/behavioral-engine.js +521 -0
  11. package/dist/behavioral-engine.js.map +1 -0
  12. package/dist/blacktip.d.ts +289 -0
  13. package/dist/blacktip.d.ts.map +1 -0
  14. package/dist/blacktip.js +1574 -0
  15. package/dist/blacktip.js.map +1 -0
  16. package/dist/browser-core.d.ts +47 -0
  17. package/dist/browser-core.d.ts.map +1 -0
  18. package/dist/browser-core.js +375 -0
  19. package/dist/browser-core.js.map +1 -0
  20. package/dist/cli.d.ts +20 -0
  21. package/dist/cli.d.ts.map +1 -0
  22. package/dist/cli.js +226 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/element-finder.d.ts +42 -0
  25. package/dist/element-finder.d.ts.map +1 -0
  26. package/dist/element-finder.js +240 -0
  27. package/dist/element-finder.js.map +1 -0
  28. package/dist/evasion.d.ts +39 -0
  29. package/dist/evasion.d.ts.map +1 -0
  30. package/dist/evasion.js +488 -0
  31. package/dist/evasion.js.map +1 -0
  32. package/dist/fingerprint.d.ts +19 -0
  33. package/dist/fingerprint.d.ts.map +1 -0
  34. package/dist/fingerprint.js +171 -0
  35. package/dist/fingerprint.js.map +1 -0
  36. package/dist/index.d.ts +19 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +14 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/logging.d.ts +13 -0
  41. package/dist/logging.d.ts.map +1 -0
  42. package/dist/logging.js +42 -0
  43. package/dist/logging.js.map +1 -0
  44. package/dist/observability.d.ts +69 -0
  45. package/dist/observability.d.ts.map +1 -0
  46. package/dist/observability.js +189 -0
  47. package/dist/observability.js.map +1 -0
  48. package/dist/proxy-pool.d.ts +101 -0
  49. package/dist/proxy-pool.d.ts.map +1 -0
  50. package/dist/proxy-pool.js +156 -0
  51. package/dist/proxy-pool.js.map +1 -0
  52. package/dist/snapshot.d.ts +59 -0
  53. package/dist/snapshot.d.ts.map +1 -0
  54. package/dist/snapshot.js +91 -0
  55. package/dist/snapshot.js.map +1 -0
  56. package/dist/types.d.ts +243 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +15 -0
  59. package/dist/types.js.map +1 -0
  60. package/examples/01-basic-navigate.ts +40 -0
  61. package/examples/02-login-with-mfa.ts +68 -0
  62. package/examples/03-agent-serve-mode.md +98 -0
  63. package/package.json +62 -0
@@ -0,0 +1,1574 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { createServer } from 'node:net';
3
+ import { BrowserCore } from './browser-core.js';
4
+ import { BehavioralEngine, HUMAN_PROFILE, SCRAPER_PROFILE } from './behavioral-engine.js';
5
+ import { ElementFinder } from './element-finder.js';
6
+ import { Logger } from './logging.js';
7
+ const RETRY_STRATEGIES = ['standard', 'wait', 'reload', 'altSelector', 'scroll', 'clearOverlays'];
8
+ /**
9
+ * Text patterns that suggest an action is high-importance — submit,
10
+ * payment, confirmation, destructive actions. When clickText or clickRole
11
+ * matches a button whose label matches one of these, the behavioral
12
+ * engine automatically applies importance:'high' so the pre-action
13
+ * hesitation matches a real human's pause before committing.
14
+ *
15
+ * Caller can still override by passing importance explicitly.
16
+ */
17
+ const HIGH_IMPORTANCE_PATTERNS = [
18
+ /^\s*submit\b/i,
19
+ /^\s*pay\b/i,
20
+ /^\s*confirm\b/i,
21
+ /^\s*place\s*order\b/i,
22
+ /^\s*checkout\b/i,
23
+ /^\s*purchase\b/i,
24
+ /^\s*buy\b/i,
25
+ /^\s*delete\b/i,
26
+ /^\s*remove\b/i,
27
+ /^\s*agree\b/i,
28
+ /^\s*accept\b/i,
29
+ /send\s*money/i,
30
+ /submit\s*payment/i,
31
+ /confirm\s*payment/i,
32
+ /place\s*order/i,
33
+ /^\s*sign\s*(up|in|out)?\s*$/i,
34
+ /^\s*log\s*out\b/i,
35
+ /^\s*finish\b/i,
36
+ /^\s*complete\b/i,
37
+ ];
38
+ function inferImportance(text, explicit) {
39
+ if (explicit)
40
+ return explicit;
41
+ if (!text)
42
+ return 'normal';
43
+ return HIGH_IMPORTANCE_PATTERNS.some((re) => re.test(text)) ? 'high' : 'normal';
44
+ }
45
+ /**
46
+ * BlackTip — Stealth browser instrument for AI agents.
47
+ *
48
+ * BlackTip is NOT an agent. It is a tool that an agent drives.
49
+ * Every action (click, type, scroll) is wrapped in human-like behavioral
50
+ * simulation that defeats bot detection.
51
+ *
52
+ * ## Quick Start (for AI agents)
53
+ *
54
+ * ```typescript
55
+ * const bt = new BlackTip();
56
+ * await bt.launch();
57
+ * await bt.navigate('https://example.com');
58
+ * await bt.type('input[name="email"]', 'user@example.com', { paste: true });
59
+ * await bt.click('.submit-btn');
60
+ * await bt.close();
61
+ * ```
62
+ *
63
+ * ## Agent Usage Guide
64
+ *
65
+ * Call `BlackTip.agentGuide()` to get detailed instructions for how an AI
66
+ * agent should use BlackTip. This includes critical patterns for React/Angular
67
+ * forms, Okta login pages, custom dropdowns, and common mistakes to avoid.
68
+ *
69
+ * ## Server Mode (recommended for agents)
70
+ *
71
+ * ```typescript
72
+ * const bt = new BlackTip();
73
+ * await bt.serve(9779); // TCP server, send commands as JS strings
74
+ * ```
75
+ *
76
+ * ## Key Methods
77
+ * - `navigate(url)` — go to URL
78
+ * - `click(selector)` — click by CSS/XPath
79
+ * - `clickText(text, {nth?})` — click by visible text (handles React/Okta)
80
+ * - `type(selector, text, {paste?})` — type into input (React-compatible)
81
+ * - `screenshot({path})` — capture page state
82
+ * - `executeJS(script)` — run JS in page context
83
+ * - `uploadFile(selector, path)` — upload a file
84
+ * - `waitFor(selector)` — wait for element
85
+ * - `extractText(selector)` — get text content
86
+ * - `frame(selector)` — interact with iframes
87
+ * - `serve(port)` — start TCP command server
88
+ */
89
+ export class BlackTip extends EventEmitter {
90
+ core;
91
+ engine;
92
+ finder;
93
+ logger;
94
+ config;
95
+ customProfiles = new Map();
96
+ launched = false;
97
+ constructor(config = {}) {
98
+ super();
99
+ this.config = config;
100
+ const logLevel = (config.logLevel ?? process.env.BLACKTIP_LOG_LEVEL ?? 'info');
101
+ this.logger = new Logger(logLevel);
102
+ // Forward log events
103
+ this.logger.on('log', (entry) => this.emit('log', entry));
104
+ // Resolve behavioral profile
105
+ const profile = this.resolveProfile(config.behaviorProfile ?? 'human');
106
+ this.engine = new BehavioralEngine(profile);
107
+ this.core = new BrowserCore(config, this.logger);
108
+ this.finder = new ElementFinder(this.logger);
109
+ // Forward tab events from core
110
+ this.core.on('tabChange', (event) => this.emit('tabChange', event));
111
+ // Default 'error' listener so Node's EventEmitter doesn't crash the
112
+ // process when an action fails and no user listener is attached. The
113
+ // action result already carries the error; this event is supplemental.
114
+ // Users can still add their own 'error' listeners alongside this one.
115
+ this.on('error', (errorEvent) => {
116
+ this.logger.debug('BlackTip action error (no external listener)', {
117
+ code: errorEvent?.code,
118
+ action: errorEvent?.action,
119
+ });
120
+ });
121
+ }
122
+ // ── Lifecycle ──
123
+ /**
124
+ * Launch the browser. Returns the agent usage guide — read it before
125
+ * driving BlackTip to avoid common mistakes.
126
+ */
127
+ async launch() {
128
+ await this.core.launch();
129
+ this.launched = true;
130
+ return BlackTip.agentGuide();
131
+ }
132
+ async close() {
133
+ await this.core.close();
134
+ this.launched = false;
135
+ }
136
+ isActive() {
137
+ return this.launched && this.core.isActive();
138
+ }
139
+ // ── Navigation ──
140
+ async navigate(url, options) {
141
+ this.ensureLaunched();
142
+ // Pre-navigation pause
143
+ await this.sleep(this.engine.generatePreActionPause());
144
+ return this.core.navigate(url, options);
145
+ }
146
+ // ── Actions ──
147
+ async click(selector, options) {
148
+ return this.executeAction('click', selector, async () => {
149
+ const page = this.core.getActivePage();
150
+ const element = await this.finder.find(page, selector, {
151
+ timeout: options?.timeout ?? this.config.timeout,
152
+ visible: true,
153
+ });
154
+ const box = await this.finder.getBoundingBox(element);
155
+ if (!box)
156
+ throw new Error('Element has no bounding box');
157
+ // Generate human-like mouse movement toward the originally-captured box.
158
+ const currentPos = await this.getMousePosition();
159
+ const targetPos = this.engine.generateClickPosition(box);
160
+ const behavioral = await this.performMouseMove(currentPos, targetPos);
161
+ // Hover dwell
162
+ const dwell = this.engine.generateClickDwell();
163
+ await this.sleep(dwell);
164
+ behavioral.clickDwell = dwell;
165
+ // L011 fix: the DOM may have reflowed during our ~200ms mouse path
166
+ // (async scripts, layout shifts, animations). Re-read the element's
167
+ // live bounding box immediately before the click and, if it moved
168
+ // more than a few pixels, do a short correction move to the new
169
+ // center. Real humans do the same thing when a target shifts.
170
+ const liveBox = await this.finder.getBoundingBox(element);
171
+ if (liveBox) {
172
+ const liveCenter = {
173
+ x: liveBox.x + liveBox.width / 2,
174
+ y: liveBox.y + liveBox.height / 2,
175
+ };
176
+ const drift = Math.hypot(liveCenter.x - targetPos.x, liveCenter.y - targetPos.y);
177
+ if (drift > 5) {
178
+ this.logger.debug('Click target reflow detected, correcting', {
179
+ drift: Math.round(drift),
180
+ from: targetPos,
181
+ to: liveCenter,
182
+ });
183
+ await this.performMouseMove(targetPos, liveCenter);
184
+ targetPos.x = liveCenter.x;
185
+ targetPos.y = liveCenter.y;
186
+ // A small post-correction dwell — humans take a brief beat
187
+ // between a correction and the click.
188
+ await this.sleep(80 + Math.random() * 120);
189
+ }
190
+ }
191
+ // Pre-click verification: if the click coordinates are covered by
192
+ // an overlay, dismiss the overlay and fall back to Playwright's
193
+ // element.click() with force:true.
194
+ const hitCheck = await page.evaluate(`((x, y) => {
195
+ const el = document.elementFromPoint(x, y);
196
+ if (!el) return { ok: false, reason: 'no-element' };
197
+ let cur = el;
198
+ while (cur) {
199
+ if (cur.tagName === 'BUTTON' || cur.tagName === 'A' || cur.tagName === 'INPUT' || cur.getAttribute('role') === 'button') {
200
+ return { ok: true };
201
+ }
202
+ cur = cur.parentElement;
203
+ }
204
+ return { ok: false, reason: 'not-interactive', tag: el.tagName };
205
+ })(${targetPos.x}, ${targetPos.y})`);
206
+ if (hitCheck.ok) {
207
+ await page.mouse.click(targetPos.x, targetPos.y, {
208
+ button: options?.button ?? 'left',
209
+ clickCount: options?.count ?? 1,
210
+ });
211
+ }
212
+ else {
213
+ this.logger.warn('Click target covered by overlay; dismissing and retrying', {
214
+ reason: hitCheck.reason,
215
+ coveredBy: hitCheck.tag,
216
+ });
217
+ await this.dismissOverlays();
218
+ await element.click({ force: true, timeout: 3000 });
219
+ }
220
+ // Try to auto-infer importance from the element text if the
221
+ // caller didn't set it.
222
+ let inferredImportance = options?.importance;
223
+ if (!inferredImportance) {
224
+ try {
225
+ const elText = await element.innerText();
226
+ inferredImportance = inferImportance(elText);
227
+ }
228
+ catch { /* element may have navigated away */ }
229
+ }
230
+ // Post-action pause
231
+ const postPause = this.engine.generatePostActionPause();
232
+ await this.sleep(postPause);
233
+ behavioral.postActionPause = postPause;
234
+ // Stash the inferred importance on the behavioral object so the
235
+ // outer executeAction wrapper can read it. But the pre-action
236
+ // pause already happened — importance inference only affects
237
+ // future calls. Keeping the interface consistent anyway.
238
+ void inferredImportance;
239
+ return behavioral;
240
+ }, undefined, options?.importance);
241
+ }
242
+ async clickText(text, options) {
243
+ // Auto-infer importance from the text if caller didn't set it.
244
+ const importance = inferImportance(text, options?.importance);
245
+ return this.executeAction('click', `text="${text}"`, async () => {
246
+ const page = this.core.getActivePage();
247
+ let locator = page.getByText(text, { exact: options?.exact ?? true });
248
+ if (options?.nth !== undefined) {
249
+ locator = locator.nth(options.nth);
250
+ }
251
+ else {
252
+ locator = locator.first(); // Avoid strict mode violation when multiple matches
253
+ }
254
+ await locator.waitFor({ state: 'visible', timeout: options?.timeout ?? this.config.timeout ?? 15000 });
255
+ const box = await locator.boundingBox();
256
+ if (!box)
257
+ throw new Error('Element has no bounding box');
258
+ const currentPos = await this.getMousePosition();
259
+ const targetPos = this.engine.generateClickPosition(box);
260
+ const behavioral = await this.performMouseMove(currentPos, targetPos);
261
+ const dwell = this.engine.generateClickDwell();
262
+ await this.sleep(dwell);
263
+ behavioral.clickDwell = dwell;
264
+ // Pre-click verification: check if the click coordinates land on
265
+ // an interactive element. If they don't (e.g. a chat widget or
266
+ // cookie banner is covering the button), dismiss overlays and
267
+ // fall back to locator.click() which bypasses the coordinate
268
+ // issue entirely.
269
+ const hitCheck = await page.evaluate(`((x, y) => {
270
+ const el = document.elementFromPoint(x, y);
271
+ if (!el) return { ok: false, reason: 'no-element' };
272
+ let cur = el;
273
+ while (cur) {
274
+ if (cur.tagName === 'BUTTON' || cur.tagName === 'A' || cur.getAttribute('role') === 'button') {
275
+ return { ok: true };
276
+ }
277
+ cur = cur.parentElement;
278
+ }
279
+ return { ok: false, reason: 'not-interactive', tag: el.tagName };
280
+ })(${targetPos.x}, ${targetPos.y})`);
281
+ if (hitCheck.ok) {
282
+ await page.mouse.click(targetPos.x, targetPos.y, {
283
+ button: options?.button ?? 'left',
284
+ clickCount: options?.count ?? 1,
285
+ });
286
+ }
287
+ else {
288
+ // Click would be intercepted. Dismiss overlays and use
289
+ // locator.click({force:true}) which does its own visibility
290
+ // handling.
291
+ this.logger.warn('Click target covered by overlay; dismissing and retrying via locator', {
292
+ reason: hitCheck.reason,
293
+ coveredBy: hitCheck.tag,
294
+ });
295
+ await this.dismissOverlays();
296
+ await locator.click({ force: true, timeout: 3000 });
297
+ }
298
+ const postPause = this.engine.generatePostActionPause();
299
+ await this.sleep(postPause);
300
+ behavioral.postActionPause = postPause;
301
+ return behavioral;
302
+ }, undefined, importance);
303
+ }
304
+ async clickRole(role, options) {
305
+ const label = `role=${role}${options?.name ? `[name="${options.name}"]` : ''}`;
306
+ // Auto-infer importance from the role name if provided.
307
+ const importance = inferImportance(options?.name, options?.importance);
308
+ return this.executeAction('click', label, async () => {
309
+ const page = this.core.getActivePage();
310
+ let locator = page.getByRole(role, options?.name ? { name: options.name } : undefined);
311
+ if (options?.nth !== undefined) {
312
+ locator = locator.nth(options.nth);
313
+ }
314
+ else {
315
+ locator = locator.first();
316
+ }
317
+ await locator.waitFor({ state: 'visible', timeout: options?.timeout ?? this.config.timeout ?? 15000 });
318
+ const box = await locator.boundingBox();
319
+ if (!box)
320
+ throw new Error('Element has no bounding box');
321
+ const currentPos = await this.getMousePosition();
322
+ const targetPos = this.engine.generateClickPosition(box);
323
+ const behavioral = await this.performMouseMove(currentPos, targetPos);
324
+ const dwell = this.engine.generateClickDwell();
325
+ await this.sleep(dwell);
326
+ behavioral.clickDwell = dwell;
327
+ // Pre-click verification — same pattern as clickText.
328
+ const hitCheck = await page.evaluate(`((x, y) => {
329
+ const el = document.elementFromPoint(x, y);
330
+ if (!el) return { ok: false, reason: 'no-element' };
331
+ let cur = el;
332
+ while (cur) {
333
+ if (cur.tagName === 'BUTTON' || cur.tagName === 'A' || cur.tagName === 'INPUT' || cur.getAttribute('role') === 'button') {
334
+ return { ok: true };
335
+ }
336
+ cur = cur.parentElement;
337
+ }
338
+ return { ok: false, reason: 'not-interactive', tag: el.tagName };
339
+ })(${targetPos.x}, ${targetPos.y})`);
340
+ if (hitCheck.ok) {
341
+ await page.mouse.click(targetPos.x, targetPos.y, {
342
+ button: options?.button ?? 'left',
343
+ clickCount: options?.count ?? 1,
344
+ });
345
+ }
346
+ else {
347
+ this.logger.warn('Click target covered by overlay; dismissing and retrying via locator', {
348
+ reason: hitCheck.reason,
349
+ coveredBy: hitCheck.tag,
350
+ });
351
+ await this.dismissOverlays();
352
+ await locator.click({ force: true, timeout: 3000 });
353
+ }
354
+ const postPause = this.engine.generatePostActionPause();
355
+ await this.sleep(postPause);
356
+ behavioral.postActionPause = postPause;
357
+ return behavioral;
358
+ }, undefined, importance);
359
+ }
360
+ async type(selector, text, options) {
361
+ return this.executeAction('type', selector, async () => {
362
+ const page = this.core.getActivePage();
363
+ const element = await this.finder.find(page, selector, {
364
+ timeout: options?.timeout ?? this.config.timeout,
365
+ visible: true,
366
+ });
367
+ // Click on the element first (human-like)
368
+ const box = await this.finder.getBoundingBox(element);
369
+ if (!box)
370
+ throw new Error('Element has no bounding box');
371
+ const currentPos = await this.getMousePosition();
372
+ const targetPos = this.engine.generateClickPosition(box);
373
+ const behavioral = await this.performMouseMove(currentPos, targetPos);
374
+ const dwell = this.engine.generateClickDwell();
375
+ await this.sleep(dwell);
376
+ await page.mouse.click(targetPos.x, targetPos.y);
377
+ // Clear existing content if requested
378
+ if (options?.clearFirst) {
379
+ await page.keyboard.press('Control+a');
380
+ await this.sleep(50);
381
+ await page.keyboard.press('Backspace');
382
+ await this.sleep(this.engine.generatePreActionPause() * 0.3);
383
+ }
384
+ // Use Playwright's fill() to set value — this correctly triggers React/Angular
385
+ // change events, input events, and form validation. Then optionally simulate
386
+ // typing cadence with page.type() for keystroke-level behavioral fidelity.
387
+ const shouldPaste = options?.paste ?? this.engine.shouldPaste(text);
388
+ if (shouldPaste) {
389
+ // Fast path: fill directly (like a paste) — triggers React/Angular events correctly
390
+ await element.fill(text);
391
+ await this.sleep(100 + Math.random() * 200);
392
+ }
393
+ else {
394
+ // Human typing path: use page.keyboard.type() which dispatches the full
395
+ // keydown/keypress/input/keyup event cycle per character. This triggers
396
+ // React/Angular synthetic event handlers correctly, unlike keyboard.press().
397
+ // Clear field with Select-all + Backspace (works with all frameworks)
398
+ await page.keyboard.press('Control+a');
399
+ await this.sleep(30 + Math.random() * 50);
400
+ await page.keyboard.press('Backspace');
401
+ await this.sleep(50);
402
+ const sequence = this.engine.generateTypingSequence(text);
403
+ for (const keystroke of sequence) {
404
+ if (keystroke.isTypo && keystroke.correctionSequence) {
405
+ // Type wrong character via keyboard.type() (triggers input events)
406
+ await page.keyboard.type(keystroke.key, { delay: keystroke.holdDuration });
407
+ // Apply corrections (backspace + correct char)
408
+ for (const correction of keystroke.correctionSequence) {
409
+ await this.sleep(correction.delay);
410
+ if (correction.key === 'Backspace') {
411
+ await page.keyboard.press('Backspace');
412
+ }
413
+ else {
414
+ await page.keyboard.type(correction.key, { delay: correction.holdDuration });
415
+ }
416
+ }
417
+ }
418
+ else {
419
+ await this.sleep(keystroke.delay);
420
+ await page.keyboard.type(keystroke.key, { delay: keystroke.holdDuration });
421
+ }
422
+ }
423
+ behavioral.typingDuration = sequence.reduce((sum, k) => sum + k.delay + k.holdDuration, 0);
424
+ // Verify the field value — if the framework didn't register keystrokes, fall back to fill()
425
+ try {
426
+ const currentValue = await element.inputValue();
427
+ if (currentValue !== text) {
428
+ this.logger.warn('Typing mismatch, falling back to fill()', {
429
+ expected: text.length,
430
+ got: currentValue.length,
431
+ });
432
+ await element.fill(text);
433
+ }
434
+ }
435
+ catch {
436
+ // inputValue() may fail on non-input elements — that's OK
437
+ }
438
+ }
439
+ // Press Enter if requested
440
+ if (options?.pressEnter) {
441
+ await this.sleep(200 + Math.random() * 300);
442
+ await page.keyboard.press('Enter');
443
+ }
444
+ // Post-action pause
445
+ const postPause = this.engine.generatePostActionPause();
446
+ await this.sleep(postPause);
447
+ behavioral.postActionPause = postPause;
448
+ return behavioral;
449
+ }, text, options?.importance);
450
+ }
451
+ async scroll(options) {
452
+ const direction = options?.direction ?? 'down';
453
+ const amount = options?.amount ?? 300;
454
+ return this.executeAction('scroll', options?.selector ?? 'page', async () => {
455
+ const page = this.core.getActivePage();
456
+ if (options?.selector) {
457
+ const element = await this.finder.find(page, options.selector);
458
+ await element.scrollIntoViewIfNeeded();
459
+ }
460
+ const scrollDir = (direction === 'left' || direction === 'up') ? 'up' : 'down';
461
+ const steps = this.engine.generateScrollSteps(amount, scrollDir);
462
+ for (const step of steps) {
463
+ await this.sleep(step.delay);
464
+ const deltaX = (direction === 'left' ? -step.deltaY : direction === 'right' ? step.deltaY : 0);
465
+ const deltaY = (direction === 'up' ? -step.deltaY : direction === 'down' ? step.deltaY : 0);
466
+ await page.mouse.wheel(deltaX, deltaY);
467
+ }
468
+ const postPause = this.engine.generatePostActionPause();
469
+ await this.sleep(postPause);
470
+ return { postActionPause: postPause };
471
+ });
472
+ }
473
+ async hover(selector, options) {
474
+ return this.executeAction('hover', selector, async () => {
475
+ const page = this.core.getActivePage();
476
+ const element = await this.finder.find(page, selector, {
477
+ timeout: options?.timeout ?? this.config.timeout,
478
+ visible: true,
479
+ });
480
+ const box = await this.finder.getBoundingBox(element);
481
+ if (!box)
482
+ throw new Error('Element has no bounding box');
483
+ const currentPos = await this.getMousePosition();
484
+ const targetPos = this.engine.generateClickPosition(box);
485
+ const behavioral = await this.performMouseMove(currentPos, targetPos);
486
+ // Hover dwell (longer than click dwell)
487
+ const dwell = this.engine.generateClickDwell() * 1.5;
488
+ await this.sleep(dwell);
489
+ return behavioral;
490
+ });
491
+ }
492
+ async select(selector, value, options) {
493
+ return this.executeAction('select', selector, async () => {
494
+ const page = this.core.getActivePage();
495
+ const element = await this.finder.find(page, selector, {
496
+ timeout: options?.timeout ?? this.config.timeout,
497
+ });
498
+ // Click the select element first
499
+ const box = await this.finder.getBoundingBox(element);
500
+ if (!box)
501
+ throw new Error('Element has no bounding box');
502
+ const currentPos = await this.getMousePosition();
503
+ const targetPos = this.engine.generateClickPosition(box);
504
+ const behavioral = await this.performMouseMove(currentPos, targetPos);
505
+ await this.sleep(this.engine.generateClickDwell());
506
+ await page.mouse.click(targetPos.x, targetPos.y);
507
+ // Small delay before selecting
508
+ await this.sleep(200 + Math.random() * 300);
509
+ // Playwright's selectOption requires ONE matcher; passing both value
510
+ // and label means the option must match both. Try value first, fall
511
+ // back to label for callers who pass the visible label text.
512
+ try {
513
+ await page.selectOption(selector, { value });
514
+ }
515
+ catch {
516
+ await page.selectOption(selector, { label: value });
517
+ }
518
+ const postPause = this.engine.generatePostActionPause();
519
+ await this.sleep(postPause);
520
+ behavioral.postActionPause = postPause;
521
+ return behavioral;
522
+ }, value);
523
+ }
524
+ async pressKey(key, options) {
525
+ return this.executeAction('pressKey', key, async () => {
526
+ const page = this.core.getActivePage();
527
+ await this.sleep(this.engine.generatePreActionPause() * 0.5);
528
+ await page.keyboard.press(key);
529
+ const postPause = this.engine.generatePostActionPause();
530
+ await this.sleep(postPause);
531
+ return { postActionPause: postPause };
532
+ });
533
+ }
534
+ async uploadFile(selector, filePath, options) {
535
+ return this.executeAction('uploadFile', selector, async () => {
536
+ const page = this.core.getActivePage();
537
+ const element = await this.finder.find(page, selector, {
538
+ timeout: options?.timeout ?? this.config.timeout,
539
+ });
540
+ await element.setInputFiles(filePath);
541
+ const postPause = this.engine.generatePostActionPause();
542
+ await this.sleep(postPause);
543
+ return { postActionPause: postPause };
544
+ }, filePath);
545
+ }
546
+ /**
547
+ * Click an element that triggers a download, wait for the download to
548
+ * complete, save it to `saveTo`, and return metadata about the file.
549
+ *
550
+ * Usage:
551
+ * const info = await bt.download('a.invoice-link', { saveTo: './invoice.pdf' });
552
+ * // info.path, info.size, info.suggestedFilename, info.url
553
+ */
554
+ async download(selector, options) {
555
+ this.ensureLaunched();
556
+ const page = this.core.getActivePage();
557
+ const timeout = options.timeout ?? 30_000;
558
+ // Start waiting for the download BEFORE we click, so Playwright
559
+ // catches the event regardless of click-to-dialog timing.
560
+ const [download] = await Promise.all([
561
+ page.waitForEvent('download', { timeout }),
562
+ this.click(selector, { timeout }),
563
+ ]);
564
+ await download.saveAs(options.saveTo);
565
+ const path = options.saveTo;
566
+ // Get the file size from Node's fs module.
567
+ const fs = await import('node:fs/promises');
568
+ const stat = await fs.stat(path);
569
+ return {
570
+ path,
571
+ size: stat.size,
572
+ suggestedFilename: download.suggestedFilename(),
573
+ url: download.url(),
574
+ };
575
+ }
576
+ // ── Data Extraction ──
577
+ async extractText(selector, options) {
578
+ this.ensureLaunched();
579
+ const page = this.core.getActivePage();
580
+ if (options?.multiple) {
581
+ const elements = await page.$$(selector);
582
+ const texts = [];
583
+ for (const el of elements) {
584
+ const text = await el.innerText();
585
+ texts.push(text);
586
+ }
587
+ return texts;
588
+ }
589
+ const element = await this.finder.find(page, selector);
590
+ return element.innerText();
591
+ }
592
+ async extractAttribute(selector, attribute) {
593
+ this.ensureLaunched();
594
+ const page = this.core.getActivePage();
595
+ const element = await this.finder.find(page, selector);
596
+ return element.getAttribute(attribute);
597
+ }
598
+ /**
599
+ * Find an element inside an open shadow root reachable from the page.
600
+ * Useful for modern web component libraries (Lit, Stencil, Material Web,
601
+ * Ionic). See `ElementFinder.findInShadowDom` for limitations on closed
602
+ * shadow roots.
603
+ */
604
+ async findInShadowDom(cssSelector, options) {
605
+ this.ensureLaunched();
606
+ const page = this.core.getActivePage();
607
+ return this.finder.findInShadowDom(page, cssSelector, options);
608
+ }
609
+ async extractTable(selector) {
610
+ this.ensureLaunched();
611
+ const page = this.core.getActivePage();
612
+ return page.evaluate(`((sel) => {
613
+ const table = document.querySelector(sel);
614
+ if (!table) return [];
615
+
616
+ const headers = [];
617
+ const headerCells = table.querySelectorAll('thead th, tr:first-child th');
618
+ headerCells.forEach((th) => headers.push(th.textContent?.trim() ?? ''));
619
+
620
+ const rows = [];
621
+ const bodyRows = table.querySelectorAll('tbody tr, tr:not(:first-child)');
622
+ bodyRows.forEach((tr) => {
623
+ const cells = tr.querySelectorAll('td');
624
+ if (cells.length === 0) return;
625
+ const row = {};
626
+ cells.forEach((td, i) => {
627
+ const key = headers[i] ?? ('col' + i);
628
+ row[key] = td.textContent?.trim() ?? '';
629
+ });
630
+ rows.push(row);
631
+ });
632
+
633
+ return rows;
634
+ })(${JSON.stringify(selector)})`);
635
+ }
636
+ async getPageContent(options) {
637
+ this.ensureLaunched();
638
+ return this.core.getPageContent(options);
639
+ }
640
+ // ── Waiting ──
641
+ async waitFor(selector, options) {
642
+ this.ensureLaunched();
643
+ const page = this.core.getActivePage();
644
+ const start = Date.now();
645
+ try {
646
+ const state = options?.hidden ? 'hidden' : (options?.visible !== false ? 'visible' : 'attached');
647
+ await page.waitForSelector(selector, {
648
+ timeout: options?.timeout ?? 30000,
649
+ state,
650
+ });
651
+ return { success: true, duration: Date.now() - start };
652
+ }
653
+ catch (err) {
654
+ return {
655
+ success: false,
656
+ duration: Date.now() - start,
657
+ error: err instanceof Error ? err.message : String(err),
658
+ };
659
+ }
660
+ }
661
+ async waitForNavigation(options) {
662
+ this.ensureLaunched();
663
+ const page = this.core.getActivePage();
664
+ await page.waitForLoadState(options?.waitUntil ?? 'domcontentloaded', {
665
+ timeout: options?.timeout ?? 30000,
666
+ });
667
+ }
668
+ // ── Screenshots ──
669
+ async screenshot(options) {
670
+ this.ensureLaunched();
671
+ return this.core.screenshot(options);
672
+ }
673
+ // ── Wait primitives (v0.3) ──
674
+ /**
675
+ * Wait until the page has been "stable" for a configurable window.
676
+ * Stability means: no network requests fired for `networkIdleMs`, and
677
+ * no DOM mutations observed for `domIdleMs`. Replaces fixed sleep
678
+ * waits with a real signal, so you don't wait longer than necessary.
679
+ *
680
+ * Returns an object describing how long the wait took and why it
681
+ * completed (both-idle / network-only / dom-only / timeout).
682
+ */
683
+ async waitForStable(options = {}) {
684
+ this.ensureLaunched();
685
+ const networkIdleMs = options.networkIdleMs ?? 500;
686
+ const domIdleMs = options.domIdleMs ?? 500;
687
+ const maxMs = options.maxMs ?? 10_000;
688
+ const pollMs = options.pollMs ?? 100;
689
+ const page = this.core.getActivePage();
690
+ const startedAt = Date.now();
691
+ // Set up tracking in-page. We attach a MutationObserver that records
692
+ // the time of the last DOM mutation, and we use the Performance API
693
+ // to find the time of the most recent network response.
694
+ await page.evaluate(`(() => {
695
+ if (window.__btStableTracker) return;
696
+ const tracker = { lastDomMutation: Date.now(), observer: null };
697
+ tracker.observer = new MutationObserver(() => { tracker.lastDomMutation = Date.now(); });
698
+ tracker.observer.observe(document.documentElement, {
699
+ childList: true, subtree: true, attributes: true, characterData: true,
700
+ });
701
+ window.__btStableTracker = tracker;
702
+ })()`);
703
+ try {
704
+ while (Date.now() - startedAt < maxMs) {
705
+ const state = await page.evaluate(`(() => {
706
+ const now = Date.now();
707
+ const tracker = window.__btStableTracker;
708
+ const domIdle = tracker ? now - tracker.lastDomMutation : 0;
709
+ const entries = performance.getEntriesByType('resource');
710
+ let lastNetwork = 0;
711
+ for (let i = entries.length - 1; i >= 0 && i >= entries.length - 20; i--) {
712
+ const e = entries[i];
713
+ const end = e.responseEnd || e.startTime + e.duration;
714
+ if (end > lastNetwork) lastNetwork = end;
715
+ }
716
+ const perfNow = performance.now();
717
+ const networkIdle = lastNetwork === 0 ? 999999 : perfNow - lastNetwork;
718
+ return { domIdle, networkIdle };
719
+ })()`);
720
+ if (state.domIdle >= domIdleMs && state.networkIdle >= networkIdleMs) {
721
+ return { durationMs: Date.now() - startedAt, reason: 'both-idle' };
722
+ }
723
+ await new Promise((r) => setTimeout(r, pollMs));
724
+ }
725
+ return { durationMs: Date.now() - startedAt, reason: 'timeout' };
726
+ }
727
+ finally {
728
+ await page.evaluate(`(() => {
729
+ if (window.__btStableTracker && window.__btStableTracker.observer) {
730
+ window.__btStableTracker.observer.disconnect();
731
+ delete window.__btStableTracker;
732
+ }
733
+ })()`).catch(() => { });
734
+ }
735
+ }
736
+ /**
737
+ * Wait for a text string to appear in the body innerText. Case-sensitive
738
+ * substring match by default. Use for server-rendered confirmations,
739
+ * OCR completion messages, and similar async content.
740
+ */
741
+ async waitForText(text, options = {}) {
742
+ this.ensureLaunched();
743
+ const timeout = options.timeout ?? 15_000;
744
+ const pollMs = options.pollMs ?? 250;
745
+ const page = this.core.getActivePage();
746
+ const startedAt = Date.now();
747
+ const escaped = text.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
748
+ while (Date.now() - startedAt < timeout) {
749
+ const present = await page.evaluate(`(document.body ? document.body.innerText : '').includes(\`${escaped}\`)`);
750
+ if (present)
751
+ return { durationMs: Date.now() - startedAt, found: true };
752
+ await new Promise((r) => setTimeout(r, pollMs));
753
+ }
754
+ return { durationMs: Date.now() - startedAt, found: false };
755
+ }
756
+ // ── Diagnostic primitives (v0.3) ──
757
+ /**
758
+ * Inspect an element: exists, visible, text, tag, key attributes,
759
+ * bounding box. One call replaces several hand-written executeJS
760
+ * queries.
761
+ */
762
+ async inspect(selector) {
763
+ this.ensureLaunched();
764
+ const page = this.core.getActivePage();
765
+ const result = await page.evaluate(`((sel) => {
766
+ const el = document.querySelector(sel);
767
+ if (!el) return { exists: false, visible: false };
768
+ const rect = el.getBoundingClientRect();
769
+ const style = window.getComputedStyle(el);
770
+ // Fixed/sticky elements have offsetParent === null but can still
771
+ // be visible. The correct visibility check is: display !== none,
772
+ // visibility !== hidden, opacity > 0, and bounding box has size.
773
+ const visible = !!(
774
+ rect.width > 0 &&
775
+ rect.height > 0 &&
776
+ style.display !== 'none' &&
777
+ style.visibility !== 'hidden' &&
778
+ parseFloat(style.opacity || '1') > 0
779
+ );
780
+ const attrs = {};
781
+ for (const a of el.attributes || []) {
782
+ attrs[a.name] = a.value.slice(0, 200);
783
+ }
784
+ return {
785
+ exists: true,
786
+ visible: visible,
787
+ tagName: el.tagName,
788
+ text: (el.textContent || '').trim().slice(0, 300),
789
+ attributes: attrs,
790
+ boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
791
+ };
792
+ })(${JSON.stringify(selector)})`);
793
+ return result;
794
+ }
795
+ /**
796
+ * List options in an Angular/React-style custom dropdown that uses
797
+ * the `{baseId}_option-{n}` pattern. Returns `[{id, text}]`. Used
798
+ * heavily in Anthem/Okta forms.
799
+ *
800
+ * If `baseId` is given, matches options whose id starts with
801
+ * `${baseId}_option-`. Otherwise, tries to infer from the provided
802
+ * button selector by looking for `_button` suffix.
803
+ */
804
+ async listOptions(buttonSelectorOrBaseId) {
805
+ this.ensureLaunched();
806
+ const page = this.core.getActivePage();
807
+ // Derive base id: "#foo_button" → "foo"
808
+ let baseId = buttonSelectorOrBaseId;
809
+ if (baseId.startsWith('#'))
810
+ baseId = baseId.slice(1);
811
+ if (baseId.endsWith('_button'))
812
+ baseId = baseId.slice(0, -7);
813
+ const list = await page.evaluate(`((base) => {
814
+ const prefix = base + '_option-';
815
+ const els = document.querySelectorAll('[id^="' + prefix + '"]');
816
+ const out = [];
817
+ for (const el of els) {
818
+ const id = el.id;
819
+ if (id.endsWith('_text')) continue;
820
+ out.push({ id, text: (el.textContent || '').trim().slice(0, 200) });
821
+ }
822
+ return out;
823
+ })(${JSON.stringify(baseId)})`);
824
+ return list;
825
+ }
826
+ /**
827
+ * Return Performance API resource entries since `sinceMs` milliseconds
828
+ * ago, optionally filtered by a substring or regex match on the URL.
829
+ */
830
+ async networkSince(sinceMs, pattern) {
831
+ this.ensureLaunched();
832
+ const page = this.core.getActivePage();
833
+ const patternSource = pattern instanceof RegExp ? pattern.source : pattern ?? '';
834
+ const isRegex = pattern instanceof RegExp;
835
+ const results = await page.evaluate(`((sinceMs, patternSource, isRegex) => {
836
+ const re = patternSource ? (isRegex ? new RegExp(patternSource) : null) : null;
837
+ const entries = performance.getEntriesByType('resource');
838
+ const cutoff = performance.now() - sinceMs;
839
+ const out = [];
840
+ for (let i = entries.length - 1; i >= 0; i--) {
841
+ const e = entries[i];
842
+ if (e.startTime < cutoff) break;
843
+ if (patternSource) {
844
+ const match = re ? re.test(e.name) : e.name.includes(patternSource);
845
+ if (!match) continue;
846
+ }
847
+ out.push({
848
+ name: e.name,
849
+ startTime: Math.round(e.startTime),
850
+ durationMs: Math.round(e.duration),
851
+ });
852
+ }
853
+ return out.reverse();
854
+ })(${JSON.stringify(sinceMs)}, ${JSON.stringify(patternSource)}, ${JSON.stringify(isRegex)})`);
855
+ return results;
856
+ }
857
+ /**
858
+ * Boolean convenience: did a network request matching `pattern` fire
859
+ * in the last `sinceMs` milliseconds? Critical for "did my submit
860
+ * actually reach the server?" diagnostics.
861
+ */
862
+ async didRequestFireSince(pattern, sinceMs) {
863
+ const matches = await this.networkSince(sinceMs, pattern);
864
+ return matches.length > 0;
865
+ }
866
+ /**
867
+ * Proactively hide fixed/sticky overlays that commonly block clicks:
868
+ * chat widgets, cookie banners, "we value your feedback" modals,
869
+ * cookie consent, newsletter signups. Returns the count of hidden
870
+ * elements and a list of CSS selectors that were affected.
871
+ */
872
+ async dismissOverlays() {
873
+ this.ensureLaunched();
874
+ const page = this.core.getActivePage();
875
+ return page.evaluate(`(() => {
876
+ const PATTERNS = [
877
+ '[class*="chat-widget"]', '[class*="ChatWidget"]', '[class*="chat-bubble"]',
878
+ '[class*="intercom"]', '[id*="intercom"]',
879
+ '[class*="drift-frame"]', '[class*="zendesk"]', '[class*="zopim"]',
880
+ '[class*="cookie-banner"]', '[class*="cookie-consent"]', '[class*="cookieBanner"]',
881
+ '[class*="consent-banner"]', '[id*="cookie"]',
882
+ '[class*="onetrust"]', '[id*="onetrust"]',
883
+ '[class*="newsletter-modal"]', '[class*="newsletter-popup"]',
884
+ '[class*="feedback-widget"]', '[id*="medallia"]', '[class*="medallia"]',
885
+ '[class*="notification-banner"]',
886
+ ];
887
+ const hiddenSelectors = [];
888
+ let count = 0;
889
+ for (const sel of PATTERNS) {
890
+ const els = document.querySelectorAll(sel);
891
+ for (const el of els) {
892
+ const style = window.getComputedStyle(el);
893
+ if (style.position === 'fixed' || style.position === 'sticky' || style.position === 'absolute') {
894
+ el.style.setProperty('display', 'none', 'important');
895
+ count++;
896
+ }
897
+ }
898
+ if (count > 0) hiddenSelectors.push(sel);
899
+ }
900
+ return { hidden: count, selectors: hiddenSelectors };
901
+ })()`);
902
+ }
903
+ // ── Iframe Support ──
904
+ async frame(selector) {
905
+ this.ensureLaunched();
906
+ const frame = await this.core.getFrame(selector);
907
+ return new BlackTipFrame(frame, this.engine, this.finder, this.logger);
908
+ }
909
+ async frames() {
910
+ this.ensureLaunched();
911
+ return this.core.getFrames();
912
+ }
913
+ // ── Tab Management ──
914
+ async getTabs() {
915
+ return this.core.getTabs();
916
+ }
917
+ async newTab(url) {
918
+ return this.core.newTab(url);
919
+ }
920
+ async switchTab(index) {
921
+ return this.core.switchTab(index);
922
+ }
923
+ async closeTab(index) {
924
+ return this.core.closeTab(index);
925
+ }
926
+ // ── Session Management ──
927
+ async newContext() {
928
+ return this.core.newContext();
929
+ }
930
+ async cookies() {
931
+ return this.core.cookies();
932
+ }
933
+ async setCookies(cookies) {
934
+ return this.core.setCookies(cookies);
935
+ }
936
+ async clearCookies() {
937
+ return this.core.clearCookies();
938
+ }
939
+ // ── JavaScript Execution ──
940
+ async executeJS(script) {
941
+ this.ensureLaunched();
942
+ return this.core.executeJS(script);
943
+ }
944
+ // ── Profile Management ──
945
+ createProfile(name, config) {
946
+ const base = HUMAN_PROFILE;
947
+ this.customProfiles.set(name, { ...base, ...config });
948
+ }
949
+ getProfile(name) {
950
+ return this.resolveProfile(name);
951
+ }
952
+ listProfiles() {
953
+ return ['human', 'scraper', ...this.customProfiles.keys()];
954
+ }
955
+ deleteProfile(name) {
956
+ if (name === 'human' || name === 'scraper') {
957
+ throw new Error('Cannot delete built-in profiles');
958
+ }
959
+ this.customProfiles.delete(name);
960
+ }
961
+ // ── Pool Factory ──
962
+ /**
963
+ * Returns detailed usage instructions for AI agents.
964
+ * Call this on first use to understand how to drive BlackTip correctly.
965
+ */
966
+ static agentGuide() {
967
+ return `
968
+ BlackTip Agent Guide
969
+ ====================
970
+
971
+ BlackTip is a stealth browser instrument. YOU are the agent — BlackTip provides
972
+ the hands, you provide the brain. Every action is wrapped in human-like behavior
973
+ that defeats bot detection.
974
+
975
+ CRITICAL RULES:
976
+
977
+ 1. READ INPUT DOCUMENTS FIRST — If the user gives you a PDF/file to submit,
978
+ read it BEFORE starting the browser. Extract names, dates, codes. Never guess.
979
+
980
+ 2. SCREENSHOT BEFORE EVERY DECISION — After each action, take a screenshot
981
+ (bt.screenshot({path:'shot.png'})) and examine it before deciding the next
982
+ step. Do NOT pre-script sequences.
983
+
984
+ 3. USE clickText() FOR VISIBLE TEXT — bt.clickText("Submit", {nth: 0}) uses
985
+ Playwright's locator API. Works with React, Angular, Okta, and custom
986
+ components. Prefer this over bt.click() with CSS selectors when the text
987
+ is visible on the page.
988
+
989
+ 4. USE paste:true FOR FORMS — bt.type(selector, text, {paste: true}) is fast
990
+ and works with React/Angular synthetic events. Use this for form filling.
991
+
992
+ 5. USE executeJS() TO INSPECT — When selectors are unclear, inspect the DOM:
993
+ await bt.executeJS("JSON.stringify([...document.querySelectorAll('button')].map(b=>b.textContent.trim()))")
994
+
995
+ 6. ANGULAR/CUSTOM DROPDOWNS — These are NOT <select> elements. Pattern:
996
+ - Click the combobox button: bt.click("#dropdown_button")
997
+ - Inspect options via executeJS
998
+ - Click the option: bt.click("#dropdown_option-0")
999
+
1000
+ 7. FAIL FAST — Use timeout: 10000 and retryAttempts: 2. If an action fails,
1001
+ inspect the page rather than retrying for minutes.
1002
+
1003
+ 8. ASK THE USER WHEN UNCERTAIN — Don't guess patient names, account types, or
1004
+ form selections. Ask.
1005
+
1006
+ SERVER MODE (recommended):
1007
+ const bt = new BlackTip({ timeout: 10000, retryAttempts: 2 });
1008
+ await bt.serve(9779);
1009
+ // Then send commands via TCP: bt.send("await bt.navigate('...')")
1010
+
1011
+ AVAILABLE METHODS:
1012
+ bt.navigate(url) — Go to URL
1013
+ bt.click(selector) — Click by CSS/XPath
1014
+ bt.clickText(text, {nth?, exact?}) — Click by visible text
1015
+ bt.clickRole(role, {name?}) — Click by ARIA role
1016
+ bt.type(selector, text, {paste?}) — Type into input
1017
+ bt.scroll({direction, amount}) — Scroll page
1018
+ bt.screenshot({path}) — Capture page
1019
+ bt.waitFor(selector, {timeout}) — Wait for element
1020
+ bt.extractText(selector) — Get text content
1021
+ bt.executeJS(script) — Run JS in page
1022
+ bt.uploadFile(selector, path) — Upload file
1023
+ bt.frame(selector) — Iframe context
1024
+ bt.serve(port) — Start TCP server
1025
+
1026
+ COMMON MISTAKES (don't repeat):
1027
+ - Pre-scripting entire flows (breaks on first unexpected state)
1028
+ - Using executeJS("el.click()") for React/Okta buttons (use clickText)
1029
+ - Not reading screenshots between actions
1030
+ - Guessing form values instead of reading source documents
1031
+ - Long timeouts (30s+) — fail fast, inspect, adapt
1032
+ `.trim();
1033
+ }
1034
+ static async pool(count, config) {
1035
+ const instances = [];
1036
+ for (let i = 0; i < count; i++) {
1037
+ const bt = new BlackTip(config);
1038
+ await bt.launch();
1039
+ instances.push(bt);
1040
+ }
1041
+ return instances;
1042
+ }
1043
+ // ── Server Mode ──
1044
+ /**
1045
+ * Start a TCP command server. Agents connect and send JS commands that
1046
+ * execute with `bt` in scope. Each command returns a result and saves
1047
+ * a screenshot to `screenshotPath`.
1048
+ *
1049
+ * Usage from CLI: node -e "net.createConnection(port).write('await bt.click(\"#btn\")\n__END__\n')"
1050
+ * Or use the built-in CLI: npx blacktip serve
1051
+ */
1052
+ async serve(port = 9779, screenshotPath = 'shot.png') {
1053
+ if (!this.launched)
1054
+ await this.launch();
1055
+ const DELIM = '\n__END__\n';
1056
+ const bt = this;
1057
+ // Pause registry: when a command inside fn() calls bt.pauseForInput(),
1058
+ // it registers a pending entry and returns a promise that resolves
1059
+ // when a subsequent RESUME command provides the value. The serve
1060
+ // handler listens for 'btPause' events (emitted by pauseForInput)
1061
+ // and forwards them to the socket so the client knows to prompt the
1062
+ // user.
1063
+ const pending = new Map();
1064
+ bt._pauseRegistry = pending;
1065
+ const server = createServer((socket) => {
1066
+ let buf = '';
1067
+ socket.on('data', (chunk) => {
1068
+ buf += chunk.toString();
1069
+ while (buf.includes(DELIM)) {
1070
+ const idx = buf.indexOf(DELIM);
1071
+ const cmd = buf.slice(0, idx);
1072
+ buf = buf.slice(idx + DELIM.length);
1073
+ void handleCommand(cmd, socket);
1074
+ }
1075
+ });
1076
+ });
1077
+ async function buildBundle(result, startedAt, includeScreenshot) {
1078
+ const bundle = {
1079
+ ok: true,
1080
+ durationMs: Date.now() - startedAt,
1081
+ };
1082
+ if (result !== undefined)
1083
+ bundle.result = result;
1084
+ try {
1085
+ const page = bt.core.getActivePage();
1086
+ bundle.url = page.url();
1087
+ try {
1088
+ bundle.title = await page.title();
1089
+ }
1090
+ catch { /* title may fail on about:blank */ }
1091
+ }
1092
+ catch { /* browser may be closed */ }
1093
+ if (includeScreenshot) {
1094
+ try {
1095
+ const shot = await bt.screenshot({ path: screenshotPath });
1096
+ bundle.screenshotPath = screenshotPath;
1097
+ bundle.screenshotB64 = shot.data.toString('base64');
1098
+ bundle.screenshotBytes = shot.data.length;
1099
+ }
1100
+ catch { /* screenshot may fail */ }
1101
+ }
1102
+ return bundle;
1103
+ }
1104
+ async function handleCommand(cmd, socket) {
1105
+ const startedAt = Date.now();
1106
+ try {
1107
+ if (cmd === 'QUIT') {
1108
+ socket.write('bye' + DELIM);
1109
+ await bt.close();
1110
+ server.close();
1111
+ return;
1112
+ }
1113
+ // RESUME protocol: "RESUME <id>\n<value>" resolves a pending pause.
1114
+ if (cmd.startsWith('RESUME ')) {
1115
+ const rest = cmd.slice(7);
1116
+ const newlineIdx = rest.indexOf('\n');
1117
+ const id = newlineIdx === -1 ? rest.trim() : rest.slice(0, newlineIdx).trim();
1118
+ const value = newlineIdx === -1 ? '' : rest.slice(newlineIdx + 1);
1119
+ const entry = pending.get(id);
1120
+ if (entry) {
1121
+ pending.delete(id);
1122
+ entry.resolve(value);
1123
+ socket.write(JSON.stringify({ ok: true, resumed: id }) + DELIM);
1124
+ }
1125
+ else {
1126
+ socket.write(JSON.stringify({ ok: false, error: `No pending pause with id ${id}` }) + DELIM);
1127
+ }
1128
+ return;
1129
+ }
1130
+ // LIST_PENDING protocol: returns current paused commands.
1131
+ if (cmd === 'LIST_PENDING') {
1132
+ const list = [...pending.entries()].map(([id, e]) => ({ id, prompt: e.prompt }));
1133
+ socket.write(JSON.stringify({ ok: true, pending: list }) + DELIM);
1134
+ return;
1135
+ }
1136
+ // BATCH protocol: "BATCH\n<json array of command strings>" runs
1137
+ // each command sequentially and returns an array of bundles.
1138
+ if (cmd.startsWith('BATCH\n')) {
1139
+ const jsonPart = cmd.slice(6);
1140
+ let commands;
1141
+ try {
1142
+ commands = JSON.parse(jsonPart);
1143
+ }
1144
+ catch {
1145
+ socket.write(JSON.stringify({ ok: false, error: 'BATCH payload must be a JSON array of command strings' }) + DELIM);
1146
+ return;
1147
+ }
1148
+ const bundles = [];
1149
+ for (const c of commands) {
1150
+ const perStart = Date.now();
1151
+ try {
1152
+ const fn = new Function('bt', `return (async () => { ${c} })();`);
1153
+ const result = await fn(bt);
1154
+ bundles.push(await buildBundle(result, perStart, true));
1155
+ }
1156
+ catch (e) {
1157
+ const msg = e instanceof Error ? e.message : String(e);
1158
+ const errBundle = await buildBundle(undefined, perStart, true);
1159
+ errBundle.ok = false;
1160
+ errBundle.error = msg;
1161
+ bundles.push(errBundle);
1162
+ // Stop on first failure — caller can see what failed.
1163
+ break;
1164
+ }
1165
+ }
1166
+ socket.write(JSON.stringify({ ok: true, bundles }) + DELIM);
1167
+ return;
1168
+ }
1169
+ // Listen for pause events emitted by pauseForInput during the
1170
+ // command's execution. Forward them to the client as separate
1171
+ // JSON frames; the client sends RESUME to continue.
1172
+ const pauseListener = (info) => {
1173
+ socket.write(JSON.stringify({
1174
+ ok: true,
1175
+ paused: true,
1176
+ pauseId: info.id,
1177
+ prompt: info.prompt,
1178
+ }) + DELIM);
1179
+ };
1180
+ bt.on('btPause', pauseListener);
1181
+ try {
1182
+ const fn = new Function('bt', `return (async () => { ${cmd} })();`);
1183
+ const result = await fn(bt);
1184
+ const bundle = await buildBundle(result, startedAt, true);
1185
+ socket.write(JSON.stringify(bundle) + DELIM);
1186
+ }
1187
+ finally {
1188
+ bt.off('btPause', pauseListener);
1189
+ }
1190
+ }
1191
+ catch (e) {
1192
+ const msg = e instanceof Error ? e.message : String(e);
1193
+ const errBundle = await buildBundle(undefined, startedAt, true)
1194
+ .catch(() => ({ ok: false }));
1195
+ errBundle.ok = false;
1196
+ errBundle.error = msg;
1197
+ socket.write(JSON.stringify(errBundle) + DELIM);
1198
+ }
1199
+ }
1200
+ return new Promise((resolve) => {
1201
+ server.listen(port, '127.0.0.1', () => {
1202
+ this.logger.info(`BlackTip server listening on port ${port}`);
1203
+ resolve(server);
1204
+ });
1205
+ });
1206
+ }
1207
+ /**
1208
+ * Pause execution inside a command and wait for a value to be sent via
1209
+ * the RESUME protocol. Only usable when running under serve mode.
1210
+ *
1211
+ * Usage (from agent side):
1212
+ * const value = await bt.pauseForInput({ prompt: "Enter SMS code" });
1213
+ * await bt.type('input[name="credentials.passcode"]', value, { paste: true });
1214
+ *
1215
+ * The serve mode forwards a `{paused:true, pauseId, prompt}` frame to
1216
+ * the client. When the client sends `RESUME <id>\n<value>`, this call
1217
+ * resolves with the value. If `validate` is provided and the value
1218
+ * doesn't match, the call rejects with a validation error.
1219
+ */
1220
+ async pauseForInput(options) {
1221
+ const registry = this._pauseRegistry;
1222
+ if (!registry) {
1223
+ throw new Error('pauseForInput can only be called while running under serve mode');
1224
+ }
1225
+ const id = `pause-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
1226
+ const waitPromise = new Promise((resolve, reject) => {
1227
+ registry.set(id, { resolve, reject, prompt: options.prompt });
1228
+ if (options.timeoutMs) {
1229
+ setTimeout(() => {
1230
+ if (registry.has(id)) {
1231
+ registry.delete(id);
1232
+ reject(new Error(`pauseForInput timed out after ${options.timeoutMs}ms`));
1233
+ }
1234
+ }, options.timeoutMs);
1235
+ }
1236
+ });
1237
+ // Signal the serve handler to forward a pause frame to the client.
1238
+ this.emit('btPause', { id, prompt: options.prompt });
1239
+ const value = await waitPromise;
1240
+ // Validate if requested.
1241
+ if (options.validate) {
1242
+ const valid = typeof options.validate === 'function'
1243
+ ? options.validate(value)
1244
+ : options.validate.test(value);
1245
+ if (!valid) {
1246
+ throw new Error(`pauseForInput received invalid value: ${value}`);
1247
+ }
1248
+ }
1249
+ return value;
1250
+ }
1251
+ // ── Internal: Action Execution with Retry ──
1252
+ async executeAction(actionName, target, fn, value, importance) {
1253
+ this.ensureLaunched();
1254
+ const maxAttempts = this.config.retryAttempts ?? 5;
1255
+ const start = Date.now();
1256
+ let lastError = '';
1257
+ // Pre-action pause — scaled by the caller's importance hint so
1258
+ // submit/payment/confirm actions get the long-tail hesitation that
1259
+ // behavioral biometrics systems expect to see.
1260
+ const preActionPause = this.engine.generatePreActionPause(importance ?? 'normal');
1261
+ await this.sleep(preActionPause);
1262
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1263
+ const strategy = RETRY_STRATEGIES[Math.min(attempt - 1, RETRY_STRATEGIES.length - 1)];
1264
+ try {
1265
+ // Apply retry strategy
1266
+ if (attempt > 1) {
1267
+ await this.applyRetryStrategy(strategy);
1268
+ this.emit('retry', {
1269
+ timestamp: new Date().toISOString(),
1270
+ action: actionName,
1271
+ target,
1272
+ attempt,
1273
+ maxAttempts,
1274
+ strategy,
1275
+ error: lastError,
1276
+ });
1277
+ }
1278
+ const behavioral = await fn();
1279
+ const duration = Date.now() - start;
1280
+ const event = {
1281
+ timestamp: new Date().toISOString(),
1282
+ action: actionName,
1283
+ target,
1284
+ value,
1285
+ outcome: 'success',
1286
+ duration,
1287
+ retries: attempt - 1,
1288
+ behavioral: {
1289
+ preActionPause,
1290
+ ...behavioral,
1291
+ },
1292
+ };
1293
+ this.emit('action', event);
1294
+ return {
1295
+ success: true,
1296
+ duration,
1297
+ retries: attempt - 1,
1298
+ };
1299
+ }
1300
+ catch (err) {
1301
+ lastError = err instanceof Error ? err.message : String(err);
1302
+ this.logger.warn(`Action ${actionName} failed (attempt ${attempt}/${maxAttempts})`, {
1303
+ target,
1304
+ error: lastError,
1305
+ strategy,
1306
+ });
1307
+ }
1308
+ }
1309
+ // All retries exhausted
1310
+ const duration = Date.now() - start;
1311
+ const page = this.core.getActivePage();
1312
+ let screenshot;
1313
+ try {
1314
+ screenshot = await page.screenshot();
1315
+ }
1316
+ catch { /* screenshot may fail if browser crashed */ }
1317
+ const errorEvent = {
1318
+ timestamp: new Date().toISOString(),
1319
+ code: 'ELEMENT_NOT_FOUND',
1320
+ message: lastError,
1321
+ url: page.url(),
1322
+ action: actionName,
1323
+ attempts: maxAttempts,
1324
+ screenshot,
1325
+ };
1326
+ this.emit('error', errorEvent);
1327
+ const event = {
1328
+ timestamp: new Date().toISOString(),
1329
+ action: actionName,
1330
+ target,
1331
+ value,
1332
+ outcome: 'failure',
1333
+ duration,
1334
+ retries: maxAttempts - 1,
1335
+ error: lastError,
1336
+ };
1337
+ this.emit('action', event);
1338
+ return {
1339
+ success: false,
1340
+ duration,
1341
+ retries: maxAttempts - 1,
1342
+ error: lastError,
1343
+ errorCode: 'ELEMENT_NOT_FOUND',
1344
+ };
1345
+ }
1346
+ async applyRetryStrategy(strategy) {
1347
+ const page = this.core.getActivePage();
1348
+ switch (strategy) {
1349
+ case 'standard':
1350
+ // Just retry
1351
+ break;
1352
+ case 'wait':
1353
+ await this.sleep(2000 + Math.random() * 3000);
1354
+ break;
1355
+ case 'reload':
1356
+ await page.reload({ waitUntil: 'domcontentloaded' });
1357
+ await this.sleep(1000);
1358
+ break;
1359
+ case 'altSelector':
1360
+ // The element finder will try alternative strategies on next attempt
1361
+ break;
1362
+ case 'scroll':
1363
+ await page.mouse.wheel(0, 300);
1364
+ await this.sleep(500);
1365
+ break;
1366
+ case 'clearOverlays':
1367
+ // Try to dismiss common overlays
1368
+ await page.evaluate(`(() => {
1369
+ const overlays = document.querySelectorAll('[class*="overlay"], [class*="modal"], [class*="popup"], [class*="cookie"], [class*="banner"]');
1370
+ overlays.forEach((el) => {
1371
+ const style = window.getComputedStyle(el);
1372
+ if (style.position === 'fixed' || style.position === 'sticky') {
1373
+ el.style.display = 'none';
1374
+ }
1375
+ });
1376
+ })()`);
1377
+ await this.sleep(500);
1378
+ break;
1379
+ }
1380
+ }
1381
+ // ── Internal: Mouse Movement ──
1382
+ mouseX = 0;
1383
+ mouseY = 0;
1384
+ async getMousePosition() {
1385
+ return { x: this.mouseX, y: this.mouseY };
1386
+ }
1387
+ async performMouseMove(from, to) {
1388
+ const page = this.core.getActivePage();
1389
+ const steps = this.engine.generateMousePath(from, to);
1390
+ const moveStart = Date.now();
1391
+ let pathLength = 0;
1392
+ let prevPoint = from;
1393
+ for (const step of steps) {
1394
+ await this.sleep(step.delay);
1395
+ await page.mouse.move(step.x, step.y);
1396
+ // Calculate path length
1397
+ const dx = step.x - prevPoint.x;
1398
+ const dy = step.y - prevPoint.y;
1399
+ pathLength += Math.sqrt(dx * dx + dy * dy);
1400
+ prevPoint = step;
1401
+ }
1402
+ this.mouseX = to.x;
1403
+ this.mouseY = to.y;
1404
+ return {
1405
+ mousePathLength: Math.round(pathLength),
1406
+ mouseMoveDuration: Date.now() - moveStart,
1407
+ };
1408
+ }
1409
+ // ── Internal: Utilities ──
1410
+ resolveProfile(nameOrConfig) {
1411
+ if (typeof nameOrConfig === 'object')
1412
+ return nameOrConfig;
1413
+ switch (nameOrConfig) {
1414
+ case 'human': return HUMAN_PROFILE;
1415
+ case 'scraper': return SCRAPER_PROFILE;
1416
+ default: {
1417
+ const custom = this.customProfiles.get(nameOrConfig);
1418
+ if (custom)
1419
+ return custom;
1420
+ this.logger.warn(`Unknown profile "${nameOrConfig}", falling back to "human"`);
1421
+ return HUMAN_PROFILE;
1422
+ }
1423
+ }
1424
+ }
1425
+ ensureLaunched() {
1426
+ if (!this.launched) {
1427
+ throw new Error('Browser not launched. Call launch() first.');
1428
+ }
1429
+ }
1430
+ sleep(ms) {
1431
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.round(ms))));
1432
+ }
1433
+ }
1434
+ /**
1435
+ * Frame context — same action methods as BlackTip, scoped to an iframe.
1436
+ */
1437
+ export class BlackTipFrame {
1438
+ frame;
1439
+ engine;
1440
+ finder;
1441
+ logger;
1442
+ constructor(frame, engine, finder, logger) {
1443
+ this.frame = frame;
1444
+ this.engine = engine;
1445
+ this.finder = finder;
1446
+ this.logger = logger;
1447
+ }
1448
+ async click(selector, options) {
1449
+ const start = Date.now();
1450
+ const element = await this.finder.find(this.frame, selector, { visible: true });
1451
+ const box = await this.finder.getBoundingBox(element);
1452
+ if (!box)
1453
+ throw new Error('Element has no bounding box');
1454
+ const targetPos = this.engine.generateClickPosition(box);
1455
+ // Human-like pause before click
1456
+ await this.sleep(this.engine.generatePreActionPause());
1457
+ const dwell = this.engine.generateClickDwell();
1458
+ await this.sleep(dwell);
1459
+ await element.click({
1460
+ button: options?.button ?? 'left',
1461
+ clickCount: options?.count ?? 1,
1462
+ });
1463
+ await this.sleep(this.engine.generatePostActionPause());
1464
+ return { success: true, duration: Date.now() - start, retries: 0 };
1465
+ }
1466
+ async type(selector, text, options) {
1467
+ const start = Date.now();
1468
+ const element = await this.finder.find(this.frame, selector, { visible: true });
1469
+ // Click element first to give it focus
1470
+ await element.click();
1471
+ await this.sleep(this.engine.generatePreActionPause() * 0.3);
1472
+ // Ported L001 fix from BlackTip.type(): use Control+A+Backspace via the
1473
+ // page-level keyboard (rather than element.fill('') which can fire a
1474
+ // premature change event in some frameworks), and use the frame's
1475
+ // native keyboard.type which dispatches the full keydown/keypress/
1476
+ // input/keyup cycle — the input event is what React/Angular listen
1477
+ // for inside iframes like Stripe Elements and Braintree Hosted Fields.
1478
+ const pageForKeyboard = this.frame.page();
1479
+ if (options?.clearFirst) {
1480
+ await pageForKeyboard.keyboard.press('Control+a');
1481
+ await this.sleep(30 + Math.random() * 50);
1482
+ await pageForKeyboard.keyboard.press('Backspace');
1483
+ await this.sleep(50);
1484
+ }
1485
+ const shouldPaste = options?.paste ?? this.engine.shouldPaste(text);
1486
+ if (shouldPaste) {
1487
+ // Fill path: Playwright's fill correctly dispatches input events on
1488
+ // form controls, even inside cross-origin iframes. Fast path for
1489
+ // paste-threshold text.
1490
+ await element.fill(text);
1491
+ await this.sleep(100 + Math.random() * 200);
1492
+ }
1493
+ else {
1494
+ // Keystroke path: clear via Control+A+Backspace (framework-safe) then
1495
+ // use page.keyboard.type which fires the full event cycle per char.
1496
+ await pageForKeyboard.keyboard.press('Control+a');
1497
+ await this.sleep(30 + Math.random() * 50);
1498
+ await pageForKeyboard.keyboard.press('Backspace');
1499
+ await this.sleep(50);
1500
+ const sequence = this.engine.generateTypingSequence(text);
1501
+ for (const keystroke of sequence) {
1502
+ if (keystroke.isTypo && keystroke.correctionSequence) {
1503
+ await pageForKeyboard.keyboard.type(keystroke.key, { delay: keystroke.holdDuration });
1504
+ for (const correction of keystroke.correctionSequence) {
1505
+ await this.sleep(correction.delay);
1506
+ if (correction.key === 'Backspace') {
1507
+ await pageForKeyboard.keyboard.press('Backspace');
1508
+ }
1509
+ else {
1510
+ await pageForKeyboard.keyboard.type(correction.key, { delay: correction.holdDuration });
1511
+ }
1512
+ }
1513
+ }
1514
+ else {
1515
+ await this.sleep(keystroke.delay);
1516
+ await pageForKeyboard.keyboard.type(keystroke.key, { delay: keystroke.holdDuration });
1517
+ }
1518
+ }
1519
+ // Verify value — fall back to fill() if framework didn't register.
1520
+ // Same safety net as BlackTip.type(): if Stripe's element.js intercepts
1521
+ // keyboard events and doesn't update the underlying input, fill()
1522
+ // forces the value through via the DOM setter.
1523
+ try {
1524
+ const val = await element.inputValue();
1525
+ if (val !== text)
1526
+ await element.fill(text);
1527
+ }
1528
+ catch { /* non-input elements */ }
1529
+ }
1530
+ if (options?.pressEnter) {
1531
+ await this.sleep(200 + Math.random() * 300);
1532
+ await element.press('Enter');
1533
+ }
1534
+ return { success: true, duration: Date.now() - start, retries: 0 };
1535
+ }
1536
+ async extractText(selector) {
1537
+ const element = await this.finder.find(this.frame, selector);
1538
+ return element.innerText();
1539
+ }
1540
+ async hover(selector) {
1541
+ const start = Date.now();
1542
+ const element = await this.finder.find(this.frame, selector, { visible: true });
1543
+ await this.sleep(this.engine.generatePreActionPause());
1544
+ await element.hover();
1545
+ await this.sleep(this.engine.generateClickDwell());
1546
+ return { success: true, duration: Date.now() - start, retries: 0 };
1547
+ }
1548
+ async select(selector, value) {
1549
+ const start = Date.now();
1550
+ await this.frame.selectOption(selector, { value, label: value });
1551
+ return { success: true, duration: Date.now() - start, retries: 0 };
1552
+ }
1553
+ async waitFor(selector, options) {
1554
+ const start = Date.now();
1555
+ try {
1556
+ await this.frame.waitForSelector(selector, {
1557
+ timeout: options?.timeout ?? 30000,
1558
+ state: options?.visible !== false ? 'visible' : 'attached',
1559
+ });
1560
+ return { success: true, duration: Date.now() - start };
1561
+ }
1562
+ catch (err) {
1563
+ return {
1564
+ success: false,
1565
+ duration: Date.now() - start,
1566
+ error: err instanceof Error ? err.message : String(err),
1567
+ };
1568
+ }
1569
+ }
1570
+ sleep(ms) {
1571
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.round(ms))));
1572
+ }
1573
+ }
1574
+ //# sourceMappingURL=blacktip.js.map