Skip to content

Commit 7f758f9

Browse files
committed
feat(js): export composable launchPersistentContext helpers
1 parent b502301 commit 7f758f9

3 files changed

Lines changed: 121 additions & 65 deletions

File tree

‎js/src/index.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@
1616
*/
1717

1818
// Launch functions (Playwright API)
19-
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
19+
export {
20+
launch,
21+
launchContext,
22+
launchPersistentContext,
23+
buildLaunchOptions,
24+
buildLaunchPersistentContextOptions,
25+
buildContextOptions,
26+
humanizeBrowser,
27+
humanizeContext
28+
} from "./playwright.js";
2029

2130
// Binary management
2231
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";

‎js/src/playwright.ts‎

Lines changed: 54 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
* Mirrors Python cloakbrowser/browser.py.
44
*/
55

6-
import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions as PlaywrightLaunchOptions } from "playwright-core";
6+
import type {
7+
Browser,
8+
BrowserContext,
9+
BrowserContextOptions,
10+
BrowserType,
11+
LaunchOptions as PlaywrightLaunchOptions
12+
} from "playwright-core";
713
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
814
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
915
import { buildArgs } from "./args.js";
@@ -71,6 +77,7 @@ function effectiveHeadless(options: LaunchOptions): boolean {
7177
export function buildContextOptions(
7278
options: LaunchContextOptions = {}
7379
): BrowserContextOptions {
80+
options = resolveTimezone(options);
7481
// Headed: viewport=null (no emulation) so the page tracks the real window and
7582
// outerWidth >= innerWidth stays coherent — CDP viewport emulation forces
7683
// inner > outer = a physically impossible window = bot tell. Headless has no
@@ -123,6 +130,27 @@ export async function buildLaunchOptions(
123130
} as PlaywrightLaunchOptions;
124131
}
125132

133+
/**
134+
* Build Playwright launchPersistentContextOptions options for CloakBrowser
135+
* without starting Chromium.
136+
*
137+
* Useful when integrating CloakBrowser with a custom Playwright build or another
138+
* wrapper that needs to call `chromium.launchPersistentContext()` itself.
139+
*/
140+
export async function buildLaunchPersistentContextOptions(
141+
options: LaunchPersistentContextOptions
142+
): Promise<Parameters<BrowserType["launchPersistentContext"]>[1]> {
143+
const launchOptions = await buildLaunchOptions(options);
144+
const contextOptions = buildContextOptions(options);
145+
146+
seedWidevineHint(options.userDataDir, launchOptions.executablePath as string);
147+
148+
return {
149+
...launchOptions,
150+
...contextOptions,
151+
}
152+
}
153+
126154
/**
127155
* Apply CloakBrowser's human-like behavioral layer to an existing Playwright browser.
128156
*/
@@ -141,6 +169,24 @@ export async function humanizeBrowser(
141169
patchBrowser(browser, cfg);
142170
}
143171

172+
/**
173+
* Apply CloakBrowser's human-like behavioral layer to an existing Playwright context.
174+
*/
175+
export async function humanizeContext(
176+
context: BrowserContext,
177+
options: LaunchContextOptions = {}
178+
): Promise<void> {
179+
if (!options.humanize) return;
180+
181+
const { patchContext } = await import('./human/index.js');
182+
const { resolveConfig } = await import('./human/config.js');
183+
const cfg = resolveConfig(
184+
options.humanPreset ?? 'default',
185+
options.humanConfig,
186+
);
187+
patchContext(context, cfg);
188+
}
189+
144190
/**
145191
* Launch stealth Chromium browser via Playwright.
146192
*
@@ -203,20 +249,7 @@ function applyDefaultNoViewport(browser: Browser): void {
203249
export async function launchContext(
204250
options: LaunchContextOptions = {}
205251
): Promise<BrowserContext> {
206-
options = resolveTimezone(options);
207-
// Resolve geoip BEFORE launch() to avoid double-resolution
208-
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
209-
let launchArgs = await resolveWebrtcArgs(options);
210-
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
211-
if (exitIp && !(launchArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
212-
launchArgs = [...(launchArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
213-
}
214-
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
215-
// so it applies to ALL contexts, not just the default one.
216-
// locale and timezone are set via binary flags only — no CDP emulation.
217-
// humanize:false on the inner launch — patchContext below applies humanize
218-
// exactly once (else launch()'s humanizeBrowser would patch it a second time).
219-
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false, humanize: false });
252+
const browser = await launch(options);
220253

221254
let context: BrowserContext;
222255
try {
@@ -233,17 +266,7 @@ export async function launchContext(
233266
await browser.close();
234267
};
235268

236-
// Human-like behavioral patching
237-
if (options.humanize) {
238-
const { patchContext } = await import('./human/index.js');
239-
const { resolveConfig } = await import('./human/config.js');
240-
const cfg = resolveConfig(
241-
options.humanPreset ?? 'default',
242-
options.humanConfig,
243-
);
244-
patchContext(context, cfg);
245-
}
246-
269+
await humanizeContext(context, options);
247270
return context;
248271
}
249272

@@ -271,45 +294,12 @@ export async function launchContext(
271294
export async function launchPersistentContext(
272295
options: LaunchPersistentContextOptions
273296
): Promise<BrowserContext> {
274-
options = resolveTimezone(options);
275297
const { chromium } = await import("playwright-core");
276-
277-
const binaryPath =
278-
process.env.CLOAKBROWSER_BINARY_PATH ||
279-
(await ensureBinary(options.licenseKey, options.browserVersion));
280-
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
281-
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion);
282-
let resolvedArgs = await resolveWebrtcArgs(options);
283-
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
284-
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
285-
}
286-
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
287-
288-
seedWidevineHint(options.userDataDir, binaryPath);
289-
290-
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
291-
// — NOT via Playwright context kwargs which use detectable CDP emulation.
292-
const context = await chromium.launchPersistentContext(options.userDataDir, {
293-
executablePath: binaryPath,
294-
headless: options.headless ?? true,
295-
args,
296-
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
297-
...(proxyOption ? { proxy: proxyOption } : {}),
298-
...buildContextOptions(options),
299-
...options.launchOptions,
300-
});
301-
302-
// Human-like behavioral patching
303-
if (options.humanize) {
304-
const { patchContext } = await import('./human/index.js');
305-
const { resolveConfig } = await import('./human/config.js');
306-
const cfg = resolveConfig(
307-
options.humanPreset ?? 'default',
308-
options.humanConfig,
309-
);
310-
patchContext(context, cfg);
311-
}
312-
298+
const context = await chromium.launchPersistentContext(
299+
options.userDataDir,
300+
await buildLaunchPersistentContextOptions(options),
301+
);
302+
await humanizeContext(context, options);
313303
return context;
314304
}
315305

‎js/tests/launch.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,47 @@ describe("composable Playwright launch helpers", () => {
176176
}
177177
});
178178

179+
it("buildLaunchPersistentContextOptions returns Playwright options without launching a browser", async () => {
180+
const freshConfig = await import("../src/config.js");
181+
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
182+
try {
183+
const { buildLaunchPersistentContextOptions } = await import("../src/index.js");
184+
185+
const options = await buildLaunchPersistentContextOptions({
186+
userDataDir: "/fake/data-dir",
187+
headless: false,
188+
proxy: "http://user:pass@proxy.example:8080",
189+
args: ["--custom-flag"],
190+
viewport: { width: 1900, height: 1080 },
191+
colorScheme: "dark",
192+
launchOptions: { slowMo: 1234 },
193+
contextOptions: {
194+
acceptDownloads: true,
195+
locale: "fr-FR",
196+
},
197+
});
198+
199+
expect(options).toMatchObject({
200+
executablePath: "/fake/chrome",
201+
headless: false,
202+
proxy: {
203+
server: "http://proxy.example:8080",
204+
username: "user",
205+
password: "pass",
206+
},
207+
viewport: { width: 1900, height: 1080 },
208+
colorScheme: "dark",
209+
slowMo: 1234,
210+
acceptDownloads: true,
211+
});
212+
expect(options.args).toContain("--custom-flag");
213+
expect(options.ignoreDefaultArgs).toContain("--enable-automation");
214+
expect(options.locale).toBeUndefined();
215+
} finally {
216+
vi.restoreAllMocks();
217+
}
218+
});
219+
179220
it("humanizeBrowser patches an existing browser only when requested", async () => {
180221
const { humanizeBrowser } = await import("../src/index.js");
181222
const browser = {
@@ -191,6 +232,22 @@ describe("composable Playwright launch helpers", () => {
191232
await humanizeBrowser(browser as any, { humanize: true });
192233
expect(browser.newContext).not.toBe(originalNewContext);
193234
});
235+
236+
it("humanizeContext patches an existing context only when requested", async () => {
237+
const { humanizeContext } = await import("../src/index.js");
238+
const context = {
239+
pages: () => [],
240+
on: vi.fn(() => undefined),
241+
newPage: vi.fn(async () => ({})),
242+
};
243+
const originalNewPage = context.newPage;
244+
245+
await humanizeContext(context as any, { humanize: false });
246+
expect(context.newPage).toBe(originalNewPage);
247+
248+
await humanizeContext(context as any, { humanize: true });
249+
expect(context.newPage).not.toBe(originalNewPage);
250+
});
194251
});
195252

196253
// Integration tests require the binary — run with:

0 commit comments

Comments
 (0)