feat: add page.stealth_evaluate() for undetectable JS execution (#108)

Extract CDP isolated world classes from humanize layer into standalone
stealth_eval module. Attach page.stealth_evaluate(expression) to every
page automatically — runs JS in a CDP isolated world with clean
Error.stack traces and full variable isolation from main world JS.
This commit is contained in:
CloakHQ
2026-04-06 04:38:49 +02:00
parent 216a7d6a6a
commit a32d7c4eae
11 changed files with 639 additions and 262 deletions
+89 -8
View File
@@ -46,14 +46,17 @@ describe("launchContext (unit)", () => {
let mockContext: any;
let mockBrowser: any;
let mockChromium: any;
let origNewContext: any;
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
const origClose = vi.fn();
mockContext = { close: origClose, _origClose: origClose };
mockContext = { close: origClose, _origClose: origClose, newPage: vi.fn(), on: vi.fn(), pages: vi.fn().mockReturnValue([]) };
origNewContext = vi.fn().mockResolvedValue(mockContext);
mockBrowser = {
newContext: vi.fn().mockResolvedValue(mockContext),
newContext: origNewContext,
newPage: vi.fn(),
close: vi.fn(),
};
mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
@@ -75,7 +78,7 @@ describe("launchContext (unit)", () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext();
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
const ctxArgs = origNewContext.mock.calls[0][0];
expect(ctxArgs.viewport).toEqual(DEFAULT_VIEWPORT);
});
@@ -84,7 +87,7 @@ describe("launchContext (unit)", () => {
const custom = { width: 1280, height: 720 };
await launchContext({ viewport: custom });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
const ctxArgs = origNewContext.mock.calls[0][0];
expect(ctxArgs.viewport).toEqual(custom);
});
@@ -92,7 +95,7 @@ describe("launchContext (unit)", () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ userAgent: "Custom/1.0" });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
const ctxArgs = origNewContext.mock.calls[0][0];
expect(ctxArgs.userAgent).toBe("Custom/1.0");
});
@@ -108,7 +111,7 @@ describe("launchContext (unit)", () => {
expect(hasTimezoneFlag).toBe(true);
// NOT in newContext() — no CDP emulation
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
const ctxArgs = origNewContext.mock.calls[0][0];
expect(ctxArgs.timezoneId).toBeUndefined();
});
@@ -116,7 +119,7 @@ describe("launchContext (unit)", () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ colorScheme: "dark" });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
const ctxArgs = origNewContext.mock.calls[0][0];
expect(ctxArgs.colorScheme).toBe("dark");
});
@@ -132,6 +135,84 @@ describe("launchContext (unit)", () => {
});
});
// ---------------------------------------------------------------------------
// stealth_evaluate patching unit tests
// ---------------------------------------------------------------------------
describe("stealthEvaluate patching (unit)", () => {
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
let mockPage: any;
let mockContext: any;
let mockBrowser: any;
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
// Page mock with context() returning the implicit context
mockPage = {
context: vi.fn(),
};
// Implicit context created by browser.newPage()
mockContext = {
pages: vi.fn().mockReturnValue([]),
newPage: vi.fn(),
on: vi.fn(),
};
mockPage.context.mockReturnValue(mockContext);
mockBrowser = {
newContext: vi.fn().mockResolvedValue(mockContext),
newPage: vi.fn().mockResolvedValue(mockPage),
close: vi.fn(),
};
const mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (origEnv) {
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
} else {
delete process.env.CLOAKBROWSER_BINARY_PATH;
}
});
it("page.stealthEvaluate exists after launch + browser.newPage", async () => {
const { launch } = await import("../src/playwright.js");
const browser = await launch({ headless: true });
const page = await browser.newPage();
expect(typeof (page as any).stealthEvaluate).toBe("function");
});
it("implicit context from browser.newPage is patched for future pages", async () => {
const { launch } = await import("../src/playwright.js");
const browser = await launch({ headless: true });
await browser.newPage();
// The 'page' event listener should be registered on the implicit context
expect(mockContext.on).toHaveBeenCalledWith("page", expect.any(Function));
// The context should be marked as patched
expect((mockContext as any)._stealthEvalPatched).toBe(true);
});
it("context from browser.newContext patches pages with stealthEvaluate", async () => {
const { launch } = await import("../src/playwright.js");
const browser = await launch({ headless: true });
const mockPage2: any = { context: vi.fn().mockReturnValue(mockContext) };
mockContext.newPage.mockResolvedValue(mockPage2);
mockContext.pages.mockReturnValue([]);
const ctx = await browser.newContext();
const page = await ctx.newPage();
expect(typeof (page as any).stealthEvaluate).toBe("function");
});
});
describe("launchPersistentContext (unit)", () => {
let mockContext: any;
let mockChromium: any;
@@ -139,7 +220,7 @@ describe("launchPersistentContext (unit)", () => {
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]) };
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]), newPage: vi.fn(), on: vi.fn() };
mockChromium = {
launchPersistentContext: vi.fn().mockResolvedValue(mockContext),
};
+12 -12
View File
@@ -470,7 +470,7 @@ describe("humanType mixed text with CDP", () => {
// patchPage stealth wiring
// =========================================================================
describe("patchPage stealth infrastructure", () => {
it("page._stealth is a StealthEval instance after patching", async () => {
it("page._stealthWorld is a StealthEval instance after patching", async () => {
const { patchPage } = await import("../src/human/index.js");
const page = buildMockPage();
@@ -478,10 +478,10 @@ describe("patchPage stealth infrastructure", () => {
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect((page as any)._stealth).toBeDefined();
expect(typeof (page as any)._stealth.evaluate).toBe("function");
expect(typeof (page as any)._stealth.invalidate).toBe("function");
expect(typeof (page as any)._stealth.getCdpSession).toBe("function");
expect((page as any)._stealthWorld).toBeDefined();
expect(typeof (page as any)._stealthWorld.evaluate).toBe("function");
expect(typeof (page as any)._stealthWorld.invalidate).toBe("function");
expect(typeof (page as any)._stealthWorld.getCdpSession).toBe("function");
});
it("page._original and page._humanCfg are set", async () => {
@@ -504,7 +504,7 @@ describe("patchPage stealth infrastructure", () => {
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const stealth = (page as any)._stealthWorld;
const invalidateSpy = vi.spyOn(stealth, "invalidate");
await page.goto("https://example.com");
@@ -540,7 +540,7 @@ describe("StealthEval lifecycle", () => {
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const stealth = (page as any)._stealthWorld;
expect(() => stealth.invalidate()).not.toThrow();
});
@@ -552,7 +552,7 @@ describe("StealthEval lifecycle", () => {
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const stealth = (page as any)._stealthWorld;
const session = await stealth.getCdpSession();
expect(session).toBeDefined();
expect(typeof session.send).toBe("function");
@@ -587,7 +587,7 @@ describe("StealthEval lifecycle", () => {
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const stealth = (page as any)._stealthWorld;
const result = await stealth.evaluate("1 + 1");
expect(result).toBe(true);
});
@@ -626,7 +626,7 @@ describe("StealthEval lifecycle", () => {
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const stealth = (page as any)._stealthWorld;
const result = await stealth.evaluate("test");
expect(result).toBe("recovered");
});
@@ -660,7 +660,7 @@ describe("StealthEval lifecycle", () => {
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const stealth = (page as any)._stealthWorld;
const result = await stealth.evaluate("broken");
expect(result).toBeUndefined();
});
@@ -975,7 +975,7 @@ describeIfSlow("stealth browser: navigation invalidation", () => {
const browser = await launch({ headless: true, args: ['--humanize'] });
const page = await browser.newPage();
expect((page as any)._stealth).toBeDefined();
expect((page as any)._stealthWorld).toBeDefined();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await sleep(1000);