diff --git a/src/browser.test.ts b/src/browser.test.ts index c209b27..a626593 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -9,11 +9,11 @@ describe('BrowserManager', () => { beforeAll(async () => { browser = new BrowserManager(); await browser.launch({ headless: true }); - }); + }, 30000); afterAll(async () => { await browser.close(); - }); + }, 30000); describe('launch and close', () => { it('should report as launched', () => { @@ -56,7 +56,7 @@ describe('BrowserManager', () => { it('should report local stealth policy capabilities', async () => { const testBrowser = new BrowserManager(); - await testBrowser.launch({ headless: true, stealth: true }); + await testBrowser.launch({ headless: true }); const status = testBrowser.getStealthStatus('chromium'); expect(status.enabled).toBe(true); @@ -84,7 +84,7 @@ describe('BrowserManager', () => { const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any); const cdpBrowser = new BrowserManager(); - await cdpBrowser.launch({ cdpPort: 9222, stealth: true }); + await cdpBrowser.launch({ cdpPort: 9222 }); expect(addInitScript).toHaveBeenCalledTimes(1); const status = cdpBrowser.getStealthStatus(); @@ -97,7 +97,7 @@ describe('BrowserManager', () => { spy.mockRestore(); }); - it('should disable stealth capabilities when launch stealth is false in CDP mode', async () => { + it('should ignore legacy stealth=false and keep CDP stealth capabilities enabled', async () => { const addInitScript = vi.fn().mockResolvedValue(undefined); const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false }; const mockContext = { @@ -116,11 +116,12 @@ describe('BrowserManager', () => { const cdpBrowser = new BrowserManager(); await cdpBrowser.launch({ cdpPort: 9222, stealth: false }); - expect(addInitScript).not.toHaveBeenCalled(); + expect(addInitScript).toHaveBeenCalledTimes(1); const status = cdpBrowser.getStealthStatus(); - expect(status.enabled).toBe(false); + expect(status.enabled).toBe(true); expect(status.connectionKind).toBe('cdp'); - expect(status.capabilities).toEqual([]); + expect(status.capabilities).toContain('context-init-scripts'); + expect(status.capabilities).not.toContain('chromium-launch-args'); await cdpBrowser.close(); spy.mockRestore(); diff --git a/src/browser.ts b/src/browser.ts index 3ab180a..11ba2c1 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -445,7 +445,10 @@ export class BrowserManager { ): Promise { const policy = this.getStealthPolicy(); if (!policy.applyInitScripts) return; - await applyStealthScripts(context, options); + await applyStealthScripts(context, { + ...options, + userAgent: this.contextUserAgent, + }); this.logStealthPolicy('init-script applied'); } @@ -1693,7 +1696,9 @@ export class BrowserManager { this.cdpEndpoint = null; if (stealthPolicy.enabled && browserType === 'chromium') { - await applyBrowserLevelStealth(this.browser); + await applyBrowserLevelStealth(this.browser, { + userAgent: contextUserAgent, + }); } if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') { diff --git a/src/protocol.test.ts b/src/protocol.test.ts index 56639ac..1c77f94 100644 --- a/src/protocol.test.ts +++ b/src/protocol.test.ts @@ -6,14 +6,14 @@ const cmd = (obj: object) => JSON.stringify(obj); describe('parseCommand', () => { describe('launch', () => { - it('should parse launch command with stealth flag', () => { + it('should parse launch command and ignore legacy stealth flag', () => { const result = parseCommand( cmd({ id: '1', action: 'launch', headless: false, stealth: true }) ); expect(result.success).toBe(true); if (result.success) { expect(result.command.action).toBe('launch'); - expect(result.command.stealth).toBe(true); + expect((result.command as any).stealth).toBeUndefined(); } }); }); diff --git a/src/stealth.ts b/src/stealth.ts index acffaec..c993a0f 100644 --- a/src/stealth.ts +++ b/src/stealth.ts @@ -10,6 +10,8 @@ import type { Browser, BrowserContext, Page } from 'playwright-core'; export interface StealthScriptOptions { locale?: string; + userAgent?: string; + acceptLanguage?: string; } /** @@ -35,25 +37,30 @@ export async function applyStealthScripts( // Apply CDP-level User-Agent override so Workers also get the patched UA. // This must be done per-page since CDP sessions are page-scoped. for (const page of context.pages()) { - await applyCDPStealthToPage(page); + await applyCDPStealthToPage(page, options); } - context.on('page', (page: Page) => applyCDPStealthToPage(page)); + context.on('page', (page: Page) => applyCDPStealthToPage(page, options)); } /** * Apply browser-level CDP overrides that affect all targets (including Workers). * Call this right after browser.launch() and before creating pages. */ -export async function applyBrowserLevelStealth(browser: Browser): Promise { +export async function applyBrowserLevelStealth( + browser: Browser, + options: StealthScriptOptions = {} +): Promise { try { const cdp = await (browser as any).newBrowserCDPSession(); const version = await cdp.send('Browser.getVersion'); const rawUA = version?.userAgent ?? ''; - if (!rawUA.includes('HeadlessChrome')) { + const explicitUA = options.userAgent?.trim(); + if (!explicitUA && !rawUA.includes('HeadlessChrome')) { await cdp.detach(); return; } - const patchedUA = rawUA.replace(/HeadlessChrome/g, 'Chrome'); + const patchedUA = explicitUA || rawUA.replace(/HeadlessChrome/g, 'Chrome'); + const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9'; const metadata = buildUserAgentMetadata(patchedUA); // Override on all existing targets @@ -66,7 +73,7 @@ export async function applyBrowserLevelStealth(browser: Browser): Promise }); await cdp.send('Emulation.setUserAgentOverride', { userAgent: patchedUA, - acceptLanguage: 'en-US,en;q=0.9', + acceptLanguage, platform: getPlatformString(), userAgentMetadata: metadata, }); @@ -81,17 +88,22 @@ export async function applyBrowserLevelStealth(browser: Browser): Promise } } -async function applyCDPStealthToPage(page: Page): Promise { +async function applyCDPStealthToPage( + page: Page, + options: StealthScriptOptions = {} +): Promise { try { const cdp = await page.context().newCDPSession(page); const ua = await cdp.send('Browser.getVersion').catch(() => null); const rawUA = ua?.userAgent ?? ''; - const patchedUA = rawUA.replace(/HeadlessChrome/g, 'Chrome'); + const explicitUA = options.userAgent?.trim(); + const patchedUA = explicitUA || rawUA.replace(/HeadlessChrome/g, 'Chrome'); + const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9'; const metadata = buildUserAgentMetadata(patchedUA); await cdp.send('Emulation.setUserAgentOverride', { userAgent: patchedUA, - acceptLanguage: 'en-US,en;q=0.9', + acceptLanguage, platform: getPlatformString(), userAgentMetadata: metadata, }); diff --git a/test/file-access.test.ts b/test/file-access.test.ts index 874a4cb..ce3c0aa 100644 --- a/test/file-access.test.ts +++ b/test/file-access.test.ts @@ -146,9 +146,9 @@ describe('File Access (Issue #345)', () => { const content = await page.locator('h1').textContent(); expect(content).toBe('Test File Access'); - // Verify webdriver is hidden (from custom arg) + // Verify webdriver is hidden under stealth defaults const webdriver = await page.evaluate(() => navigator.webdriver); - expect(webdriver).toBe(false); + expect(webdriver).toBeUndefined(); }); }); }); diff --git a/test/launch-options.test.ts b/test/launch-options.test.ts index e594e54..c1a6009 100644 --- a/test/launch-options.test.ts +++ b/test/launch-options.test.ts @@ -11,7 +11,7 @@ describe('Launch Options', () => { }); describe('browser args', () => { - it('should launch with custom args to disable webdriver detection', async () => { + it('should keep webdriver undefined with custom args under stealth defaults', async () => { browser = new BrowserManager(); await browser.launch({ headless: true, @@ -21,9 +21,9 @@ describe('Launch Options', () => { const page = browser.getPage(); await page.goto('about:blank'); - // Check that navigator.webdriver is false + // Under stealth defaults, webdriver is hidden (undefined) const webdriver = await page.evaluate(() => navigator.webdriver); - expect(webdriver).toBe(false); + expect(webdriver).toBeUndefined(); }); it('should launch with multiple args', async () => { @@ -39,7 +39,7 @@ describe('Launch Options', () => { expect(browser.isLaunched()).toBe(true); }); - it('should launch without args (default behavior)', async () => { + it('should launch without args and keep webdriver hidden by default', async () => { browser = new BrowserManager(); await browser.launch({ headless: true, @@ -48,9 +48,9 @@ describe('Launch Options', () => { const page = browser.getPage(); await page.goto('about:blank'); - // Default Playwright behavior - webdriver is true + // Stealth default behavior - webdriver is hidden const webdriver = await page.evaluate(() => navigator.webdriver); - expect(webdriver).toBe(true); + expect(webdriver).toBeUndefined(); }); }); @@ -152,7 +152,7 @@ describe('Launch Options', () => { // Verify webdriver is hidden const webdriver = await page.evaluate(() => navigator.webdriver); - expect(webdriver).toBe(false); + expect(webdriver).toBeUndefined(); }); }); });