From a23268c9e9f3e91ab4c68d659cb2dc4c2002c2b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=AF=BC=EC=9E=AC?= Date: Mon, 18 May 2026 02:15:39 +0900 Subject: [PATCH] feat(js): export composable launch helpers (#244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 이민재 <19909783+honor2030@users.noreply.github.com> --- js/src/index.ts | 2 +- js/src/playwright.ts | 80 +++++++++++++++++++++++++---------------- js/tests/launch.test.ts | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 32 deletions(-) diff --git a/js/src/index.ts b/js/src/index.ts index 52609b6..d72882f 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -16,7 +16,7 @@ */ // Launch functions (Playwright API) -export { launch, launchContext, launchPersistentContext } from "./playwright.js"; +export { launch, launchContext, launchPersistentContext, buildLaunchOptions, humanizeBrowser } from "./playwright.js"; // Binary management export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js"; diff --git a/js/src/playwright.ts b/js/src/playwright.ts index eb8426f..55bf14e 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -3,7 +3,7 @@ * Mirrors Python cloakbrowser/browser.py. */ -import type { Browser, BrowserContext, BrowserContextOptions } from "playwright-core"; +import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions as PlaywrightLaunchOptions } from "playwright-core"; import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js"; import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js"; import { buildArgs } from "./args.js"; @@ -44,6 +44,52 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial { + const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); + const { exitIp, ...resolved } = await maybeResolveGeoip(options); + const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy); + let resolvedArgs = await resolveWebrtcArgs(options); + if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) { + resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`]; + } + const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] }); + + return { + executablePath: binaryPath, + headless: options.headless ?? true, + args, + ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, + ...(proxyOption ? { proxy: proxyOption } : {}), + ...options.launchOptions, + } as PlaywrightLaunchOptions; +} + +/** + * Apply CloakBrowser's human-like behavioral layer to an existing Playwright browser. + */ +export async function humanizeBrowser( + browser: Browser, + options: LaunchOptions = {} +): Promise { + if (!options.humanize) return; + + const { patchBrowser } = await import('./human/index.js'); + const { resolveConfig } = await import('./human/config.js'); + const cfg = resolveConfig( + options.humanPreset ?? 'default', + options.humanConfig, + ); + patchBrowser(browser, cfg); +} + /** * Launch stealth Chromium browser via Playwright. * @@ -59,36 +105,8 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial { const { chromium } = await import("playwright-core"); - - const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); - const { exitIp, ...resolved } = await maybeResolveGeoip(options); - const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy); - let resolvedArgs = await resolveWebrtcArgs(options); - if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) { - resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`]; - } - const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] }); - - const browser = await chromium.launch({ - executablePath: binaryPath, - headless: options.headless ?? true, - args, - ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, - ...(proxyOption ? { proxy: proxyOption } : {}), - ...options.launchOptions, - }); - - // Human-like behavioral patching - if (options.humanize) { - const { patchBrowser } = await import('./human/index.js'); - const { resolveConfig } = await import('./human/config.js'); - const cfg = resolveConfig( - options.humanPreset ?? 'default', - options.humanConfig, - ); - patchBrowser(browser, cfg); - } - + const browser = await chromium.launch(await buildLaunchOptions(options)); + await humanizeBrowser(browser, options); return browser; } diff --git a/js/tests/launch.test.ts b/js/tests/launch.test.ts index 43a8fde..ce23f2d 100644 --- a/js/tests/launch.test.ts +++ b/js/tests/launch.test.ts @@ -21,6 +21,70 @@ describe("binaryInfo", () => { }); }); +describe("composable Playwright launch helpers", () => { + const origBinaryPath = process.env.CLOAKBROWSER_BINARY_PATH; + + beforeEach(() => { + process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome"; + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + if (origBinaryPath) { + process.env.CLOAKBROWSER_BINARY_PATH = origBinaryPath; + } else { + delete process.env.CLOAKBROWSER_BINARY_PATH; + } + }); + + it("exports buildLaunchOptions and humanizeBrowser from the package entrypoint", async () => { + const entry = await import("../src/index.js"); + + expect(entry.buildLaunchOptions).toBeTypeOf("function"); + expect(entry.humanizeBrowser).toBeTypeOf("function"); + }); + + it("buildLaunchOptions returns Playwright options without launching a browser", async () => { + const { buildLaunchOptions } = await import("../src/index.js"); + + const options = await buildLaunchOptions({ + headless: false, + proxy: "http://user:pass@proxy.example:8080", + args: ["--custom-flag"], + launchOptions: { timeout: 1234 }, + }); + + expect(options.executablePath).toBe("/fake/chrome"); + expect(options.headless).toBe(false); + expect(options.args).toContain("--custom-flag"); + expect(options.ignoreDefaultArgs).toContain("--enable-automation"); + expect(options.proxy).toEqual({ + server: "http://proxy.example:8080", + username: "user", + password: "pass", + }); + expect(options.timeout).toBe(1234); + }); + + it("humanizeBrowser patches an existing browser only when requested", async () => { + const { humanizeBrowser } = await import("../src/index.js"); + const browser = { + contexts: () => [], + newContext: vi.fn(async () => ({})), + newPage: vi.fn(async () => ({ context: () => ({}) })), + }; + const originalNewContext = browser.newContext; + + await humanizeBrowser(browser as any, { humanize: false }); + expect(browser.newContext).toBe(originalNewContext); + + await humanizeBrowser(browser as any, { humanize: true }); + expect(browser.newContext).not.toBe(originalNewContext); + }); +}); + // Integration tests require the binary — run with: // CLOAKBROWSER_BINARY_PATH=/path/to/chrome npm test describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(