Files
gstack/browse/test/stealth.test.ts
gstack 8df1c003b5 test(stealth): comprehensive unit + e2e tests, fix platform mismatch
Tests (52 total, 0 failures):

Unit tests (33):
- Module exports validation (stealthArgs shape, applyStealthPatches type)
- Launch args content (AutomationControlled, no-first-run, no forbidden flags)
- Init script source analysis (all 10 patch vectors verified present)
- applyStealthPatches API (mock context, GPU args, serialization, idempotency)
- Adversarial edge cases (array spread safety, extension compat, GPU plausibility)
- Import integration (browser-manager.ts correctly imports and calls both paths)
- Old inline patches removal verification

E2E tests (19):
- Real Chromium launch with stealth patches applied
- navigator.webdriver value AND property existence
- WebGL1 + WebGL2 renderer spoofing
- PluginArray instanceof + shape verification
- Complete chrome object (app, runtime, loadTimes, csi)
- Languages, permissions, CDP artifacts, Playwright globals
- Platform/UA consistency
- Patches survive page navigation

Bug fix: navigator.platform now spoofed to 'MacIntel' when UA claims
Macintosh. Previously reported 'Linux x86_64' in containers, which
contradicts the Mac user agent and is a detectable fingerprint mismatch.
Caught by the e2e test.
2026-04-21 03:06:40 +00:00

324 lines
12 KiB
TypeScript

