From e4c372ed9875c4e6cda03e106c33513ec958124b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 18 Jun 2026 00:58:57 -0700 Subject: [PATCH] test(browse): runtime + extended-mode coverage for the stealth blend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stealth tests were all static string-shape assertions; nothing executed the script in a real page. Add real-Chromium runtime checks via applyStealth + page.evaluate: - Layer C runtime: window.chrome.* rich shape, Notification.permission='default' paired with permissions.query notifications='prompt' (guards the shim now running on every path), and patched getters reporting [native code]. - Per-install hardware: navigator.hardwareConcurrency/deviceMemory reflect the GSTACK_* env profile. - Extended-mode blend: navigator.plugins is faked when GSTACK_STEALTH=extended, Layer C still wins window.chrome.runtime, and navigator.webdriver stays false (own-prop getter survives extended's prototype delete). - Persistent-context (launchHeaded/handoff) parity now uses a page created AFTER applyStealth — the old test checked pages()[0], which predates the init script, so webdriver was false only via the launch arg, not Layer C. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/test/stealth-webdriver.test.ts | 174 +++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 6 deletions(-) diff --git a/browse/test/stealth-webdriver.test.ts b/browse/test/stealth-webdriver.test.ts index c0fec6ce4..084229e09 100644 --- a/browse/test/stealth-webdriver.test.ts +++ b/browse/test/stealth-webdriver.test.ts @@ -98,11 +98,165 @@ describe('applyStealth — context level', () => { await page.close(); } }); + + test('window.chrome.* ships the rich Layer C shape at runtime', async () => { + const page = await context.newPage(); + try { + const shape = await page.evaluate(() => { + const c = (window as any).chrome; + return { + hasRuntime: !!c?.runtime, + hasPlatformArch: !!c?.runtime?.PlatformArch, + hasOnInstalled: !!c?.runtime?.OnInstalledReason, + csiIsFn: typeof c?.csi === 'function', + loadTimesIsFn: typeof c?.loadTimes === 'function', + appIsObj: typeof c?.app === 'object', + }; + }); + expect(shape.hasRuntime).toBe(true); + expect(shape.hasPlatformArch).toBe(true); + expect(shape.hasOnInstalled).toBe(true); + expect(shape.csiIsFn).toBe(true); + expect(shape.loadTimesIsFn).toBe(true); + expect(shape.appIsObj).toBe(true); + } finally { + await page.close(); + } + }); + + test('Notification.permission is default AND Permissions API returns prompt for notifications', async () => { + // The cdc/Permissions shim now lives in applyStealth, so this pairing + // holds on the plain newContext path too — previously it was headed-only, + // which left Notification.permission=default mismatched against the native + // Permissions answer in headless. Regression guard for that gap. + const page = await context.newPage(); + try { + const result = await page.evaluate(async () => { + const perm = typeof Notification !== 'undefined' ? Notification.permission : 'unavailable'; + let queryState = 'unavailable'; + try { + const status = await navigator.permissions.query({ name: 'notifications' } as any); + queryState = status.state; + } catch {} + return { perm, queryState }; + }); + expect(result.perm).toBe('default'); + expect(result.queryState).toBe('prompt'); + } finally { + await page.close(); + } + }); + + test('patched getters report [native code] via the toString proxy', async () => { + const page = await context.newPage(); + try { + const getterSrc = await page.evaluate(() => { + const wd = Object.getOwnPropertyDescriptor(navigator, 'webdriver'); + return wd && wd.get ? wd.get.toString() : ''; + }); + // Layer C wraps every patched getter through markNative, so the + // Function.prototype.toString Proxy reports native code instead of the + // injected source — defeats the toString integrity check. + expect(getterSrc).toContain('[native code]'); + } finally { + await page.close(); + } + }); }); -describe('applyStealth — persistent context (headed-mode parity)', () => { - test('webdriver mask applies to launchPersistentContext too (D7)', async () => { - // Simulate the launchHeaded path: launchPersistentContext + applyStealth +describe('applyStealth — per-install hardware from env', () => { + let ctx: BrowserContext; + let savedHw: string | undefined; + let savedMem: string | undefined; + + beforeAll(async () => { + savedHw = process.env.GSTACK_HW_CONCURRENCY; + savedMem = process.env.GSTACK_DEVICE_MEMORY; + process.env.GSTACK_HW_CONCURRENCY = '12'; + process.env.GSTACK_DEVICE_MEMORY = '4'; + ctx = await browser.newContext(); + await applyStealth(ctx); // readHostProfile() reads env at call time + }); + + afterAll(async () => { + await ctx.close(); + if (savedHw === undefined) delete process.env.GSTACK_HW_CONCURRENCY; + else process.env.GSTACK_HW_CONCURRENCY = savedHw; + if (savedMem === undefined) delete process.env.GSTACK_DEVICE_MEMORY; + else process.env.GSTACK_DEVICE_MEMORY = savedMem; + }); + + test('navigator.hardwareConcurrency and deviceMemory reflect the env profile', async () => { + const page = await ctx.newPage(); + try { + const hw = await page.evaluate(() => ({ + cores: navigator.hardwareConcurrency, + mem: (navigator as any).deviceMemory, + })); + expect(hw.cores).toBe(12); + expect(hw.mem).toBe(4); + } finally { + await page.close(); + } + }); +}); + +describe('applyStealth — extended mode layered on Layer C (GSTACK_STEALTH=extended)', () => { + let ctx: BrowserContext; + let savedStealth: string | undefined; + + beforeAll(async () => { + savedStealth = process.env.GSTACK_STEALTH; + process.env.GSTACK_STEALTH = 'extended'; + ctx = await browser.newContext(); + await applyStealth(ctx); + }); + + afterAll(async () => { + await ctx.close(); + if (savedStealth === undefined) delete process.env.GSTACK_STEALTH; + else process.env.GSTACK_STEALTH = savedStealth; + }); + + test('extended actually runs: navigator.plugins is the faked PluginArray', async () => { + const page = await ctx.newPage(); + try { + const names = await page.evaluate(() => Array.from(navigator.plugins).map((p) => p.name)); + expect(names).toContain('PDF Viewer'); + } finally { + await page.close(); + } + }); + + test('blend: Layer C wins window.chrome.runtime (rich shape, not extended skeletal)', async () => { + // extended's chrome.runtime is {OnInstalledReason, OnRestartRequiredReason} + // and is if(!)-guarded, so Layer C (applied first, with PlatformArch) must + // win. This pins the coexistence ordering the blend depends on. + const page = await ctx.newPage(); + try { + const hasRich = await page.evaluate(() => !!(window as any).chrome?.runtime?.PlatformArch); + expect(hasRich).toBe(true); + } finally { + await page.close(); + } + }); + + test('blend: navigator.webdriver stays false (Layer C own-prop survives extended prototype delete)', async () => { + const page = await ctx.newPage(); + try { + const wd = await page.evaluate(() => (navigator as any).webdriver); + expect(wd).toBe(false); + } finally { + await page.close(); + } + }); +}); + +describe('applyStealth — persistent context (headed + handoff parity)', () => { + test('full Layer C applies to launchPersistentContext (the launchHeaded/handoff path)', async () => { + // Simulate the launchHeaded/handoff path: launchPersistentContext + + // applyStealth. Verifies the persistent-context path gets the SAME Layer C + // as newContext, not just the webdriver mask. const fs = await import('fs'); const os = await import('os'); const path = await import('path'); @@ -114,9 +268,17 @@ describe('applyStealth — persistent context (headed-mode parity)', () => { }); try { await applyStealth(ctx); - const page = ctx.pages()[0] ?? await ctx.newPage(); - const webdriver = await page.evaluate(() => (navigator as any).webdriver); - expect(webdriver).toBe(false); + // Use a page created AFTER applyStealth. launchPersistentContext opens an + // initial page at launch time, before addInitScript is registered, so + // init scripts never run on pages()[0] (its webdriver is only false + // because of the --disable-blink-features launch arg, not Layer C). + const page = await ctx.newPage(); + const probe = await page.evaluate(() => ({ + webdriver: (navigator as any).webdriver, + hasChromeRuntime: !!(window as any).chrome?.runtime?.PlatformArch, + })); + expect(probe.webdriver).toBe(false); + expect(probe.hasChromeRuntime).toBe(true); } finally { await ctx.close(); fs.rmSync(userDataDir, { recursive: true, force: true });