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