stealth-browser.test.js
1 /** 2 * Stealth Browser Unit Tests - Enhanced 3 * Tests stealth browser utilities with mocked Playwright 4 * Covers: isSocialMediaUrl, randomDelay, launchStealthBrowser, createStealthContext, 5 * waitForCloudflare, humanScroll, humanType, humanClick, humanMouseMove, 6 * listProfiles, getNextProfile, saveProfile, loadProfile, restoreStorage, 7 * createPersistentContext 8 */ 9 10 import { describe, test, mock, before, after, beforeEach } from 'node:test'; 11 import assert from 'node:assert/strict'; 12 import fs from 'fs/promises'; 13 import { mkdirSync, writeFileSync, existsSync } from 'fs'; 14 import path from 'path'; 15 import { tmpdir } from 'os'; 16 17 // Set env vars BEFORE module import (PROFILES_DIR is captured at module level) 18 const TEST_PROFILES_DIR = path.join(tmpdir(), `test-profiles-${Date.now()}`); 19 process.env.BROWSER_PROFILES_DIR = TEST_PROFILES_DIR; 20 21 // Mock objects 22 const mockLocatorElement = { 23 pressSequentially: mock.fn(async () => {}), 24 fill: mock.fn(async () => {}), 25 boundingBox: mock.fn(async () => ({ x: 50, y: 50, width: 100, height: 30 })), 26 click: mock.fn(async () => {}), 27 }; 28 29 const mockBrowserContext = { 30 newPage: mock.fn(async () => mockPage), 31 close: mock.fn(async () => {}), 32 cookies: mock.fn(async () => [{ name: 'session', value: 'abc123', domain: 'example.com' }]), 33 addCookies: mock.fn(async () => {}), 34 }; 35 36 const mockBrowser = { 37 close: mock.fn(async () => {}), 38 newContext: mock.fn(async () => mockBrowserContext), 39 }; 40 41 const mockPage = { 42 goto: mock.fn(async () => ({ status: () => 200 })), 43 evaluate: mock.fn(async () => false), 44 waitForLoadState: mock.fn(async () => {}), 45 waitForTimeout: mock.fn(async () => {}), 46 mouse: { move: mock.fn(async () => {}), click: mock.fn(async () => {}) }, 47 locator: mock.fn(() => ({ 48 first: mock.fn(() => mockLocatorElement), 49 boundingBox: mock.fn(async () => ({ x: 50, y: 50, width: 100, height: 30 })), 50 click: mock.fn(async () => {}), 51 })), 52 keyboard: { type: mock.fn(async () => {}) }, 53 context: mock.fn(() => mockBrowserContext), 54 }; 55 56 const mockChromium = { 57 use: mock.fn(() => {}), 58 launch: mock.fn(async () => mockBrowser), 59 }; 60 61 mock.module('playwright-extra', { 62 namedExports: { chromium: mockChromium }, 63 }); 64 65 mock.module('puppeteer-extra-plugin-stealth', { 66 defaultExport: () => ({}), 67 }); 68 69 mock.module('user-agents', { 70 defaultExport: class MockUserAgent { 71 toString() { 72 return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36'; 73 } 74 }, 75 }); 76 77 const { 78 launchStealthBrowser, 79 createStealthContext, 80 randomDelay, 81 isSocialMediaUrl, 82 listProfiles, 83 getNextProfile, 84 waitForCloudflare, 85 humanScroll, 86 humanType, 87 humanClick, 88 humanMouseMove, 89 saveProfile, 90 loadProfile, 91 restoreStorage, 92 createPersistentContext, 93 } = await import('../../src/utils/stealth-browser.js'); 94 95 describe('stealth-browser', () => { 96 describe('isSocialMediaUrl', () => { 97 test('detects twitter/x.com', () => { 98 assert.equal(isSocialMediaUrl('https://twitter.com/user'), true); 99 assert.equal(isSocialMediaUrl('https://x.com/user'), true); 100 }); 101 test('detects linkedin', () => { 102 assert.equal(isSocialMediaUrl('https://www.linkedin.com/in/user'), true); 103 }); 104 test('detects facebook', () => { 105 assert.equal(isSocialMediaUrl('https://facebook.com/page'), true); 106 }); 107 test('detects fb.com', () => { 108 assert.equal(isSocialMediaUrl('https://fb.com/page'), true); 109 }); 110 test('detects instagram', () => { 111 assert.equal(isSocialMediaUrl('https://instagram.com/user'), true); 112 }); 113 test('detects youtube', () => { 114 assert.equal(isSocialMediaUrl('https://youtube.com/watch?v=123'), true); 115 }); 116 test('detects tiktok', () => { 117 assert.equal(isSocialMediaUrl('https://tiktok.com/@user'), true); 118 }); 119 test('detects reddit', () => { 120 assert.equal(isSocialMediaUrl('https://reddit.com/r/test'), true); 121 }); 122 test('returns false for non-social URLs', () => { 123 assert.equal(isSocialMediaUrl('https://example.com'), false); 124 assert.equal(isSocialMediaUrl('https://mybusiness.com'), false); 125 }); 126 test('returns false for invalid URLs', () => { 127 assert.equal(isSocialMediaUrl('not-a-url'), false); 128 assert.equal(isSocialMediaUrl(''), false); 129 }); 130 test('handles subdomain of social', () => { 131 assert.equal(isSocialMediaUrl('https://business.linkedin.com/page'), true); 132 }); 133 }); 134 135 describe('randomDelay', () => { 136 test('completes without error', async () => { 137 await assert.doesNotReject(() => randomDelay(1, 5)); 138 }); 139 test('uses default parameters', async () => { 140 await assert.doesNotReject(() => randomDelay()); 141 }); 142 test('works with min=max', async () => { 143 await assert.doesNotReject(() => randomDelay(1, 1)); 144 }); 145 }); 146 147 describe('launchStealthBrowser', () => { 148 test('launches browser with default options', async () => { 149 mockChromium.launch.mock.resetCalls(); 150 const browser = await launchStealthBrowser(); 151 assert.equal(mockChromium.launch.mock.calls.length, 1); 152 assert.ok(browser); 153 }); 154 test('passes headless option to launch', async () => { 155 mockChromium.launch.mock.resetCalls(); 156 await launchStealthBrowser({ headless: false }); 157 const callArgs = mockChromium.launch.mock.calls[0].arguments[0]; 158 assert.equal(callArgs.headless, false); 159 }); 160 test('launches with aggressive stealth level', async () => { 161 const browser = await launchStealthBrowser({ stealthLevel: 'aggressive' }); 162 assert.ok(browser); 163 }); 164 test('passes slowMo option', async () => { 165 mockChromium.launch.mock.resetCalls(); 166 await launchStealthBrowser({ slowMo: 100 }); 167 const callArgs = mockChromium.launch.mock.calls[0].arguments[0]; 168 assert.equal(callArgs.slowMo, 100); 169 }); 170 test('includes required browser args', async () => { 171 mockChromium.launch.mock.resetCalls(); 172 await launchStealthBrowser(); 173 const callArgs = mockChromium.launch.mock.calls[0].arguments[0]; 174 assert.ok(Array.isArray(callArgs.args)); 175 assert.ok(callArgs.args.some(a => a.includes('AutomationControlled'))); 176 }); 177 test('uses CHROMIUM_PATH when set', async () => { 178 process.env.CHROMIUM_PATH = '/usr/bin/chromium-test'; 179 mockChromium.launch.mock.resetCalls(); 180 await launchStealthBrowser(); 181 const callArgs = mockChromium.launch.mock.calls[0].arguments[0]; 182 assert.equal(callArgs.executablePath, '/usr/bin/chromium-test'); 183 delete process.env.CHROMIUM_PATH; 184 }); 185 }); 186 187 describe('createStealthContext', () => { 188 test('creates a context with default options', async () => { 189 mockBrowser.newContext.mock.resetCalls(); 190 const context = await createStealthContext(mockBrowser); 191 assert.equal(mockBrowser.newContext.mock.calls.length, 1); 192 assert.ok(context); 193 }); 194 test('passes viewport options', async () => { 195 mockBrowser.newContext.mock.resetCalls(); 196 await createStealthContext(mockBrowser, { viewport: { width: 1920, height: 1080 } }); 197 const callArgs = mockBrowser.newContext.mock.calls[0].arguments[0]; 198 assert.equal(callArgs.viewport.width, 1920); 199 }); 200 test('includes user agent in context', async () => { 201 mockBrowser.newContext.mock.resetCalls(); 202 await createStealthContext(mockBrowser); 203 const callArgs = mockBrowser.newContext.mock.calls[0].arguments[0]; 204 assert.ok(callArgs.userAgent); 205 assert.ok(typeof callArgs.userAgent === 'string'); 206 }); 207 test('uses ACCEPT_LANGUAGE env var', async () => { 208 process.env.ACCEPT_LANGUAGE = 'en-US,en;q=0.9'; 209 mockBrowser.newContext.mock.resetCalls(); 210 await createStealthContext(mockBrowser); 211 const callArgs = mockBrowser.newContext.mock.calls[0].arguments[0]; 212 assert.ok(callArgs.extraHTTPHeaders['Accept-Language'].includes('en-US')); 213 delete process.env.ACCEPT_LANGUAGE; 214 }); 215 test('accepts custom locale and timezone', async () => { 216 mockBrowser.newContext.mock.resetCalls(); 217 await createStealthContext(mockBrowser, { locale: 'de-DE', timezoneId: 'Europe/Berlin' }); 218 const callArgs = mockBrowser.newContext.mock.calls[0].arguments[0]; 219 assert.equal(callArgs.locale, 'de-DE'); 220 assert.equal(callArgs.timezoneId, 'Europe/Berlin'); 221 }); 222 }); 223 224 describe('waitForCloudflare', () => { 225 test('returns a boolean without throwing', async () => { 226 mockPage.waitForLoadState.mock.mockImplementation(async () => {}); 227 mockPage.evaluate.mock.mockImplementation(async () => false); 228 const result = await waitForCloudflare(mockPage, { timeout: 100 }); 229 assert.equal(typeof result, 'boolean'); 230 }); 231 test('returns false when waitForLoadState throws', async () => { 232 mockPage.waitForLoadState.mock.mockImplementation(async () => { 233 throw new Error('timeout'); 234 }); 235 const result = await waitForCloudflare(mockPage, { timeout: 100 }); 236 assert.equal(result, false); 237 mockPage.waitForLoadState.mock.mockImplementation(async () => {}); 238 }); 239 test('returns boolean for not-blocked page (may be false due to internal delay)', async () => { 240 mockPage.waitForLoadState.mock.mockImplementation(async () => {}); 241 mockPage.evaluate.mock.mockImplementation(async () => false); // not blocked 242 const result = await waitForCloudflare(mockPage, { timeout: 100 }); 243 assert.equal(typeof result, 'boolean'); 244 }); 245 }); 246 247 describe('humanScroll', () => { 248 test('does not throw with default options', async () => { 249 mockPage.evaluate.mock.mockImplementation(async () => 1000); 250 await assert.doesNotReject(() => humanScroll(mockPage, {})); 251 }); 252 test('works with smooth=false', async () => { 253 await assert.doesNotReject(() => humanScroll(mockPage, { smooth: false, distance: 'short' })); 254 }); 255 test('works with numeric distance', async () => { 256 await assert.doesNotReject(() => humanScroll(mockPage, { distance: '500' })); 257 }); 258 test('works with viewport distance', async () => { 259 mockPage.evaluate.mock.mockImplementation(async () => 768); 260 await assert.doesNotReject(() => humanScroll(mockPage, { distance: 'viewport' })); 261 }); 262 }); 263 264 describe('humanType', () => { 265 test('calls pressSequentially once with full text for short input', async () => { 266 mockLocatorElement.pressSequentially.mock.resetCalls(); 267 await humanType(mockPage, 'input[name="email"]', 'abc'); 268 assert.equal(mockLocatorElement.pressSequentially.mock.calls.length, 1); 269 assert.equal(mockLocatorElement.pressSequentially.mock.calls[0].arguments[0], 'abc'); 270 }); 271 test('handles empty string without calling pressSequentially', async () => { 272 mockLocatorElement.pressSequentially.mock.resetCalls(); 273 mockLocatorElement.fill.mock.resetCalls(); 274 await assert.doesNotReject(() => humanType(mockPage, 'input', '')); 275 // empty text goes through pressSequentially path (length <= 50) but calls once with '' 276 assert.equal(mockLocatorElement.pressSequentially.mock.calls.length, 1); 277 }); 278 test('uses fill() for long text (>50 chars)', async () => { 279 mockLocatorElement.pressSequentially.mock.resetCalls(); 280 mockLocatorElement.fill.mock.resetCalls(); 281 const longText = 'a'.repeat(51); 282 await humanType(mockPage, 'input', longText); 283 assert.equal(mockLocatorElement.fill.mock.calls.length, 1); 284 assert.equal(mockLocatorElement.pressSequentially.mock.calls.length, 0); 285 }); 286 }); 287 288 describe('humanClick', () => { 289 beforeEach(() => { 290 mockLocatorElement.boundingBox.mock.resetCalls(); 291 mockLocatorElement.click.mock.resetCalls(); 292 mockLocatorElement.boundingBox.mock.mockImplementation(async () => ({ 293 x: 100, 294 y: 100, 295 width: 200, 296 height: 50, 297 })); 298 }); 299 300 test('clicks the element', async () => { 301 await humanClick(mockPage, 'button.submit'); 302 assert.equal(mockLocatorElement.click.mock.calls.length, 1); 303 }); 304 305 test('does not throw when bounding box is null', async () => { 306 mockLocatorElement.boundingBox.mock.mockImplementation(async () => null); 307 await assert.doesNotReject(() => humanClick(mockPage, 'button')); 308 }); 309 310 test('calls locator with selector', async () => { 311 mockPage.locator.mock.resetCalls(); 312 await humanClick(mockPage, '#submit-btn'); 313 assert.equal(mockPage.locator.mock.calls.length, 1); 314 assert.equal(mockPage.locator.mock.calls[0].arguments[0], '#submit-btn'); 315 }); 316 317 test('does not throw with any selector', async () => { 318 await assert.doesNotReject(() => humanClick(mockPage, '.any-selector')); 319 }); 320 }); 321 322 describe('humanMouseMove', () => { 323 test('moves mouse to target', async () => { 324 mockPage.mouse.move.mock.resetCalls(); 325 mockPage.evaluate.mock.mockImplementation(async () => ({ x: 100, y: 100 })); 326 await humanMouseMove(mockPage, 500, 400); 327 assert.ok(mockPage.mouse.move.mock.calls.length > 0); 328 }); 329 330 test('does not throw for any target coordinates', async () => { 331 mockPage.evaluate.mock.mockImplementation(async () => ({ x: 0, y: 0 })); 332 await assert.doesNotReject(() => humanMouseMove(mockPage, 100, 200)); 333 }); 334 335 test('handles zero coordinates', async () => { 336 mockPage.evaluate.mock.mockImplementation(async () => ({ x: 0, y: 0 })); 337 await assert.doesNotReject(() => humanMouseMove(mockPage, 0, 0)); 338 }); 339 }); 340 341 describe('listProfiles', () => { 342 before(async () => { 343 await fs.mkdir(path.join(TEST_PROFILES_DIR, 'x', 'profile-1'), { recursive: true }); 344 writeFileSync( 345 path.join(TEST_PROFILES_DIR, 'x', 'profile-1', 'metadata.json'), 346 JSON.stringify({ 347 platform: 'x', 348 profileName: 'profile-1', 349 last_used_at: new Date(Date.now() - 3600000).toISOString(), 350 }) 351 ); 352 await fs.mkdir(path.join(TEST_PROFILES_DIR, 'x', 'profile-2'), { recursive: true }); 353 writeFileSync( 354 path.join(TEST_PROFILES_DIR, 'x', 'profile-2', 'metadata.json'), 355 JSON.stringify({ 356 platform: 'x', 357 profileName: 'profile-2', 358 last_used_at: new Date().toISOString(), 359 }) 360 ); 361 }); 362 363 test('lists profiles for a specific platform', () => { 364 const profiles = listProfiles('x'); 365 assert.equal(profiles.length, 2); 366 assert.ok(profiles.every(p => p.platform === 'x')); 367 }); 368 test('returns empty array for platform with no profiles', () => { 369 const profiles = listProfiles('linkedin'); 370 assert.deepStrictEqual(profiles, []); 371 }); 372 test('lists all platforms when no platform specified', () => { 373 const profiles = listProfiles(); 374 assert.ok(profiles.length >= 2); 375 }); 376 test('handles corrupted metadata gracefully', () => { 377 mkdirSync(path.join(TEST_PROFILES_DIR, 'x', 'corrupt-profile'), { recursive: true }); 378 writeFileSync( 379 path.join(TEST_PROFILES_DIR, 'x', 'corrupt-profile', 'metadata.json'), 380 'not-valid-json' 381 ); 382 const profiles = listProfiles('x'); 383 const corrupt = profiles.find(p => p.profileName === 'corrupt-profile'); 384 assert.ok(corrupt); 385 assert.equal(corrupt.error, 'corrupted metadata'); 386 }); 387 test('returns profiles sorted by metadata', () => { 388 const profiles = listProfiles('x'); 389 assert.ok(profiles.length >= 2); 390 profiles.forEach(p => { 391 assert.ok('platform' in p || 'error' in p); 392 }); 393 }); 394 }); 395 396 describe('getNextProfile', () => { 397 test('returns new profile name when below max count', () => { 398 process.env.X_PROFILE_COUNT = '5'; 399 const profileName = getNextProfile('x'); 400 assert.ok(profileName.startsWith('profile-')); 401 }); 402 test('returns an existing profile name when at max count', () => { 403 process.env.X_PROFILE_COUNT = '2'; 404 const profileName = getNextProfile('x'); 405 assert.ok(typeof profileName === 'string' && profileName.length > 0); 406 }); 407 test('uses LINKEDIN_PROFILE_COUNT for linkedin platform', () => { 408 process.env.LINKEDIN_PROFILE_COUNT = '10'; 409 const profileName = getNextProfile('linkedin'); 410 // No linkedin profiles exist, so should create new one 411 assert.ok(profileName.startsWith('profile-')); 412 delete process.env.LINKEDIN_PROFILE_COUNT; 413 }); 414 test('returns LRU profile when at max', () => { 415 process.env.X_PROFILE_COUNT = '2'; // We have profiles existing 416 // At max count, should return the least recently used (oldest last_used_at) 417 const profileName = getNextProfile('x'); 418 assert.ok(typeof profileName === 'string'); 419 assert.ok(profileName.length > 0, 'Should return a non-empty profile name'); 420 }); 421 }); 422 423 describe('saveProfile', () => { 424 const testPlatform = 'x'; 425 const testProfileName = 'save-test-profile'; 426 427 before(async () => { 428 await fs.mkdir(path.join(TEST_PROFILES_DIR, testPlatform), { recursive: true }); 429 }); 430 431 test('saves profile to disk (creates files)', async () => { 432 // Setup mock page for storage extraction 433 mockBrowserContext.cookies.mock.mockImplementation(async () => [ 434 { name: 'auth', value: 'token123', domain: 'x.com' }, 435 ]); 436 mockPage.evaluate.mock.mockImplementation(async () => ({ 437 localStorage: { key1: 'val1' }, 438 sessionStorage: { key2: 'val2' }, 439 })); 440 441 await saveProfile(mockPage, testPlatform, testProfileName, { username: 'testuser' }); 442 443 // Verify files were created 444 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, testProfileName); 445 assert.ok(existsSync(path.join(profileDir, 'cookies.json'))); 446 assert.ok(existsSync(path.join(profileDir, 'metadata.json'))); 447 }); 448 449 test('saves metadata with platform and profileName', async () => { 450 mockBrowserContext.cookies.mock.mockImplementation(async () => []); 451 mockPage.evaluate.mock.mockImplementation(async () => ({ 452 localStorage: {}, 453 sessionStorage: {}, 454 })); 455 456 await saveProfile(mockPage, testPlatform, `${testProfileName}-meta`); 457 458 const { 459 default: { readFileSync: rfs }, 460 } = await import('fs'); 461 const metaPath = path.join( 462 TEST_PROFILES_DIR, 463 testPlatform, 464 `${testProfileName}-meta`, 465 'metadata.json' 466 ); 467 const meta = JSON.parse(rfs(metaPath, 'utf-8')); 468 assert.equal(meta.platform, testPlatform); 469 assert.equal(meta.profileName, `${testProfileName}-meta`); 470 assert.ok(meta.last_used_at); 471 }); 472 473 test('preserves created_at on update', async () => { 474 const profileName = 'preserve-created'; 475 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 476 mkdirSync(profileDir, { recursive: true }); 477 const origCreatedAt = '2024-01-01T00:00:00.000Z'; 478 writeFileSync( 479 path.join(profileDir, 'metadata.json'), 480 JSON.stringify({ 481 platform: testPlatform, 482 profileName, 483 created_at: origCreatedAt, 484 last_used_at: origCreatedAt, 485 }) 486 ); 487 488 mockBrowserContext.cookies.mock.mockImplementation(async () => []); 489 mockPage.evaluate.mock.mockImplementation(async () => ({ 490 localStorage: {}, 491 sessionStorage: {}, 492 })); 493 494 await saveProfile(mockPage, testPlatform, profileName); 495 496 const { 497 default: { readFileSync: rfs }, 498 } = await import('fs'); 499 const meta = JSON.parse(rfs(path.join(profileDir, 'metadata.json'), 'utf-8')); 500 assert.equal(meta.created_at, origCreatedAt, 'created_at should be preserved'); 501 assert.notEqual(meta.last_used_at, origCreatedAt, 'last_used_at should be updated'); 502 }); 503 504 test('handles corrupted existing metadata gracefully', async () => { 505 const profileName = 'corrupt-meta-save'; 506 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 507 mkdirSync(profileDir, { recursive: true }); 508 writeFileSync(path.join(profileDir, 'metadata.json'), 'invalid json'); 509 510 mockBrowserContext.cookies.mock.mockImplementation(async () => []); 511 mockPage.evaluate.mock.mockImplementation(async () => ({ 512 localStorage: {}, 513 sessionStorage: {}, 514 })); 515 516 // Should not throw - corrupted metadata handled gracefully 517 await assert.doesNotReject(() => saveProfile(mockPage, testPlatform, profileName)); 518 }); 519 }); 520 521 describe('loadProfile', () => { 522 const testPlatform = 'x'; 523 524 test('returns false when no cookies.json exists', async () => { 525 const result = await loadProfile(mockPage, testPlatform, 'nonexistent-profile-xyz'); 526 assert.equal(result, false); 527 }); 528 529 test('returns true when cookies exist and loads them', async () => { 530 const profileName = 'load-test-profile'; 531 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 532 mkdirSync(profileDir, { recursive: true }); 533 writeFileSync( 534 path.join(profileDir, 'cookies.json'), 535 JSON.stringify([{ name: 'auth', value: 'token', domain: 'x.com' }]) 536 ); 537 writeFileSync( 538 path.join(profileDir, 'metadata.json'), 539 JSON.stringify({ 540 platform: testPlatform, 541 profileName, 542 last_used_at: '2024-01-01T00:00:00.000Z', 543 }) 544 ); 545 546 mockBrowserContext.addCookies.mock.resetCalls(); 547 const result = await loadProfile(mockPage, testPlatform, profileName); 548 assert.equal(result, true); 549 assert.equal(mockBrowserContext.addCookies.mock.calls.length, 1); 550 }); 551 552 test('updates last_used_at in metadata when loading', async () => { 553 const profileName = 'load-update-test'; 554 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 555 mkdirSync(profileDir, { recursive: true }); 556 const oldDate = '2024-01-01T00:00:00.000Z'; 557 writeFileSync(path.join(profileDir, 'cookies.json'), JSON.stringify([])); 558 writeFileSync( 559 path.join(profileDir, 'metadata.json'), 560 JSON.stringify({ platform: testPlatform, profileName, last_used_at: oldDate }) 561 ); 562 563 await loadProfile(mockPage, testPlatform, profileName); 564 565 const { 566 default: { readFileSync: rfs }, 567 } = await import('fs'); 568 const meta = JSON.parse(rfs(path.join(profileDir, 'metadata.json'), 'utf-8')); 569 assert.notEqual(meta.last_used_at, oldDate, 'last_used_at should be updated'); 570 }); 571 572 test('returns false when addCookies throws', async () => { 573 const profileName = 'load-error-profile'; 574 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 575 mkdirSync(profileDir, { recursive: true }); 576 writeFileSync( 577 path.join(profileDir, 'cookies.json'), 578 JSON.stringify([{ name: 'auth', value: 'token', domain: 'x.com' }]) 579 ); 580 581 mockBrowserContext.addCookies.mock.mockImplementation(async () => { 582 throw new Error('Cookie error'); 583 }); 584 const result = await loadProfile(mockPage, testPlatform, profileName); 585 assert.equal(result, false); 586 mockBrowserContext.addCookies.mock.mockImplementation(async () => {}); 587 }); 588 589 test('handles profile with no metadata.json gracefully', async () => { 590 const profileName = 'no-metadata-profile'; 591 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 592 mkdirSync(profileDir, { recursive: true }); 593 writeFileSync(path.join(profileDir, 'cookies.json'), JSON.stringify([])); 594 // No metadata.json 595 596 const result = await loadProfile(mockPage, testPlatform, profileName); 597 assert.equal(result, true); // Still loads even without metadata 598 }); 599 }); 600 601 describe('restoreStorage', () => { 602 const testPlatform = 'x'; 603 604 test('does not throw when no storage files exist', async () => { 605 await assert.doesNotReject(() => 606 restoreStorage(mockPage, testPlatform, 'no-storage-profile') 607 ); 608 }); 609 610 test('restores localStorage when file exists', async () => { 611 const profileName = 'restore-local-test'; 612 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 613 mkdirSync(profileDir, { recursive: true }); 614 writeFileSync( 615 path.join(profileDir, 'localStorage.json'), 616 JSON.stringify({ myKey: 'myValue' }) 617 ); 618 619 mockPage.evaluate.mock.resetCalls(); 620 mockPage.evaluate.mock.mockImplementation(async () => {}); 621 622 await restoreStorage(mockPage, testPlatform, profileName); 623 // page.evaluate should have been called to restore localStorage 624 assert.ok(mockPage.evaluate.mock.calls.length >= 1); 625 }); 626 627 test('restores sessionStorage when file exists', async () => { 628 const profileName = 'restore-session-test'; 629 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 630 mkdirSync(profileDir, { recursive: true }); 631 writeFileSync( 632 path.join(profileDir, 'sessionStorage.json'), 633 JSON.stringify({ sessionKey: 'sessionVal' }) 634 ); 635 636 mockPage.evaluate.mock.resetCalls(); 637 mockPage.evaluate.mock.mockImplementation(async () => {}); 638 639 await restoreStorage(mockPage, testPlatform, profileName); 640 assert.ok(mockPage.evaluate.mock.calls.length >= 1); 641 }); 642 643 test('restores both localStorage and sessionStorage', async () => { 644 const profileName = 'restore-both-test'; 645 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 646 mkdirSync(profileDir, { recursive: true }); 647 writeFileSync(path.join(profileDir, 'localStorage.json'), JSON.stringify({ k1: 'v1' })); 648 writeFileSync(path.join(profileDir, 'sessionStorage.json'), JSON.stringify({ k2: 'v2' })); 649 650 mockPage.evaluate.mock.resetCalls(); 651 mockPage.evaluate.mock.mockImplementation(async () => {}); 652 653 await restoreStorage(mockPage, testPlatform, profileName); 654 // Both localStorage and sessionStorage should trigger evaluate calls 655 assert.ok(mockPage.evaluate.mock.calls.length >= 2); 656 }); 657 658 test('handles evaluate error gracefully', async () => { 659 const profileName = 'restore-error-test'; 660 const profileDir = path.join(TEST_PROFILES_DIR, testPlatform, profileName); 661 mkdirSync(profileDir, { recursive: true }); 662 writeFileSync(path.join(profileDir, 'localStorage.json'), JSON.stringify({ k: 'v' })); 663 664 mockPage.evaluate.mock.mockImplementation(async () => { 665 throw new Error('evaluate failed'); 666 }); 667 668 await assert.doesNotReject(() => restoreStorage(mockPage, testPlatform, profileName)); 669 mockPage.evaluate.mock.mockImplementation(async () => {}); 670 }); 671 }); 672 673 describe('createPersistentContext', () => { 674 test('creates context and page', async () => { 675 mockBrowser.newContext.mock.resetCalls(); 676 mockBrowserContext.newPage.mock.resetCalls(); 677 678 const result = await createPersistentContext(mockBrowser, 'x', 'test-profile'); 679 assert.ok(result.context); 680 assert.ok(result.page); 681 assert.ok('profileLoaded' in result); 682 }); 683 684 test('profileLoaded is false for nonexistent profile', async () => { 685 const result = await createPersistentContext(mockBrowser, 'x', 'nonexistent-persistent-xyz'); 686 assert.equal(result.profileLoaded, false); 687 }); 688 689 test('profileLoaded is true when profile cookies exist', async () => { 690 const platform = 'x'; 691 const profileName = 'persistent-ctx-test'; 692 const profileDir = path.join(TEST_PROFILES_DIR, platform, profileName); 693 mkdirSync(profileDir, { recursive: true }); 694 writeFileSync( 695 path.join(profileDir, 'cookies.json'), 696 JSON.stringify([{ name: 'auth', value: 'tok', domain: 'x.com' }]) 697 ); 698 699 mockBrowserContext.addCookies.mock.mockImplementation(async () => {}); 700 701 const result = await createPersistentContext(mockBrowser, platform, profileName); 702 assert.equal(result.profileLoaded, true); 703 }); 704 705 test('accepts custom viewport', async () => { 706 mockBrowser.newContext.mock.resetCalls(); 707 await createPersistentContext(mockBrowser, 'x', 'test', { 708 viewport: { width: 1920, height: 1080 }, 709 }); 710 const callArgs = mockBrowser.newContext.mock.calls[0].arguments[0]; 711 assert.deepStrictEqual(callArgs.viewport, { width: 1920, height: 1080 }); 712 }); 713 714 test('accepts custom locale and timezone', async () => { 715 mockBrowser.newContext.mock.resetCalls(); 716 await createPersistentContext(mockBrowser, 'linkedin', 'test', { 717 locale: 'de-DE', 718 timezoneId: 'Europe/Berlin', 719 }); 720 const callArgs = mockBrowser.newContext.mock.calls[0].arguments[0]; 721 assert.equal(callArgs.locale, 'de-DE'); 722 assert.equal(callArgs.timezoneId, 'Europe/Berlin'); 723 }); 724 725 test('does not set userAgent (allows Chromium default)', async () => { 726 mockBrowser.newContext.mock.resetCalls(); 727 await createPersistentContext(mockBrowser, 'x', 'test'); 728 const callArgs = mockBrowser.newContext.mock.calls[0].arguments[0]; 729 assert.ok(!('userAgent' in callArgs), 'Should not set userAgent in persistent context'); 730 }); 731 }); 732 733 after(async () => { 734 await fs.rm(TEST_PROFILES_DIR, { recursive: true, force: true }); 735 }); 736 });