/ tests / utils / stealth-browser-supplement2.test.js
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  });