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