Skip to content

Commit 4245bf2

Browse files
authored
Config fixes required to support multiple browsers. (issue #60) (#67)
1 parent bd85bfe commit 4245bf2

File tree

6 files changed

+116
-26
lines changed

6 files changed

+116
-26
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- Multiple Browsers can be created without one affecting the other @raycardillo
13+
1214
### Added
1315

1416
### Changed

tests/bot_detection/test_browserscan.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
async def test_browserscan(browser: zd.Browser):
55
page = await browser.get("https://www.browserscan.net/bot-detection")
66

7+
# wait for the page to fully load
8+
await page.wait_for_ready_state("complete")
9+
710
# give the javascript some time to finish executing
811
await page.wait(2)
912

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ async def browser(request: pytest.FixtureRequest) -> AsyncGenerator[zd.Browser,
4343
)
4444
browser_pid = browser._process_pid
4545
assert browser_pid is not None and browser_pid > 0
46+
await browser.wait(0)
4647
yield browser
4748
if PAUSE_AFTER_TEST:
4849
logger.info(
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import zendriver as zd
2+
3+
4+
# browser fixture not used here because this is a special case
5+
6+
7+
async def test_multiple_browsers_diff_userdata():
8+
config = zd.Config(
9+
headless=True,
10+
browser_args=[
11+
"--enable-features=UseOzonePlatform",
12+
"--ozone-platform=wayland",
13+
"--window-size=800,800",
14+
"--wait-for-debugger-webui",
15+
],
16+
)
17+
config.browser_connection_timeout = 1
18+
config.browser_connection_max_tries = 15
19+
20+
browser1 = await zd.start(config)
21+
browser2 = await zd.start(config)
22+
browser3 = await zd.start(config)
23+
24+
assert not browser1.config.uses_custom_data_dir
25+
assert not browser2.config.uses_custom_data_dir
26+
assert not browser3.config.uses_custom_data_dir
27+
28+
# make sure ports are unique
29+
ports = {browser1.config.port, browser2.config.port, browser3.config.port}
30+
assert len(ports) == 3
31+
32+
# make sure user data dirs are unique
33+
udds = {
34+
browser1.config.user_data_dir,
35+
browser2.config.user_data_dir,
36+
browser3.config.user_data_dir,
37+
}
38+
assert len(udds) == 3
39+
40+
page1 = await browser1.get("https://example.com/one")
41+
await page1
42+
assert page1.target
43+
assert page1.target.title == "Example Domain"
44+
45+
page2 = await browser2.get("https://example.com/two")
46+
await page2
47+
assert page2.target
48+
assert page2.target.title == "Example Domain"
49+
50+
page3 = await browser3.get("https://example.com/three")
51+
await page3
52+
assert page3.target
53+
assert page3.target.title == "Example Domain"
54+
55+
await browser1.stop()
56+
await browser2.stop()
57+
await browser3.stop()

zendriver/core/browser.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import copy
45
import http
56
import http.cookiejar
67
import json
@@ -101,7 +102,7 @@ async def browser_atexit() -> None:
101102

102103
return instance
103104

104-
def __init__(self, config: Config, **kwargs):
105+
def __init__(self, config: Config):
105106
"""
106107
constructor. to create a instance, use :py:meth:`Browser.create(...)`
107108
@@ -117,15 +118,17 @@ def __init__(self, config: Config, **kwargs):
117118
)
118119
)
119120
# weakref.finalize(self, self._quit, self)
120-
self.config = config
121+
122+
# each instance gets it's own copy so this class gets a copy that it can
123+
# use to help manage the browser instance data (needed for multiple browsers)
124+
self.config = copy.deepcopy(config)
121125

122126
self.targets: List = []
123127
"""current targets (all types)"""
124128
self.info: ContraDict | None = None
125129
self._target = None
126130
self._process = None
127131
self._process_pid = None
128-
self._keep_user_data_dir = None
129132
self._is_updating = asyncio.Event()
130133
self.connection = None
131134
logger.debug("Session object initialized: %s" % vars(self))
@@ -254,7 +257,7 @@ async def get(
254257
raise RuntimeError("Browser not yet started. use await browser.start()")
255258

256259
if new_tab or new_window:
257-
# creat new target using the browser session
260+
# create new target using the browser session
258261
target_id = await self.connection.send(
259262
cdp.target.create_target(
260263
url, new_window=new_window, enable_begin_frame_control=True
@@ -572,21 +575,29 @@ async def stop(self):
572575
logger.debug("closed the connection")
573576

574577
if self._process:
575-
self._process.terminate()
576-
logger.debug("gracefully stopping browser process")
577-
# wait 3 seconds for the browser to stop
578-
for _ in range(12):
579-
if self._process.returncode is not None:
580-
break
581-
await asyncio.sleep(0.25)
582-
else:
583-
logger.debug("browser process did not stop. killing it")
584-
self._process.kill()
585-
logger.debug("killed browser process")
578+
try:
579+
self._process.terminate()
580+
logger.debug("gracefully stopping browser process")
581+
# wait 3 seconds for the browser to stop
582+
for _ in range(12):
583+
if self._process.returncode is not None:
584+
break
585+
await asyncio.sleep(0.25)
586+
else:
587+
logger.debug("browser process did not stop. killing it")
588+
self._process.kill()
589+
logger.debug("killed browser process")
590+
591+
await self._process.wait()
592+
593+
except ProcessLookupError:
594+
# ignore this well known race condition because it only means that
595+
# the process was not found while trying to terminate or kill it
596+
pass
586597

587-
await self._process.wait()
588598
self._process = None
589599
self._process_pid = None
600+
590601
await self._cleanup_temporary_profile()
591602

592603
async def _cleanup_temporary_profile(self) -> None:

zendriver/core/config.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def __init__(
5353
5454
Instances of this class are usually not instantiated by end users.
5555
56-
:param user_data_dir: the data directory to use
56+
:param user_data_dir: the data directory to use (must be unique if using multiple browsers)
5757
:param headless: set to True for headless mode
5858
:param browser_executable_path: specify browser executable, instead of using autodetect
5959
:param browser_args: forwarded to browser executable. eg : ["--some-chromeparam=somevalue", "some-other-param=someval"]
@@ -78,11 +78,12 @@ def __init__(
7878
if not browser_args:
7979
browser_args = []
8080

81-
if not user_data_dir:
82-
self._user_data_dir = temp_profile_dir()
83-
self._custom_data_dir = False
84-
else:
85-
self.user_data_dir = user_data_dir
81+
# defer creating a temp user data dir until the browser requests it so
82+
# config can be used/reused as a template for multiple browser instances
83+
self._user_data_dir = None
84+
self._custom_data_dir = False
85+
if user_data_dir:
86+
self.user_data_dir = str(user_data_dir)
8687

8788
if not browser_executable_path:
8889
browser_executable_path = find_chrome_executable()
@@ -96,10 +97,11 @@ def __init__(
9697
self.port = port
9798
self.expert = expert
9899
self._extensions: list[PathLike] = []
100+
99101
# when using posix-ish operating system and running as root
100102
# you must use no_sandbox = True, which in case is corrected here
101103
if is_posix and is_root() and sandbox:
102-
logger.info("detected root usage, autoo disabling sandbox mode")
104+
logger.info("detected root usage, auto disabling sandbox mode")
103105
self.sandbox = False
104106

105107
self.autodiscover_targets = True
@@ -136,13 +138,27 @@ def browser_args(self):
136138
return sorted(self._default_browser_args + self._browser_args)
137139

138140
@property
139-
def user_data_dir(self):
141+
def user_data_dir(self) -> str:
142+
"""
143+
Get the user data dir or lazily create a new one if unset.
144+
145+
Returns:
146+
str: User data directory (used for Chrome profile)
147+
"""
148+
if not self._user_data_dir:
149+
self._user_data_dir = temp_profile_dir()
150+
self._custom_data_dir = False
151+
140152
return self._user_data_dir
141153

142154
@user_data_dir.setter
143155
def user_data_dir(self, path: PathLike):
144-
self._user_data_dir = str(path)
145-
self._custom_data_dir = True
156+
if path:
157+
self._user_data_dir = str(path)
158+
self._custom_data_dir = True
159+
else:
160+
self._user_data_dir = None
161+
self._custom_data_dir = False
146162

147163
@property
148164
def uses_custom_data_dir(self) -> bool:

0 commit comments

Comments
 (0)