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