/**
* stealth.test.ts — Unit + integration tests for anti-bot stealth patches
*
* Tests:
* 1. Module exports (stealthArgs, applyStealthPatches)
* 2. Launch args correctness
* 3. Init script content validation (parsed from source)
* 4. Integration with BrowserManager (import path, no crash)
* 5. Adversarial: prototype pollution, toString traps, WebGL spoof values
*/
import { describe, test, expect } from 'bun:test';
import { stealthArgs, applyStealthPatches } from '../src/stealth';
// ─── 1. Module Exports ──────────────────────────────────
describe('stealth module exports', () => {
test('stealthArgs is a non-empty array of strings', () => {
expect(Array.isArray(stealthArgs)).toBe(true);
expect(stealthArgs.length).toBeGreaterThan(0);
for (const arg of stealthArgs) {
expect(typeof arg).toBe('string');
expect(arg.startsWith('--')).toBe(true);
}
});
test('applyStealthPatches is an async function', () => {
expect(typeof applyStealthPatches).toBe('function');
// Should accept a context-like object without crashing at import time
});
});
// ─── 2. Launch Args ─────────────────────────────────────
describe('stealthArgs content', () => {
test('includes AutomationControlled disable', () => {
expect(stealthArgs).toContain('--disable-blink-features=AutomationControlled');
});
test('includes no-first-run to avoid welcome page', () => {
expect(stealthArgs).toContain('--no-first-run');
});
test('does not include --headless (that is a separate concern)', () => {
expect(stealthArgs.some(a => a.includes('headless'))).toBe(false);
});
test('does not include --no-sandbox (environment-specific)', () => {
expect(stealthArgs.some(a => a.includes('no-sandbox'))).toBe(false);
});
test('does not include proxy args (runtime-specific)', () => {
expect(stealthArgs.some(a => a.includes('proxy'))).toBe(false);
});
});
// ─── 3. Init Script Content (source analysis) ──────────
describe('init script coverage', () => {
// Read the source to verify all patches are present
const source = require('fs').readFileSync(
require('path').join(__dirname, '../src/stealth.ts'),
'utf-8',
);
test('patches navigator.webdriver via prototype deletion', () => {
expect(source).toContain('Navigator.prototype');
expect(source).toContain('webdriver');
expect(source).toContain('delete');
});
test('patches WebGL renderer (both WebGL1 and WebGL2)', () => {
expect(source).toContain('WebGLRenderingContext.prototype.getParameter');
expect(source).toContain('WebGL2RenderingContext');
expect(source).toContain('0x9245'); // UNMASKED_VENDOR
expect(source).toContain('0x9246'); // UNMASKED_RENDERER
});
test('creates proper PluginArray (not raw array)', () => {
expect(source).toContain('PluginArray.prototype');
expect(source).toContain('MimeType.prototype');
expect(source).toContain('Plugin.prototype');
expect(source).toContain('Symbol.iterator');
});
test('sets up complete chrome object with app', () => {
expect(source).toContain('chrome.app');
expect(source).toContain('InstallState');
expect(source).toContain('RunningState');
expect(source).toContain('chrome.runtime');
expect(source).toContain('chrome.csi');
expect(source).toContain('chrome.loadTimes');
});
test('cleans CDP artifacts', () => {
expect(source).toContain('cdc_');
expect(source).toContain('$cdc_');
expect(source).toContain('__webdriver');
expect(source).toContain('__selenium');
});
test('patches Permissions API for notifications', () => {
expect(source).toContain('permissions');
expect(source).toContain('notifications');
expect(source).toContain('prompt');
});
test('patches Function.prototype.toString', () => {
expect(source).toContain('Function.prototype.toString');
expect(source).toContain('[native code]');
});
test('handles mediaDevices for containers', () => {
expect(source).toContain('mediaDevices');
expect(source).toContain('enumerateDevices');
expect(source).toContain('getUserMedia');
});
test('spoofs navigator.platform to match UA', () => {
expect(source).toContain('navigator.platform');
expect(source).toContain('MacIntel');
expect(source).toContain('Macintosh');
});
test('passes GPU vendor/renderer as args (not hardcoded in browser context)', () => {
// The function signature should accept args for GPU strings
expect(source).toContain('gpuVendor');
expect(source).toContain('gpuRenderer');
// And pass them to addInitScript as the second arg
expect(source).toContain('[gpuVendor, gpuRenderer]');
});
});
// ─── 4. applyStealthPatches API ─────────────────────────
describe('applyStealthPatches API', () => {
test('rejects when called without a context', async () => {
// @ts-expect-error - intentionally passing null
await expect(applyStealthPatches(null)).rejects.toThrow();
});
test('rejects when context has no addInitScript', async () => {
// @ts-expect-error - intentionally passing incomplete mock
await expect(applyStealthPatches({})).rejects.toThrow();
});
test('calls addInitScript on a mock context', async () => {
let called = false;
let receivedArg: unknown;
const mockContext = {
addInitScript: async (fn: unknown, arg: unknown) => {
called = true;
receivedArg = arg;
},
};
// @ts-expect-error - mock
await applyStealthPatches(mockContext);
expect(called).toBe(true);
});
test('passes GPU args as [vendor, renderer] tuple', async () => {
let receivedArg: unknown;
const mockContext = {
addInitScript: async (_fn: unknown, arg: unknown) => {
receivedArg = arg;
},
};
// @ts-expect-error - mock
await applyStealthPatches(mockContext, {
gpuVendor: 'TestVendor',
gpuRenderer: 'TestRenderer',
});
expect(receivedArg).toEqual(['TestVendor', 'TestRenderer']);
});
test('uses default GPU strings when no options provided', async () => {
let receivedArg: unknown;
const mockContext = {
addInitScript: async (_fn: unknown, arg: unknown) => {
receivedArg = arg;
},
};
// @ts-expect-error - mock
await applyStealthPatches(mockContext);
const [vendor, renderer] = receivedArg as [string, string];
expect(vendor).toContain('Apple');
expect(renderer).toContain('M1 Pro');
});
test('init script function is serializable (no closures over Node APIs)', async () => {
let capturedFn: Function | null = null;
const mockContext = {
addInitScript: async (fn: unknown, _arg: unknown) => {
capturedFn = fn as Function;
},
};
// @ts-expect-error - mock
await applyStealthPatches(mockContext);
expect(capturedFn).not.toBeNull();
// The function should be serializable via toString (Playwright does this)
const str = capturedFn!.toString();
expect(str).toContain('Navigator.prototype');
// Should NOT reference any Node.js APIs (require, process, Buffer, etc.)
expect(str).not.toContain('require(');
expect(str).not.toContain('process.');
expect(str).not.toContain('Buffer.');
expect(str).not.toContain('__dirname');
expect(str).not.toContain('__filename');
});
});
// ─── 5. Adversarial: Edge Cases ─────────────────────────
describe('adversarial edge cases', () => {
test('stealthArgs are safe to spread into existing arrays', () => {
const existing = ['--no-sandbox', '--disable-gpu'];
const combined = [...existing, ...stealthArgs];
expect(combined.length).toBe(existing.length + stealthArgs.length);
// No duplicates of safety-critical flags
const unique = new Set(combined);
expect(unique.size).toBe(combined.length);
});
test('stealthArgs do not contain flags that break extension loading', () => {
// These flags would break GStack's headed mode with extension
const forbidden = ['--disable-extensions', '--disable-component-extensions-with-background-pages'];
for (const flag of forbidden) {
expect(stealthArgs).not.toContain(flag);
}
});
test('GPU spoof strings are plausible (not detectable as fake)', () => {
// The default GPU strings should match what a real Mac reports
let receivedArg: [string, string] | null = null;
const mockContext = {
addInitScript: async (_fn: unknown, arg: unknown) => {
receivedArg = arg as [string, string];
},
};
// @ts-expect-error - mock
applyStealthPatches(mockContext).then(() => {
const [vendor, renderer] = receivedArg!;
// Should look like a real Apple GPU report
expect(vendor).toMatch(/Google Inc\./);
expect(renderer).toMatch(/ANGLE.*Apple.*M1/);
// Should NOT contain SwiftShader, llvmpipe, or Mesa
expect(renderer).not.toMatch(/SwiftShader|llvmpipe|Mesa|Subzero/i);
});
});
test('applyStealthPatches can be called multiple times without error', async () => {
let callCount = 0;
const mockContext = {
addInitScript: async () => { callCount++; },
};
// @ts-expect-error - mock
await applyStealthPatches(mockContext);
// @ts-expect-error - mock
await applyStealthPatches(mockContext);
// Should be called twice (no guard against double-apply)
// This is fine — Playwright deduplicates addInitScript internally
expect(callCount).toBe(2);
});
});
// ─── 6. Import Integration ──────────────────────────────
describe('import integration', () => {
test('browser-manager.ts imports stealth module', () => {
const bmSource = require('fs').readFileSync(
require('path').join(__dirname, '../src/browser-manager.ts'),
'utf-8',
);
expect(bmSource).toContain("import { stealthArgs, applyStealthPatches } from './stealth'");
});
test('browser-manager.ts uses stealthArgs in launch()', () => {
const bmSource = require('fs').readFileSync(
require('path').join(__dirname, '../src/browser-manager.ts'),
'utf-8',
);
// In launch() — headless path
expect(bmSource).toContain('...stealthArgs');
});
test('browser-manager.ts calls applyStealthPatches in launch()', () => {
const bmSource = require('fs').readFileSync(
require('path').join(__dirname, '../src/browser-manager.ts'),
'utf-8',
);
expect(bmSource).toContain('await applyStealthPatches(this.context)');
});
test('browser-manager.ts uses stealthArgs in launchHeaded()', () => {
const bmSource = require('fs').readFileSync(
require('path').join(__dirname, '../src/browser-manager.ts'),
'utf-8',
);
expect(bmSource).toContain('...stealthArgs');
});
test('browser-manager.ts calls applyStealthPatches in launchHeaded()', () => {
const bmSource = require('fs').readFileSync(
require('path').join(__dirname, '../src/browser-manager.ts'),
'utf-8',
);
// Should have exactly 2 calls to applyStealthPatches (launch + launchHeaded)
const matches = bmSource.match(/applyStealthPatches/g) || [];
expect(matches.length).toBeGreaterThanOrEqual(3); // import + 2 calls
});
test('old inline stealth patches are removed from browser-manager.ts', () => {
const bmSource = require('fs').readFileSync(
require('path').join(__dirname, '../src/browser-manager.ts'),
'utf-8',
);
// Old inline patches should be gone
expect(bmSource).not.toContain("name: 'PDF Viewer'");
expect(bmSource).not.toContain("Fake plugins array");
expect(bmSource).not.toContain("Fake languages");
expect(bmSource).not.toContain("key.startsWith('cdc_')");
});
});