stealth-browser-supplement.test.js
1 /** 2 * Supplemental tests for stealth-browser.js — covers uncovered functions 3 * 4 * Targets: 5 * - randomDelay: timing bounds 6 * - generateBezierWaypoints (via humanMouseMove): produces valid points 7 * - humanMouseMove: moves mouse through waypoints 8 * - humanScroll: viewport/short/numeric scroll + smooth/non-smooth 9 * - humanClick: click with bounding box + without bounding box 10 * - humanType: short text (pressSequentially) + long text (fill) 11 * - waitForCloudflare: pass, blocked-then-pass, timeout, error 12 * - createStealthContext: context creation with options 13 * - launchStealthBrowser: launch with env vars 14 * - configureNopeCHA: no-op stub 15 * - createPersistentContext: returns context+page+profileLoaded 16 * - detectChromiumPath (indirectly via launchStealthBrowser) 17 * 18 * Does NOT launch real browsers — all playwright calls are mocked. 19 * 20 * NOTE: requires --experimental-test-module-mocks 21 */ 22 23 import { test, describe, beforeEach, afterEach, after, mock } from 'node:test'; 24 import assert from 'node:assert/strict'; 25 import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; 26 import { join } from 'path'; 27 import { tmpdir } from 'os'; 28 29 // Set up temp profiles dir BEFORE importing stealth-browser 30 const TEMP_PROFILES_DIR = join(tmpdir(), `stealth-browser-supp-${process.pid}`); 31 process.env.BROWSER_PROFILES_DIR = TEMP_PROFILES_DIR; 32 process.env.LOGS_DIR = '/tmp/test-logs'; 33 34 // ── Mock playwright-extra and dependencies ─────────────────────────────────── 35 36 let mockBrowserLaunch; 37 let mockNewContext; 38 let mockNewPage; 39 40 // Default mock page 41 function createMockPage(overrides = {}) { 42 return { 43 goto: async () => {}, 44 evaluate: async (fn, ...args) => { 45 // Return sensible defaults based on what's being evaluated 46 if (typeof fn === 'function') { 47 // Can't actually run browser-context functions, return defaults 48 return { x: 100, y: 100 }; 49 } 50 return {}; 51 }, 52 close: async () => {}, 53 mouse: { 54 move: async () => {}, 55 }, 56 locator: () => ({ 57 first: () => ({ 58 boundingBox: async () => ({ x: 50, y: 50, width: 100, height: 40 }), 59 click: async () => {}, 60 fill: async () => {}, 61 pressSequentially: async () => {}, 62 }), 63 }), 64 waitForLoadState: async () => {}, 65 context: () => ({ 66 cookies: async () => [], 67 addCookies: async () => {}, 68 }), 69 ...overrides, 70 }; 71 } 72 73 function createMockContext(overrides = {}) { 74 return { 75 newPage: async () => createMockPage(), 76 close: async () => {}, 77 ...overrides, 78 }; 79 } 80 81 function createMockBrowser(overrides = {}) { 82 return { 83 newContext: async opts => { 84 if (mockNewContext) return mockNewContext(opts); 85 return createMockContext(); 86 }, 87 close: async () => {}, 88 ...overrides, 89 }; 90 } 91 92 mockBrowserLaunch = async opts => createMockBrowser(); 93 94 mock.module('playwright-extra', { 95 namedExports: { 96 chromium: { 97 use: () => {}, 98 launch: async opts => mockBrowserLaunch(opts), 99 }, 100 }, 101 }); 102 103 mock.module('puppeteer-extra-plugin-stealth', { 104 defaultExport: () => ({ name: 'stealth' }), 105 }); 106 107 mock.module('user-agents', { 108 defaultExport: class UserAgent { 109 constructor() {} 110 toString() { 111 return 'Mozilla/5.0 (Test Agent)'; 112 } 113 }, 114 }); 115 116 const { 117 randomDelay, 118 humanMouseMove, 119 humanScroll, 120 humanClick, 121 humanType, 122 waitForCloudflare, 123 createStealthContext, 124 launchStealthBrowser, 125 configureNopeCHA, 126 createPersistentContext, 127 isSocialMediaUrl, 128 loadProfile, 129 } = await import('../../src/utils/stealth-browser.js'); 130 131 // ── Cleanup ────────────────────────────────────────────────────────────────── 132 133 after(() => { 134 if (existsSync(TEMP_PROFILES_DIR)) { 135 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 136 } 137 }); 138 139 // ── randomDelay ────────────────────────────────────────────────────────────── 140 141 describe('randomDelay', () => { 142 test('resolves within expected time range', async () => { 143 const start = Date.now(); 144 await randomDelay(10, 50); 145 const elapsed = Date.now() - start; 146 assert.ok(elapsed >= 5, `Should delay at least ~10ms, got ${elapsed}ms`); 147 assert.ok(elapsed < 500, `Should not take excessively long, got ${elapsed}ms`); 148 }); 149 150 test('uses default values when called without arguments', async () => { 151 const start = Date.now(); 152 await randomDelay(); 153 const elapsed = Date.now() - start; 154 // Default is 100-500ms 155 assert.ok(elapsed >= 50, `Should delay at least ~100ms, got ${elapsed}ms`); 156 assert.ok(elapsed < 1000, `Should resolve within 1s, got ${elapsed}ms`); 157 }); 158 }); 159 160 // ── humanMouseMove ─────────────────────────────────────────────────────────── 161 162 describe('humanMouseMove', () => { 163 test('calls page.mouse.move through bezier waypoints', async () => { 164 const movePositions = []; 165 const mockPage = createMockPage({ 166 evaluate: async () => ({ x: 100, y: 100 }), 167 mouse: { 168 move: async (x, y) => { 169 movePositions.push({ x, y }); 170 }, 171 }, 172 }); 173 174 await humanMouseMove(mockPage, 500, 300); 175 176 assert.ok( 177 movePositions.length >= 3, 178 `Should have multiple waypoints, got ${movePositions.length}` 179 ); 180 // Last point should be near the target 181 const last = movePositions[movePositions.length - 1]; 182 assert.ok(Math.abs(last.x - 500) < 5, `Last x should be near 500, got ${last.x}`); 183 assert.ok(Math.abs(last.y - 300) < 5, `Last y should be near 300, got ${last.y}`); 184 }); 185 }); 186 187 // ── humanScroll ────────────────────────────────────────────────────────────── 188 189 describe('humanScroll', () => { 190 test('scrolls with viewport distance (default)', async () => { 191 let evaluateCalls = 0; 192 const mockPage = createMockPage({ 193 evaluate: async (fn, ...args) => { 194 evaluateCalls++; 195 // First call: get viewport height. Second call: do scroll. 196 if (evaluateCalls === 1) return 900; // viewport height 197 return undefined; 198 }, 199 }); 200 201 await humanScroll(mockPage); 202 assert.ok(evaluateCalls >= 2, 'Should call evaluate at least twice (get height + scroll)'); 203 }); 204 205 test('scrolls with "short" distance', async () => { 206 let evaluateCalls = 0; 207 const mockPage = createMockPage({ 208 evaluate: async () => { 209 evaluateCalls++; 210 return 300; 211 }, 212 }); 213 214 await humanScroll(mockPage, { distance: 'short' }); 215 assert.ok(evaluateCalls >= 1, 'Should call evaluate for short scroll'); 216 }); 217 218 test('scrolls with numeric distance', async () => { 219 let evaluateCalls = 0; 220 const mockPage = createMockPage({ 221 evaluate: async () => { 222 evaluateCalls++; 223 return 500; 224 }, 225 }); 226 227 await humanScroll(mockPage, { distance: '500' }); 228 assert.ok(evaluateCalls >= 1); 229 }); 230 231 test('scrolls with smooth=false', async () => { 232 let evaluateCalls = 0; 233 const mockPage = createMockPage({ 234 evaluate: async () => { 235 evaluateCalls++; 236 return 300; 237 }, 238 }); 239 240 await humanScroll(mockPage, { distance: 'short', smooth: false }); 241 assert.ok(evaluateCalls >= 1); 242 }); 243 }); 244 245 // ── humanClick ─────────────────────────────────────────────────────────────── 246 247 describe('humanClick', () => { 248 test('clicks element with bounding box (humanMouseMove + click)', async () => { 249 let clicked = false; 250 let mouseMoved = false; 251 const mockPage = createMockPage({ 252 evaluate: async () => ({ x: 50, y: 50 }), 253 mouse: { 254 move: async () => { 255 mouseMoved = true; 256 }, 257 }, 258 locator: () => ({ 259 first: () => ({ 260 boundingBox: async () => ({ x: 50, y: 50, width: 100, height: 40 }), 261 click: async () => { 262 clicked = true; 263 }, 264 }), 265 }), 266 }); 267 268 await humanClick(mockPage, '#submit'); 269 assert.ok(clicked, 'Should click the element'); 270 assert.ok(mouseMoved, 'Should move mouse before clicking'); 271 }); 272 273 test('clicks element without bounding box (null box)', async () => { 274 let clicked = false; 275 const mockPage = createMockPage({ 276 evaluate: async () => ({ x: 0, y: 0 }), 277 mouse: { move: async () => {} }, 278 locator: () => ({ 279 first: () => ({ 280 boundingBox: async () => null, 281 click: async () => { 282 clicked = true; 283 }, 284 }), 285 }), 286 }); 287 288 await humanClick(mockPage, '#hidden-btn'); 289 assert.ok(clicked, 'Should still click even without bounding box'); 290 }); 291 }); 292 293 // ── humanType ──────────────────────────────────────────────────────────────── 294 295 describe('humanType', () => { 296 test('short text uses pressSequentially', async () => { 297 let usedPressSequentially = false; 298 let usedFill = false; 299 const mockPage = createMockPage({ 300 evaluate: async () => ({ x: 100, y: 100 }), 301 mouse: { move: async () => {} }, 302 locator: () => ({ 303 first: () => ({ 304 boundingBox: async () => ({ x: 50, y: 50, width: 200, height: 30 }), 305 click: async () => {}, 306 fill: async () => { 307 usedFill = true; 308 }, 309 pressSequentially: async () => { 310 usedPressSequentially = true; 311 }, 312 }), 313 }), 314 }); 315 316 await humanType(mockPage, '#name', 'John Smith'); 317 assert.ok(usedPressSequentially, 'Short text should use pressSequentially'); 318 assert.ok(!usedFill, 'Short text should NOT use fill'); 319 }); 320 321 test('long text (>50 chars) uses fill instead', async () => { 322 let usedPressSequentially = false; 323 let usedFill = false; 324 const longText = 325 'This is a very long proposal text that exceeds fifty characters in length for testing purposes.'; 326 const mockPage = createMockPage({ 327 evaluate: async () => ({ x: 100, y: 100 }), 328 mouse: { move: async () => {} }, 329 locator: () => ({ 330 first: () => ({ 331 boundingBox: async () => ({ x: 50, y: 50, width: 400, height: 100 }), 332 click: async () => {}, 333 fill: async () => { 334 usedFill = true; 335 }, 336 pressSequentially: async () => { 337 usedPressSequentially = true; 338 }, 339 }), 340 }), 341 }); 342 343 await humanType(mockPage, '#proposal', longText); 344 assert.ok(usedFill, 'Long text should use fill'); 345 assert.ok(!usedPressSequentially, 'Long text should NOT use pressSequentially'); 346 }); 347 }); 348 349 // ── waitForCloudflare ──────────────────────────────────────────────────────── 350 351 describe('waitForCloudflare', () => { 352 test('returns true when page is not blocked', async () => { 353 const mockPage = createMockPage({ 354 waitForLoadState: async () => {}, 355 evaluate: async () => false, // not blocked 356 }); 357 358 const result = await waitForCloudflare(mockPage, { timeout: 5000 }); 359 assert.equal(result, true); 360 }); 361 362 test('returns true when evaluate throws (assume accessible)', async () => { 363 let callCount = 0; 364 const mockPage = createMockPage({ 365 waitForLoadState: async () => {}, 366 evaluate: async () => { 367 callCount++; 368 if (callCount <= 1) return undefined; // randomDelay calls 369 throw new Error('Page crashed'); 370 }, 371 }); 372 373 const result = await waitForCloudflare(mockPage, { timeout: 6000, checkInterval: 100 }); 374 assert.equal(result, true, 'Should return true on evaluate error'); 375 }); 376 377 test('returns false when challenge does not resolve within timeout', async () => { 378 const mockPage = createMockPage({ 379 waitForLoadState: async () => {}, 380 evaluate: async () => true, // always blocked 381 }); 382 383 const result = await waitForCloudflare(mockPage, { timeout: 500, checkInterval: 100 }); 384 assert.equal(result, false, 'Should return false when challenge never resolves'); 385 }); 386 387 test('handles waitForLoadState timeout gracefully', async () => { 388 const mockPage = createMockPage({ 389 waitForLoadState: async () => { 390 throw new Error('Navigation timeout'); 391 }, 392 evaluate: async () => false, // not blocked 393 }); 394 395 const result = await waitForCloudflare(mockPage, { timeout: 5000 }); 396 assert.equal(result, true, 'Should proceed even if networkidle fails'); 397 }); 398 }); 399 400 // ── configureNopeCHA ───────────────────────────────────────────────────────── 401 402 describe('configureNopeCHA', () => { 403 test('is a no-op that returns undefined', async () => { 404 const result = await configureNopeCHA({}); 405 assert.equal(result, undefined); 406 }); 407 }); 408 409 // ── launchStealthBrowser ───────────────────────────────────────────────────── 410 411 describe('launchStealthBrowser', () => { 412 test('launches browser with default options', async () => { 413 let launchOpts = null; 414 mockBrowserLaunch = async opts => { 415 launchOpts = opts; 416 return createMockBrowser(); 417 }; 418 419 const browser = await launchStealthBrowser(); 420 assert.ok(browser, 'Should return a browser object'); 421 assert.ok(launchOpts, 'Should have called launch'); 422 assert.equal(launchOpts.headless, true, 'Default headless should be true'); 423 assert.equal(launchOpts.slowMo, 0, 'Default slowMo should be 0'); 424 }); 425 426 test('launches browser with custom options', async () => { 427 let launchOpts = null; 428 mockBrowserLaunch = async opts => { 429 launchOpts = opts; 430 return createMockBrowser(); 431 }; 432 433 await launchStealthBrowser({ headless: false, slowMo: 100, devtools: true }); 434 assert.equal(launchOpts.headless, false); 435 assert.equal(launchOpts.slowMo, 100); 436 assert.equal(launchOpts.devtools, true); 437 }); 438 439 test('uses CHROMIUM_PATH env var when set', async () => { 440 let launchOpts = null; 441 mockBrowserLaunch = async opts => { 442 launchOpts = opts; 443 return createMockBrowser(); 444 }; 445 446 const origPath = process.env.CHROMIUM_PATH; 447 process.env.CHROMIUM_PATH = '/usr/bin/chromium-browser'; 448 449 await launchStealthBrowser(); 450 assert.equal(launchOpts.executablePath, '/usr/bin/chromium-browser'); 451 452 if (origPath) process.env.CHROMIUM_PATH = origPath; 453 else delete process.env.CHROMIUM_PATH; 454 }); 455 }); 456 457 // ── createStealthContext ───────────────────────────────────────────────────── 458 459 describe('createStealthContext', () => { 460 test('creates context with default locale and timezone', async () => { 461 let contextOpts = null; 462 const mockBrowser = { 463 newContext: async opts => { 464 contextOpts = opts; 465 return createMockContext(); 466 }, 467 close: async () => {}, 468 }; 469 470 const origTz = process.env.TIMEZONE; 471 const origLang = process.env.ACCEPT_LANGUAGE; 472 delete process.env.TIMEZONE; 473 delete process.env.ACCEPT_LANGUAGE; 474 475 const ctx = await createStealthContext(mockBrowser); 476 assert.ok(ctx, 'Should return context'); 477 assert.ok(contextOpts.userAgent, 'Should set userAgent'); 478 assert.deepEqual(contextOpts.viewport, { width: 1440, height: 900 }); 479 assert.equal(contextOpts.timezoneId, 'Australia/Sydney'); 480 assert.equal(contextOpts.locale, 'en-AU'); 481 482 if (origTz) process.env.TIMEZONE = origTz; 483 if (origLang) process.env.ACCEPT_LANGUAGE = origLang; 484 }); 485 486 test('respects custom viewport and locale options', async () => { 487 let contextOpts = null; 488 const mockBrowser = { 489 newContext: async opts => { 490 contextOpts = opts; 491 return createMockContext(); 492 }, 493 close: async () => {}, 494 }; 495 496 await createStealthContext(mockBrowser, { 497 viewport: { width: 1920, height: 1080 }, 498 locale: 'en-US', 499 timezoneId: 'America/New_York', 500 }); 501 502 assert.deepEqual(contextOpts.viewport, { width: 1920, height: 1080 }); 503 assert.equal(contextOpts.locale, 'en-US'); 504 assert.equal(contextOpts.timezoneId, 'America/New_York'); 505 }); 506 507 test('uses TIMEZONE and ACCEPT_LANGUAGE env vars', async () => { 508 let contextOpts = null; 509 const mockBrowser = { 510 newContext: async opts => { 511 contextOpts = opts; 512 return createMockContext(); 513 }, 514 close: async () => {}, 515 }; 516 517 const origTz = process.env.TIMEZONE; 518 const origLang = process.env.ACCEPT_LANGUAGE; 519 process.env.TIMEZONE = 'Europe/London'; 520 process.env.ACCEPT_LANGUAGE = 'en-GB,en;q=0.9'; 521 522 await createStealthContext(mockBrowser); 523 524 assert.equal(contextOpts.timezoneId, 'Europe/London'); 525 assert.equal(contextOpts.locale, 'en-GB'); 526 assert.ok(contextOpts.extraHTTPHeaders['Accept-Language'].includes('en-GB')); 527 528 if (origTz) process.env.TIMEZONE = origTz; 529 else delete process.env.TIMEZONE; 530 if (origLang) process.env.ACCEPT_LANGUAGE = origLang; 531 else delete process.env.ACCEPT_LANGUAGE; 532 }); 533 }); 534 535 // ── createPersistentContext ────────────────────────────────────────────────── 536 537 describe('createPersistentContext', () => { 538 beforeEach(() => { 539 if (existsSync(TEMP_PROFILES_DIR)) { 540 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 541 } 542 mkdirSync(TEMP_PROFILES_DIR, { recursive: true }); 543 }); 544 545 afterEach(() => { 546 if (existsSync(TEMP_PROFILES_DIR)) { 547 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 548 } 549 }); 550 551 test('returns context, page, and profileLoaded=false when no profile exists', async () => { 552 const mockBrowser = createMockBrowser({ 553 newContext: async () => 554 createMockContext({ 555 newPage: async () => 556 createMockPage({ 557 context: () => ({ 558 addCookies: async () => {}, 559 cookies: async () => [], 560 }), 561 }), 562 }), 563 }); 564 565 const result = await createPersistentContext(mockBrowser, 'x', 'nonexistent-profile'); 566 assert.ok(result.context, 'Should have context'); 567 assert.ok(result.page, 'Should have page'); 568 assert.equal(result.profileLoaded, false, 'Should not load nonexistent profile'); 569 }); 570 571 test('returns profileLoaded=true when profile exists', async () => { 572 // Create profile with cookies 573 const profileDir = join(TEMP_PROFILES_DIR, 'x', 'test-persistent'); 574 mkdirSync(profileDir, { recursive: true }); 575 writeFileSync(join(profileDir, 'cookies.json'), JSON.stringify([{ name: 'a', value: 'b' }])); 576 writeFileSync( 577 join(profileDir, 'metadata.json'), 578 JSON.stringify({ platform: 'x', last_used_at: '2025-01-01' }) 579 ); 580 581 const mockBrowser = createMockBrowser({ 582 newContext: async () => 583 createMockContext({ 584 newPage: async () => 585 createMockPage({ 586 context: () => ({ 587 addCookies: async () => {}, 588 cookies: async () => [{ name: 'a', value: 'b' }], 589 }), 590 }), 591 }), 592 }); 593 594 const result = await createPersistentContext(mockBrowser, 'x', 'test-persistent'); 595 assert.equal(result.profileLoaded, true, 'Should load existing profile'); 596 }); 597 }); 598 599 // ── isSocialMediaUrl — additional edge cases ───────────────────────────────── 600 601 describe('isSocialMediaUrl — supplemental', () => { 602 test('handles null/undefined gracefully', () => { 603 assert.equal(isSocialMediaUrl(null), false); 604 assert.equal(isSocialMediaUrl(undefined), false); 605 }); 606 607 test('detects pinterest.com is NOT social (not in list)', () => { 608 assert.equal(isSocialMediaUrl('https://pinterest.com/pin/123'), false); 609 }); 610 611 test('detects youtu.be short URL as NOT social (youtube.com only)', () => { 612 assert.equal(isSocialMediaUrl('https://youtu.be/abc123'), false); 613 }); 614 615 test('handles URL with port number', () => { 616 assert.equal(isSocialMediaUrl('https://twitter.com:443/user'), true); 617 }); 618 });