stealth-browser.test.js
1 /** 2 * Tests for stealth-browser.js pure/testable functions 3 * Tests isSocialMediaUrl, listProfiles, getNextProfile without Playwright 4 */ 5 6 import { test, describe, beforeEach, afterEach, after } from 'node:test'; 7 import assert from 'node:assert/strict'; 8 import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; 9 import { join } from 'path'; 10 import { tmpdir } from 'os'; 11 12 // Set up temp profiles dir BEFORE importing stealth-browser 13 const TEMP_PROFILES_DIR = join(tmpdir(), `stealth-browser-test-${process.pid}`); 14 process.env.BROWSER_PROFILES_DIR = TEMP_PROFILES_DIR; 15 16 // Mock playwright-extra and puppeteer-extra-plugin-stealth BEFORE import 17 import { mock } from 'node:test'; 18 mock.module('playwright-extra', { 19 namedExports: { 20 chromium: { 21 use: () => {}, 22 launch: async () => ({ 23 newContext: async () => ({ 24 newPage: async () => ({ 25 goto: async () => {}, 26 evaluate: async () => {}, 27 close: async () => {}, 28 }), 29 close: async () => {}, 30 }), 31 close: async () => {}, 32 }), 33 }, 34 }, 35 }); 36 37 mock.module('puppeteer-extra-plugin-stealth', { 38 defaultExport: () => ({ name: 'stealth' }), 39 }); 40 41 mock.module('user-agents', { 42 defaultExport: class UserAgent { 43 constructor() {} 44 toString() { 45 return 'Mozilla/5.0 (Test)'; 46 } 47 }, 48 }); 49 50 const { isSocialMediaUrl, listProfiles, getNextProfile, saveProfile, loadProfile, restoreStorage } = 51 await import('../../src/utils/stealth-browser.js'); 52 53 describe('isSocialMediaUrl', () => { 54 test('detects twitter.com', () => { 55 assert.equal(isSocialMediaUrl('https://twitter.com/user'), true); 56 }); 57 58 test('detects x.com', () => { 59 assert.equal(isSocialMediaUrl('https://x.com/user/status/123'), true); 60 }); 61 62 test('detects linkedin.com', () => { 63 assert.equal(isSocialMediaUrl('https://www.linkedin.com/in/someone'), true); 64 }); 65 66 test('detects facebook.com', () => { 67 assert.equal(isSocialMediaUrl('https://facebook.com/page'), true); 68 }); 69 70 test('detects fb.com', () => { 71 assert.equal(isSocialMediaUrl('https://fb.com/share'), true); 72 }); 73 74 test('detects instagram.com', () => { 75 assert.equal(isSocialMediaUrl('https://instagram.com/user'), true); 76 }); 77 78 test('detects youtube.com', () => { 79 assert.equal(isSocialMediaUrl('https://www.youtube.com/watch?v=abc'), true); 80 }); 81 82 test('detects tiktok.com', () => { 83 assert.equal(isSocialMediaUrl('https://tiktok.com/@user'), true); 84 }); 85 86 test('detects reddit.com', () => { 87 assert.equal(isSocialMediaUrl('https://reddit.com/r/tech'), true); 88 }); 89 90 test('returns false for regular business site', () => { 91 assert.equal(isSocialMediaUrl('https://example.com/contact'), false); 92 }); 93 94 test('returns false for invalid URL', () => { 95 assert.equal(isSocialMediaUrl('not-a-url'), false); 96 }); 97 98 test('returns false for empty string', () => { 99 assert.equal(isSocialMediaUrl(''), false); 100 }); 101 102 test('handles subdomains correctly (m.facebook.com)', () => { 103 assert.equal(isSocialMediaUrl('https://m.facebook.com/page'), true); 104 }); 105 106 test('case-insensitive matching', () => { 107 assert.equal(isSocialMediaUrl('https://TWITTER.COM/user'), true); 108 }); 109 }); 110 111 describe('listProfiles', () => { 112 beforeEach(() => { 113 // Clean up temp profiles dir 114 if (existsSync(TEMP_PROFILES_DIR)) { 115 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 116 } 117 mkdirSync(TEMP_PROFILES_DIR, { recursive: true }); 118 }); 119 120 afterEach(() => { 121 if (existsSync(TEMP_PROFILES_DIR)) { 122 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 123 } 124 }); 125 126 test('returns empty array when no profiles exist', () => { 127 const profiles = listProfiles('x'); 128 assert.deepEqual(profiles, []); 129 }); 130 131 test('returns empty array when platform dir does not exist', () => { 132 const profiles = listProfiles('nonexistent'); 133 assert.deepEqual(profiles, []); 134 }); 135 136 test('lists x profiles with valid metadata', () => { 137 const profileDir = join(TEMP_PROFILES_DIR, 'x', 'profile-1'); 138 mkdirSync(profileDir, { recursive: true }); 139 const metadata = { platform: 'x', profileName: 'profile-1', last_used_at: '2025-01-01' }; 140 writeFileSync(join(profileDir, 'metadata.json'), JSON.stringify(metadata)); 141 142 const profiles = listProfiles('x'); 143 assert.equal(profiles.length, 1); 144 assert.equal(profiles[0].platform, 'x'); 145 assert.equal(profiles[0].profileName, 'profile-1'); 146 }); 147 148 test('lists linkedin profiles', () => { 149 const profileDir = join(TEMP_PROFILES_DIR, 'linkedin', 'profile-1'); 150 mkdirSync(profileDir, { recursive: true }); 151 const metadata = { platform: 'linkedin', profileName: 'profile-1', last_used_at: '2025-01-01' }; 152 writeFileSync(join(profileDir, 'metadata.json'), JSON.stringify(metadata)); 153 154 const profiles = listProfiles('linkedin'); 155 assert.equal(profiles.length, 1); 156 assert.equal(profiles[0].platform, 'linkedin'); 157 }); 158 159 test('handles corrupted metadata.json gracefully', () => { 160 const profileDir = join(TEMP_PROFILES_DIR, 'x', 'profile-bad'); 161 mkdirSync(profileDir, { recursive: true }); 162 writeFileSync(join(profileDir, 'metadata.json'), 'not valid json {{{'); 163 164 const profiles = listProfiles('x'); 165 assert.equal(profiles.length, 1); 166 assert.equal(profiles[0].error, 'corrupted metadata'); 167 assert.equal(profiles[0].profileName, 'profile-bad'); 168 }); 169 170 test('skips directories without metadata.json', () => { 171 const profileDir = join(TEMP_PROFILES_DIR, 'x', 'profile-nometa'); 172 mkdirSync(profileDir, { recursive: true }); 173 // No metadata.json written 174 175 const profiles = listProfiles('x'); 176 assert.equal(profiles.length, 0); 177 }); 178 179 test('lists all platforms when platform=null', () => { 180 // Create x profile 181 const xDir = join(TEMP_PROFILES_DIR, 'x', 'profile-1'); 182 mkdirSync(xDir, { recursive: true }); 183 writeFileSync( 184 join(xDir, 'metadata.json'), 185 JSON.stringify({ platform: 'x', profileName: 'profile-1', last_used_at: '2025-01-01' }) 186 ); 187 188 // Create linkedin profile 189 const liDir = join(TEMP_PROFILES_DIR, 'linkedin', 'profile-1'); 190 mkdirSync(liDir, { recursive: true }); 191 writeFileSync( 192 join(liDir, 'metadata.json'), 193 JSON.stringify({ platform: 'linkedin', profileName: 'profile-1', last_used_at: '2025-01-02' }) 194 ); 195 196 const profiles = listProfiles(null); 197 assert.equal(profiles.length, 2); 198 }); 199 200 test('multiple profiles sorted by metadata', () => { 201 for (let i = 1; i <= 3; i++) { 202 const profileDir = join(TEMP_PROFILES_DIR, 'x', `profile-${i}`); 203 mkdirSync(profileDir, { recursive: true }); 204 writeFileSync( 205 join(profileDir, 'metadata.json'), 206 JSON.stringify({ 207 platform: 'x', 208 profileName: `profile-${i}`, 209 last_used_at: `2025-01-0${i}`, 210 }) 211 ); 212 } 213 214 const profiles = listProfiles('x'); 215 assert.equal(profiles.length, 3); 216 }); 217 }); 218 219 describe('getNextProfile', () => { 220 beforeEach(() => { 221 if (existsSync(TEMP_PROFILES_DIR)) { 222 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 223 } 224 mkdirSync(TEMP_PROFILES_DIR, { recursive: true }); 225 // Set max profiles to 3 226 process.env.X_PROFILE_COUNT = '3'; 227 process.env.LINKEDIN_PROFILE_COUNT = '3'; 228 }); 229 230 afterEach(() => { 231 if (existsSync(TEMP_PROFILES_DIR)) { 232 rmSync(TEMP_PROFILES_DIR, { recursive: true, force: true }); 233 } 234 delete process.env.X_PROFILE_COUNT; 235 delete process.env.LINKEDIN_PROFILE_COUNT; 236 }); 237 238 test('returns profile-1 when no profiles exist', () => { 239 const profile = getNextProfile('x'); 240 assert.equal(profile, 'profile-1'); 241 }); 242 243 test('returns profile-2 when one profile exists', () => { 244 const profileDir = join(TEMP_PROFILES_DIR, 'x', 'profile-1'); 245 mkdirSync(profileDir, { recursive: true }); 246 writeFileSync( 247 join(profileDir, 'metadata.json'), 248 JSON.stringify({ platform: 'x', profileName: 'profile-1', last_used_at: '2025-01-01' }) 249 ); 250 251 const profile = getNextProfile('x'); 252 assert.equal(profile, 'profile-2'); 253 }); 254 255 test('uses LRU when all profile slots filled', () => { 256 // Create 3 profiles with different last_used_at 257 const dates = ['2025-01-03', '2025-01-01', '2025-01-02']; 258 for (let i = 1; i <= 3; i++) { 259 const profileDir = join(TEMP_PROFILES_DIR, 'x', `profile-${i}`); 260 mkdirSync(profileDir, { recursive: true }); 261 writeFileSync( 262 join(profileDir, 'metadata.json'), 263 JSON.stringify({ platform: 'x', profileName: `profile-${i}`, last_used_at: dates[i - 1] }) 264 ); 265 } 266 267 // LRU = least recently used = profile-2 (2025-01-01) 268 const profile = getNextProfile('x'); 269 assert.equal(profile, 'profile-2'); 270 }); 271 272 test('works for linkedin platform', () => { 273 const profile = getNextProfile('linkedin'); 274 assert.equal(profile, 'profile-1'); 275 }); 276 277 test('respects X_PROFILE_COUNT env var', () => { 278 process.env.X_PROFILE_COUNT = '2'; 279 280 // Fill 2 profiles 281 for (let i = 1; i <= 2; i++) { 282 const profileDir = join(TEMP_PROFILES_DIR, 'x', `profile-${i}`); 283 mkdirSync(profileDir, { recursive: true }); 284 writeFileSync( 285 join(profileDir, 'metadata.json'), 286 JSON.stringify({ 287 platform: 'x', 288 profileName: `profile-${i}`, 289 last_used_at: `2025-01-0${i}`, 290 }) 291 ); 292 } 293 294 // With count=2 and 2 profiles, should return LRU (not profile-3) 295 const profile = getNextProfile('x'); 296 assert.equal(profile, 'profile-1'); // earliest last_used_at 297 }); 298 }); 299 300 // ─── saveProfile ────────────────────────────────────────────────────────────── 301 // Note: PROFILES_DIR is captured at module load time from BROWSER_PROFILES_DIR 302 // which was set to TEMP_PROFILES_DIR before import. Tests use TEMP_PROFILES_DIR. 303 304 describe('saveProfile', () => { 305 const SAVE_DIR = join(TEMP_PROFILES_DIR, 'save-tests'); 306 307 beforeEach(() => { 308 if (existsSync(SAVE_DIR)) rmSync(SAVE_DIR, { recursive: true, force: true }); 309 mkdirSync(SAVE_DIR, { recursive: true }); 310 }); 311 312 afterEach(() => { 313 if (existsSync(SAVE_DIR)) rmSync(SAVE_DIR, { recursive: true, force: true }); 314 }); 315 316 test('saves cookies, localStorage, sessionStorage and metadata', async () => { 317 // Use a subdir within TEMP_PROFILES_DIR (which is the module's PROFILES_DIR) 318 const platform = 'x'; 319 const profileName = 'save-profile-1'; 320 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 321 if (existsSync(profileDir)) rmSync(profileDir, { recursive: true, force: true }); 322 323 const mockPage = { 324 context: () => ({ 325 cookies: async () => [{ name: 'session', value: 'abc123', domain: 'x.com' }], 326 addCookies: async () => {}, 327 }), 328 evaluate: async () => ({ localStorage: { key1: 'val1' }, sessionStorage: { skey: 'sval' } }), 329 }; 330 331 await saveProfile(mockPage, platform, profileName); 332 333 assert.ok(existsSync(join(profileDir, 'cookies.json'))); 334 assert.ok(existsSync(join(profileDir, 'localStorage.json'))); 335 assert.ok(existsSync(join(profileDir, 'sessionStorage.json'))); 336 assert.ok(existsSync(join(profileDir, 'metadata.json'))); 337 338 const metadata = JSON.parse(readFileSync(join(profileDir, 'metadata.json'), 'utf-8')); 339 assert.equal(metadata.platform, platform); 340 assert.equal(metadata.profileName, profileName); 341 assert.ok(metadata.last_used_at); 342 assert.ok(metadata.created_at); 343 344 rmSync(profileDir, { recursive: true, force: true }); 345 }); 346 347 test('preserves existing metadata fields on re-save', async () => { 348 const platform = 'x'; 349 const profileName = 'save-profile-2'; 350 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 351 mkdirSync(profileDir, { recursive: true }); 352 writeFileSync( 353 join(profileDir, 'metadata.json'), 354 JSON.stringify({ 355 platform, 356 profileName, 357 username: '@testuser', 358 created_at: '2025-01-01T00:00:00.000Z', 359 }) 360 ); 361 362 const mockPage = { 363 context: () => ({ cookies: async () => [], addCookies: async () => {} }), 364 evaluate: async () => ({ localStorage: {}, sessionStorage: {} }), 365 }; 366 367 await saveProfile(mockPage, platform, profileName, { username: '@testuser' }); 368 369 const metadata = JSON.parse(readFileSync(join(profileDir, 'metadata.json'), 'utf-8')); 370 assert.equal(metadata.username, '@testuser'); 371 assert.equal(metadata.created_at, '2025-01-01T00:00:00.000Z'); 372 373 rmSync(profileDir, { recursive: true, force: true }); 374 }); 375 376 test('handles corrupted existing metadata gracefully', async () => { 377 const platform = 'x'; 378 const profileName = 'save-profile-bad'; 379 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 380 mkdirSync(profileDir, { recursive: true }); 381 writeFileSync(join(profileDir, 'metadata.json'), '{{invalid json}}'); 382 383 const mockPage = { 384 context: () => ({ cookies: async () => [], addCookies: async () => {} }), 385 evaluate: async () => ({ localStorage: {}, sessionStorage: {} }), 386 }; 387 388 await assert.doesNotReject(() => saveProfile(mockPage, platform, profileName)); 389 const metadata = JSON.parse(readFileSync(join(profileDir, 'metadata.json'), 'utf-8')); 390 assert.equal(metadata.platform, platform); 391 392 rmSync(profileDir, { recursive: true, force: true }); 393 }); 394 }); 395 396 // ─── loadProfile ────────────────────────────────────────────────────────────── 397 398 describe('loadProfile', () => { 399 test('returns false when no cookies.json exists', async () => { 400 const mockPage = { 401 context: () => ({ addCookies: async () => {}, cookies: async () => [] }), 402 evaluate: async () => {}, 403 }; 404 const result = await loadProfile(mockPage, 'x', 'load-profile-nonexistent'); 405 assert.equal(result, false); 406 }); 407 408 test('loads cookies and returns true when profile exists', async () => { 409 const platform = 'x'; 410 const profileName = 'load-profile-1'; 411 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 412 mkdirSync(profileDir, { recursive: true }); 413 const cookies = [{ name: 'auth', value: 'token123', domain: 'x.com' }]; 414 writeFileSync(join(profileDir, 'cookies.json'), JSON.stringify(cookies)); 415 writeFileSync( 416 join(profileDir, 'metadata.json'), 417 JSON.stringify({ platform, last_used_at: '2025-01-01T00:00:00.000Z' }) 418 ); 419 420 let addedCookies = null; 421 const mockPage = { 422 context: () => ({ 423 addCookies: async c => { 424 addedCookies = c; 425 }, 426 cookies: async () => cookies, 427 }), 428 evaluate: async () => {}, 429 }; 430 431 const result = await loadProfile(mockPage, platform, profileName); 432 assert.equal(result, true); 433 assert.deepEqual(addedCookies, cookies); 434 435 const meta = JSON.parse(readFileSync(join(profileDir, 'metadata.json'), 'utf-8')); 436 assert.ok(new Date(meta.last_used_at) > new Date('2025-01-01')); 437 438 rmSync(profileDir, { recursive: true, force: true }); 439 }); 440 441 test('loads profile without metadata.json (no error)', async () => { 442 const platform = 'x'; 443 const profileName = 'load-profile-nometa'; 444 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 445 mkdirSync(profileDir, { recursive: true }); 446 writeFileSync(join(profileDir, 'cookies.json'), JSON.stringify([])); 447 448 const mockPage = { 449 context: () => ({ addCookies: async () => {}, cookies: async () => [] }), 450 evaluate: async () => {}, 451 }; 452 453 const result = await loadProfile(mockPage, platform, profileName); 454 assert.equal(result, true); 455 456 rmSync(profileDir, { recursive: true, force: true }); 457 }); 458 459 test('returns false when addCookies throws', async () => { 460 const platform = 'x'; 461 const profileName = 'load-profile-err'; 462 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 463 mkdirSync(profileDir, { recursive: true }); 464 writeFileSync(join(profileDir, 'cookies.json'), JSON.stringify([{ name: 'a', value: 'b' }])); 465 466 const mockPage = { 467 context: () => ({ 468 addCookies: async () => { 469 throw new Error('Context closed'); 470 }, 471 cookies: async () => [], 472 }), 473 evaluate: async () => {}, 474 }; 475 476 const result = await loadProfile(mockPage, platform, profileName); 477 assert.equal(result, false); 478 479 rmSync(profileDir, { recursive: true, force: true }); 480 }); 481 }); 482 483 // ─── restoreStorage ─────────────────────────────────────────────────────────── 484 485 describe('restoreStorage', () => { 486 test('does nothing when no storage files exist', async () => { 487 let evaluateCalled = false; 488 const mockPage = { 489 evaluate: async () => { 490 evaluateCalled = true; 491 }, 492 context: () => ({ addCookies: async () => {}, cookies: async () => [] }), 493 }; 494 495 await assert.doesNotReject(() => restoreStorage(mockPage, 'x', 'restore-nostorage')); 496 assert.equal(evaluateCalled, false); 497 }); 498 499 test('restores localStorage when localStorage.json exists', async () => { 500 const platform = 'x'; 501 const profileName = 'restore-profile-1'; 502 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 503 mkdirSync(profileDir, { recursive: true }); 504 writeFileSync(join(profileDir, 'localStorage.json'), JSON.stringify({ foo: 'bar', baz: '42' })); 505 506 let evaluatedData = null; 507 const mockPage = { 508 evaluate: async (fn, data) => { 509 evaluatedData = data; 510 }, 511 context: () => ({ addCookies: async () => {}, cookies: async () => [] }), 512 }; 513 514 await restoreStorage(mockPage, platform, profileName); 515 assert.deepEqual(evaluatedData, { foo: 'bar', baz: '42' }); 516 517 rmSync(profileDir, { recursive: true, force: true }); 518 }); 519 520 test('restores sessionStorage when sessionStorage.json exists', async () => { 521 const platform = 'x'; 522 const profileName = 'restore-profile-2'; 523 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 524 mkdirSync(profileDir, { recursive: true }); 525 writeFileSync(join(profileDir, 'localStorage.json'), JSON.stringify({})); 526 writeFileSync(join(profileDir, 'sessionStorage.json'), JSON.stringify({ sess: 'data' })); 527 528 const evaluateCalls = []; 529 const mockPage = { 530 evaluate: async (fn, data) => { 531 evaluateCalls.push(data); 532 }, 533 context: () => ({ addCookies: async () => {}, cookies: async () => [] }), 534 }; 535 536 await restoreStorage(mockPage, platform, profileName); 537 assert.equal(evaluateCalls.length, 2); 538 assert.deepEqual(evaluateCalls[1], { sess: 'data' }); 539 540 rmSync(profileDir, { recursive: true, force: true }); 541 }); 542 543 test('handles evaluate errors gracefully (no throw)', async () => { 544 const platform = 'x'; 545 const profileName = 'restore-profile-err'; 546 const profileDir = join(TEMP_PROFILES_DIR, platform, profileName); 547 mkdirSync(profileDir, { recursive: true }); 548 writeFileSync(join(profileDir, 'localStorage.json'), JSON.stringify({ k: 'v' })); 549 550 const mockPage = { 551 evaluate: async () => { 552 throw new Error('Page crashed'); 553 }, 554 context: () => ({ addCookies: async () => {}, cookies: async () => [] }), 555 }; 556 557 await assert.doesNotReject(() => restoreStorage(mockPage, platform, profileName)); 558 559 rmSync(profileDir, { recursive: true, force: true }); 560 }); 561 });