stealth-browser-supplement2.test.js
1 /** 2 * Stealth Browser — Supplement 2 3 * 4 * Covers functions not tested by the existing test files: 5 * - prepareNopeCHAExtension: no key → null, missing srcDir → null, successful prep 6 * - launchWithExtensions: no extension, with extension, close() cleans up dirs 7 * - generateBezierWaypoints (via humanMouseMove): edge cases (same start/end, extremes) 8 * - getNextProfile: LINKEDIN_PROFILE_COUNT env var 9 * - listProfiles: multiple profiles across same platform 10 * - loadProfile: profile without metadata (no last_used_at update needed) 11 * - createStealthContext: ACCEPT_LANGUAGE parsing (first locale segment) 12 * - detectChromiumPath: via CHROMIUM_PATH env var override 13 * 14 * Uses full mock for playwright, puppeteer-extra-plugin-stealth, user-agents, 15 * child_process (execSync for `which chromium`), and fs for extension prep. 16 * 17 * NOTE: requires --experimental-test-module-mocks 18 */ 19 20 import { test, describe, beforeEach, afterEach, after, mock } from 'node:test'; 21 import assert from 'node:assert/strict'; 22 import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs'; 23 import { join } from 'path'; 24 import { tmpdir } from 'os'; 25 26 // ── Set up temp dirs BEFORE import ─────────────────────────────────────────── 27 28 const TEMP_PROFILES_DIR = join(tmpdir(), `stealth-browser-supp2-${process.pid}`); 29 process.env.BROWSER_PROFILES_DIR = TEMP_PROFILES_DIR; 30 mkdirSync(TEMP_PROFILES_DIR, { recursive: true }); 31 32 // ── Mutable launch state ───────────────────────────────────────────────────── 33 34 let lastLaunchOpts = null; 35 let lastPersistentContextOpts = null; 36 let shouldLaunchThrow = false; 37 38 // Mock plawright (plain) for launchPersistentContext 39 const mockPersistentContext = { 40 close: async () => {}, 41 newPage: async () => ({ 42 goto: async () => {}, 43 evaluate: async () => {}, 44 close: async () => {}, 45 mouse: { move: async () => {} }, 46 locator: () => ({ 47 first: () => ({ 48 boundingBox: async () => ({ x: 50, y: 50, width: 100, height: 40 }), 49 click: async () => {}, 50 fill: async () => {}, 51 pressSequentially: async () => {}, 52 }), 53 }), 54 waitForLoadState: async () => {}, 55 context: () => ({ cookies: async () => [], addCookies: async () => {} }), 56 }), 57 }; 58 59 mock.module('playwright', { 60 namedExports: { 61 chromium: { 62 launchPersistentContext: async (userDataDir, opts) => { 63 lastPersistentContextOpts = { userDataDir, ...opts }; 64 if (shouldLaunchThrow) throw new Error('Browser launch failed'); 65 return mockPersistentContext; 66 }, 67 }, 68 }, 69 }); 70 71 mock.module('playwright-extra', { 72 namedExports: { 73 chromium: { 74 use: () => {}, 75 launch: async opts => { 76 lastLaunchOpts = opts; 77 return { 78 newContext: async ctxOpts => ({ 79 newPage: async () => ({ 80 goto: async () => {}, 81 evaluate: async () => ({ x: 100, y: 100 }), 82 close: async () => {}, 83 mouse: { move: async () => {} }, 84 locator: () => ({ 85 first: () => ({ 86 boundingBox: async () => null, 87 click: async () => {}, 88 fill: async () => {}, 89 pressSequentially: async () => {}, 90 }), 91 }), 92 waitForLoadState: async () => {}, 93 context: () => ({ cookies: async () => [], addCookies: async () => {} }), 94 }), 95 close: async () => {}, 96 }), 97 close: async () => {}, 98 }; 99 }, 100 }, 101 }, 102 }); 103 104 mock.module('puppeteer-extra-plugin-stealth', { 105 defaultExport: () => ({ name: 'stealth' }), 106 }); 107 108 mock.module('user-agents', { 109 defaultExport: class UserAgent { 110 constructor() {} 111 toString() { return 'Mozilla/5.0 (Supp2 Test)'; } 112 }, 113 }); 114 115 // Import the module under test 116 const { 117 launchWithExtensions, 118 getNextProfile, 119 listProfiles, 120 loadProfile, 121 createStealthContext, 122 launchStealthBrowser, 123 randomDelay, 124 humanMouseMove, 125 isSocialMediaUrl, 126 } = await import('../../src/utils/stealth-browser.js'); 127 128 // ── Cleanup ────────────────────────────────────────────────────────────────── 129 130 after(() => { 131 try { rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); } catch { /* ok */ } 132 }); 133 134 // ═══════════════════════════════════════════════════════════════ 135 // launchWithExtensions 136 // ═══════════════════════════════════════════════════════════════ 137 138 describe('launchWithExtensions — no NopeCHA key', () => { 139 test('launches without extension when NOPECHA_API_KEY not set', async () => { 140 const origKey = process.env.NOPECHA_API_KEY; 141 const origKey2 = process.env.NOPECHA_API_KEY_2; 142 delete process.env.NOPECHA_API_KEY; 143 delete process.env.NOPECHA_API_KEY_2; 144 145 try { 146 const { context, close, hasNopeCHA } = await launchWithExtensions({ headless: false }); 147 assert.ok(context, 'should return a context'); 148 assert.equal(hasNopeCHA, false, 'hasNopeCHA should be false without API key'); 149 assert.ok(typeof close === 'function', 'should have a close function'); 150 await close(); 151 } finally { 152 if (origKey !== undefined) process.env.NOPECHA_API_KEY = origKey; 153 if (origKey2 !== undefined) process.env.NOPECHA_API_KEY_2 = origKey2; 154 } 155 }); 156 157 test('close() does not throw even when dirs do not exist', async () => { 158 const origKey = process.env.NOPECHA_API_KEY; 159 delete process.env.NOPECHA_API_KEY; 160 161 try { 162 const { close } = await launchWithExtensions(); 163 // close() should handle missing userDataDir gracefully 164 await assert.doesNotReject(close, 'close() should not throw'); 165 } finally { 166 if (origKey !== undefined) process.env.NOPECHA_API_KEY = origKey; 167 } 168 }); 169 }); 170 171 describe('launchWithExtensions — with NOPECHA key and extension dir present', () => { 172 test('sets hasNopeCHA=true when key is set and extension dir exists', async () => { 173 const origKey = process.env.NOPECHA_API_KEY; 174 // The extensions/nopecha dir exists in the project root so prepareNopeCHAExtension 175 // will succeed and return a path — hasNopeCHA should be true. 176 process.env.NOPECHA_API_KEY = 'test-nopecha-key-12345'; 177 178 try { 179 const { context, close, hasNopeCHA } = await launchWithExtensions(); 180 // key set + extension dir present → prepareNopeCHAExtension returns path → hasNopeCHA true 181 assert.equal(hasNopeCHA, true, 'hasNopeCHA should be true when key is set and extension dir exists'); 182 assert.ok(context, 'should return context'); 183 await close(); 184 } finally { 185 if (origKey !== undefined) process.env.NOPECHA_API_KEY = origKey; 186 else delete process.env.NOPECHA_API_KEY; 187 } 188 }); 189 }); 190 191 describe('launchWithExtensions — aggressive stealthLevel', () => { 192 test('adds extra args for aggressive stealth level', async () => { 193 const origKey = process.env.NOPECHA_API_KEY; 194 delete process.env.NOPECHA_API_KEY; 195 196 // We need to capture the args passed to launchPersistentContext 197 try { 198 const { context, close } = await launchWithExtensions({ stealthLevel: 'aggressive' }); 199 // Should not throw — just verifies code path runs 200 assert.ok(context, 'should return context'); 201 if (lastPersistentContextOpts) { 202 const hasDisableWebSecurity = lastPersistentContextOpts.args?.includes('--disable-web-security'); 203 // With aggressive level, --disable-site-isolation-trials should be added 204 // (or at minimum --disable-web-security which is in base set too) 205 assert.ok(hasDisableWebSecurity !== undefined, 'args should be defined'); 206 } 207 await close(); 208 } finally { 209 if (origKey !== undefined) process.env.NOPECHA_API_KEY = origKey; 210 else delete process.env.NOPECHA_API_KEY; 211 } 212 }); 213 }); 214 215 describe('launchWithExtensions — CHROMIUM_PATH env var', () => { 216 test('passes executablePath when CHROMIUM_PATH is set', async () => { 217 const origKey = process.env.NOPECHA_API_KEY; 218 const origPath = process.env.CHROMIUM_PATH; 219 delete process.env.NOPECHA_API_KEY; 220 process.env.CHROMIUM_PATH = '/usr/bin/chromium-test'; 221 222 try { 223 const { close } = await launchWithExtensions(); 224 if (lastPersistentContextOpts) { 225 assert.equal(lastPersistentContextOpts.executablePath, '/usr/bin/chromium-test'); 226 } 227 await close(); 228 } finally { 229 if (origKey !== undefined) process.env.NOPECHA_API_KEY = origKey; 230 else delete process.env.NOPECHA_API_KEY; 231 if (origPath !== undefined) process.env.CHROMIUM_PATH = origPath; 232 else delete process.env.CHROMIUM_PATH; 233 } 234 }); 235 }); 236 237 // ═══════════════════════════════════════════════════════════════ 238 // getNextProfile — LINKEDIN_PROFILE_COUNT 239 // ═══════════════════════════════════════════════════════════════ 240 241 describe('getNextProfile — LINKEDIN_PROFILE_COUNT', () => { 242 beforeEach(() => { 243 if (existsSync(TEMP_PROFILES_DIR)) { 244 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 245 } 246 mkdirSync(TEMP_PROFILES_DIR, { recursive: true }); 247 process.env.LINKEDIN_PROFILE_COUNT = '2'; 248 }); 249 250 afterEach(() => { 251 delete process.env.LINKEDIN_PROFILE_COUNT; 252 if (existsSync(TEMP_PROFILES_DIR)) { 253 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 254 } 255 }); 256 257 test('creates profile-1 for linkedin when no profiles exist', () => { 258 const profile = getNextProfile('linkedin'); 259 assert.equal(profile, 'profile-1'); 260 }); 261 262 test('returns LRU profile for linkedin when all slots filled', () => { 263 const dates = ['2025-02-01', '2025-01-01']; // profile-2 is older (LRU) 264 for (let i = 1; i <= 2; i++) { 265 const profileDir = join(TEMP_PROFILES_DIR, 'linkedin', `profile-${i}`); 266 mkdirSync(profileDir, { recursive: true }); 267 writeFileSync( 268 join(profileDir, 'metadata.json'), 269 JSON.stringify({ platform: 'linkedin', profileName: `profile-${i}`, last_used_at: dates[i - 1] }) 270 ); 271 } 272 273 const profile = getNextProfile('linkedin'); 274 assert.equal(profile, 'profile-2', 'should return oldest (LRU) profile'); 275 }); 276 277 test('LRU returns profile with null last_used_at (treated as epoch 0)', () => { 278 const profileDir1 = join(TEMP_PROFILES_DIR, 'linkedin', 'profile-1'); 279 mkdirSync(profileDir1, { recursive: true }); 280 writeFileSync( 281 join(profileDir1, 'metadata.json'), 282 JSON.stringify({ platform: 'linkedin', profileName: 'profile-1' }) // no last_used_at 283 ); 284 285 const profileDir2 = join(TEMP_PROFILES_DIR, 'linkedin', 'profile-2'); 286 mkdirSync(profileDir2, { recursive: true }); 287 writeFileSync( 288 join(profileDir2, 'metadata.json'), 289 JSON.stringify({ platform: 'linkedin', profileName: 'profile-2', last_used_at: '2025-01-01' }) 290 ); 291 292 const profile = getNextProfile('linkedin'); 293 // profile-1 has null last_used_at → treated as 0 → oldest → LRU 294 assert.equal(profile, 'profile-1', 'profile without last_used_at should be LRU'); 295 }); 296 }); 297 298 // ═══════════════════════════════════════════════════════════════ 299 // listProfiles — edge cases 300 // ═══════════════════════════════════════════════════════════════ 301 302 describe('listProfiles — edge cases', () => { 303 beforeEach(() => { 304 if (existsSync(TEMP_PROFILES_DIR)) { 305 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 306 } 307 mkdirSync(TEMP_PROFILES_DIR, { recursive: true }); 308 }); 309 310 afterEach(() => { 311 if (existsSync(TEMP_PROFILES_DIR)) { 312 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 313 } 314 }); 315 316 test('handles mix of valid and corrupted metadata in same platform', () => { 317 // Valid profile 318 const validDir = join(TEMP_PROFILES_DIR, 'x', 'profile-1'); 319 mkdirSync(validDir, { recursive: true }); 320 writeFileSync( 321 join(validDir, 'metadata.json'), 322 JSON.stringify({ platform: 'x', profileName: 'profile-1', last_used_at: '2025-01-01' }) 323 ); 324 325 // Corrupted profile 326 const badDir = join(TEMP_PROFILES_DIR, 'x', 'profile-2'); 327 mkdirSync(badDir, { recursive: true }); 328 writeFileSync(join(badDir, 'metadata.json'), 'CORRUPTED{{'); 329 330 const profiles = listProfiles('x'); 331 assert.equal(profiles.length, 2, 'should return both valid and corrupted profiles'); 332 const corrupted = profiles.find(p => p.profileName === 'profile-2'); 333 assert.ok(corrupted, 'corrupted profile should be included'); 334 assert.equal(corrupted.error, 'corrupted metadata'); 335 }); 336 337 test('returns profiles from both platforms when platform=null', () => { 338 // x profile 339 const xDir = join(TEMP_PROFILES_DIR, 'x', 'profile-1'); 340 mkdirSync(xDir, { recursive: true }); 341 writeFileSync( 342 join(xDir, 'metadata.json'), 343 JSON.stringify({ platform: 'x', profileName: 'profile-1', last_used_at: '2025-01-01' }) 344 ); 345 346 // linkedin profile 347 const liDir = join(TEMP_PROFILES_DIR, 'linkedin', 'profile-1'); 348 mkdirSync(liDir, { recursive: true }); 349 writeFileSync( 350 join(liDir, 'metadata.json'), 351 JSON.stringify({ platform: 'linkedin', profileName: 'profile-1', last_used_at: '2025-01-02' }) 352 ); 353 354 const profiles = listProfiles(null); 355 assert.equal(profiles.length, 2); 356 const platforms = profiles.map(p => p.platform); 357 assert.ok(platforms.includes('x'), 'should include x platform'); 358 assert.ok(platforms.includes('linkedin'), 'should include linkedin platform'); 359 }); 360 361 test('handles platform dir with non-directory entries (files)', () => { 362 const platDir = join(TEMP_PROFILES_DIR, 'x'); 363 mkdirSync(platDir, { recursive: true }); 364 // Create a file (not a directory) in the platform dir — should be skipped 365 writeFileSync(join(platDir, 'not-a-dir.txt'), 'test'); 366 367 const profiles = listProfiles('x'); 368 assert.equal(profiles.length, 0, 'files in platform dir should not be treated as profiles'); 369 }); 370 }); 371 372 // ═══════════════════════════════════════════════════════════════ 373 // loadProfile — metadata update edge cases 374 // ═══════════════════════════════════════════════════════════════ 375 376 describe('loadProfile — metadata update', () => { 377 let testProfileDir; 378 379 beforeEach(() => { 380 if (existsSync(TEMP_PROFILES_DIR)) { 381 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 382 } 383 mkdirSync(TEMP_PROFILES_DIR, { recursive: true }); 384 }); 385 386 afterEach(() => { 387 if (existsSync(TEMP_PROFILES_DIR)) { 388 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 389 } 390 }); 391 392 test('updates last_used_at in metadata when loading cookies', async () => { 393 const platform = 'x'; 394 const profileName = 'load-meta-update'; 395 testProfileDir = join(TEMP_PROFILES_DIR, platform, profileName); 396 mkdirSync(testProfileDir, { recursive: true }); 397 398 const oldDate = '2024-01-01T00:00:00.000Z'; 399 writeFileSync(join(testProfileDir, 'cookies.json'), JSON.stringify([{ name: 'session', value: 'abc' }])); 400 writeFileSync( 401 join(testProfileDir, 'metadata.json'), 402 JSON.stringify({ platform, profileName, last_used_at: oldDate }) 403 ); 404 405 const mockPage = { 406 context: () => ({ 407 addCookies: async () => {}, 408 cookies: async () => [], 409 }), 410 evaluate: async () => {}, 411 }; 412 413 const result = await loadProfile(mockPage, platform, profileName); 414 assert.equal(result, true); 415 416 const meta = JSON.parse(readFileSync(join(testProfileDir, 'metadata.json'), 'utf-8')); 417 assert.ok( 418 new Date(meta.last_used_at) > new Date(oldDate), 419 'last_used_at should be updated after loading' 420 ); 421 }); 422 }); 423 424 // ═══════════════════════════════════════════════════════════════ 425 // createStealthContext — ACCEPT_LANGUAGE parsing 426 // ═══════════════════════════════════════════════════════════════ 427 428 describe('createStealthContext — ACCEPT_LANGUAGE parsing', () => { 429 test('extracts locale from first segment of ACCEPT_LANGUAGE', async () => { 430 let contextOpts = null; 431 const mockBrowser = { 432 newContext: async opts => { 433 contextOpts = opts; 434 return { newPage: async () => {}, close: async () => {} }; 435 }, 436 close: async () => {}, 437 }; 438 439 const origLang = process.env.ACCEPT_LANGUAGE; 440 process.env.ACCEPT_LANGUAGE = 'en-GB,en;q=0.9,fr;q=0.8'; 441 442 try { 443 await createStealthContext(mockBrowser); 444 assert.equal(contextOpts.locale, 'en-GB', 'locale should be first segment of ACCEPT_LANGUAGE'); 445 assert.ok( 446 contextOpts.extraHTTPHeaders['Accept-Language'].includes('en-GB'), 447 'Accept-Language header should contain full value' 448 ); 449 } finally { 450 if (origLang !== undefined) process.env.ACCEPT_LANGUAGE = origLang; 451 else delete process.env.ACCEPT_LANGUAGE; 452 } 453 }); 454 455 test('uses en-AU as default locale when ACCEPT_LANGUAGE not set', async () => { 456 let contextOpts = null; 457 const mockBrowser = { 458 newContext: async opts => { 459 contextOpts = opts; 460 return { newPage: async () => {}, close: async () => {} }; 461 }, 462 close: async () => {}, 463 }; 464 465 const origLang = process.env.ACCEPT_LANGUAGE; 466 delete process.env.ACCEPT_LANGUAGE; 467 468 try { 469 await createStealthContext(mockBrowser); 470 assert.equal(contextOpts.locale, 'en-AU', 'default locale should be en-AU'); 471 } finally { 472 if (origLang !== undefined) process.env.ACCEPT_LANGUAGE = origLang; 473 } 474 }); 475 }); 476 477 // ═══════════════════════════════════════════════════════════════ 478 // launchStealthBrowser — args check 479 // ═══════════════════════════════════════════════════════════════ 480 481 describe('launchStealthBrowser — args', () => { 482 test('includes bot-detection avoidance args', async () => { 483 let launchArgs = null; 484 // We rely on the already-mocked playwright-extra chromium.launch 485 // which captures lastLaunchOpts 486 await launchStealthBrowser(); 487 488 // lastLaunchOpts is set by our mock 489 if (lastLaunchOpts) { 490 launchArgs = lastLaunchOpts.args; 491 assert.ok( 492 launchArgs.includes('--disable-blink-features=AutomationControlled'), 493 'should include AutomationControlled disable arg' 494 ); 495 assert.ok( 496 launchArgs.includes('--no-sandbox'), 497 'should include --no-sandbox arg' 498 ); 499 assert.ok( 500 launchArgs.includes('--disable-extensions'), 501 'should include --disable-extensions arg' 502 ); 503 } 504 }); 505 }); 506 507 // ═══════════════════════════════════════════════════════════════ 508 // isSocialMediaUrl — additional cases 509 // ═══════════════════════════════════════════════════════════════ 510 511 describe('isSocialMediaUrl — additional cases', () => { 512 test('handles URL with path traversal gracefully', () => { 513 // Valid URL with unusual characters in path 514 const result = isSocialMediaUrl('https://twitter.com/user?ref=..%2F..%2F'); 515 assert.equal(result, true, 'social media URL with query params should still match'); 516 }); 517 518 test('handles subdomain of non-social domain', () => { 519 // e.g. social.mycompany.com — not in the list 520 assert.equal(isSocialMediaUrl('https://social.mycompany.com/feed'), false); 521 }); 522 523 test('matches when hostname ends with a social domain (via includes)', () => { 524 // blog.twitter.com — hostname.includes('twitter.com') → true 525 assert.equal(isSocialMediaUrl('https://blog.twitter.com/article'), true); 526 }); 527 }); 528 529 // ═══════════════════════════════════════════════════════════════ 530 // randomDelay — min=max edge case 531 // ═══════════════════════════════════════════════════════════════ 532 533 describe('randomDelay — edge cases', () => { 534 test('works when min equals max', async () => { 535 const start = Date.now(); 536 await randomDelay(50, 50); 537 const elapsed = Date.now() - start; 538 // delay = 50ms exactly (floor(random*(0+1))+50 = 50) 539 assert.ok(elapsed >= 40, `expected at least ~50ms, got ${elapsed}ms`); 540 assert.ok(elapsed < 500, `should not take excessively long`); 541 }); 542 543 test('works with very small delay', async () => { 544 await assert.doesNotReject(() => randomDelay(1, 5), 'should not throw for tiny delays'); 545 }); 546 }); 547 548 // ═══════════════════════════════════════════════════════════════ 549 // humanMouseMove — extreme coordinates 550 // ═══════════════════════════════════════════════════════════════ 551 552 describe('humanMouseMove — edge cases', () => { 553 test('handles target at (0, 0)', async () => { 554 const movePositions = []; 555 const mockPage = { 556 evaluate: async () => ({ x: 500, y: 400 }), 557 mouse: { move: async (x, y) => { movePositions.push({ x, y }); } }, 558 }; 559 560 await humanMouseMove(mockPage, 0, 0); 561 assert.ok(movePositions.length >= 1, 'should produce waypoints'); 562 }); 563 564 test('handles same start and end position', async () => { 565 const movePositions = []; 566 const mockPage = { 567 evaluate: async () => ({ x: 100, y: 100 }), 568 mouse: { move: async (x, y) => { movePositions.push({ x, y }); } }, 569 }; 570 571 await humanMouseMove(mockPage, 100, 100); 572 assert.ok(movePositions.length >= 1, 'should still call mouse.move'); 573 }); 574 575 test('handles large coordinate values', async () => { 576 const movePositions = []; 577 const mockPage = { 578 evaluate: async () => ({ x: 0, y: 0 }), 579 mouse: { move: async (x, y) => { movePositions.push({ x, y }); } }, 580 }; 581 582 await humanMouseMove(mockPage, 3840, 2160); 583 assert.ok(movePositions.length >= 1, 'should handle large screen coordinates'); 584 const last = movePositions[movePositions.length - 1]; 585 assert.ok(last.x <= 3840 + 10, `last x should be near target, got ${last.x}`); 586 assert.ok(last.y <= 2160 + 10, `last y should be near target, got ${last.y}`); 587 }); 588 });