playwright-byob 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ name: CI tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - main
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
17
+ fail-fast: false
18
+
19
+ steps:
20
+ - uses: actions/checkout@v7
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v6
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ - name: Install dependencies
26
+ run: |
27
+ # we are using the -e flag, so that code cov finds the source.
28
+ # this is not ideal, since installing an editable can technically
29
+ # differ from a normal install in surprising ways.
30
+ pip install -e '.[all]'
31
+ - name: Test with pytest
32
+ run: |
33
+ pip install pytest
34
+ pytest
35
+
36
+ # - name: Upload coverage reports to Codecov
37
+ # uses: codecov/codecov-action@v4
38
+ # with:
39
+ # name: "py${{ matrix.python-version }}"
40
+ # token: ${{ secrets.CODECOV_TOKEN }}
41
+
42
+ test-windows:
43
+ runs-on: windows-latest
44
+ steps:
45
+ - uses: actions/checkout@v7
46
+ - name: Set up Python
47
+ uses: actions/setup-python@v6
48
+ with:
49
+ python-version: "3.14"
50
+ - name: Install dependencies
51
+ run: |
52
+ pip install -e '.[all]'
53
+ - name: Test with pytest
54
+ run: |
55
+ pip install pytest
56
+ pytest
@@ -0,0 +1,31 @@
1
+ name: Documentation
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: read
10
+ pages: write
11
+ id-token: write
12
+
13
+ jobs:
14
+ deploy:
15
+ environment:
16
+ name: github-pages
17
+ url: ${{ steps.deployment.outputs.page_url }}
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/configure-pages@v6
21
+ - uses: actions/checkout@v7
22
+ - uses: actions/setup-python@v6
23
+ with:
24
+ python-version: 3.x
25
+ - run: pip install zensical mkdocstrings-python
26
+ - run: zensical build --clean
27
+ - uses: actions/upload-pages-artifact@v5
28
+ with:
29
+ path: site
30
+ - uses: actions/deploy-pages@v5
31
+ id: deployment
@@ -0,0 +1,34 @@
1
+ name: Mypy check
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - "**"
10
+
11
+ jobs:
12
+ mypy:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v7
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v6
21
+ with:
22
+ python-version: "3.14"
23
+
24
+ - name: Install uv
25
+ run: |
26
+ pip install uv
27
+
28
+ - name: Install dependencies
29
+ run: |
30
+ uv sync --dev
31
+
32
+ - name: Run Type Checks
33
+ run: |
34
+ uv run mypy .
@@ -0,0 +1,25 @@
1
+ name: Ruff check
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - "**"
10
+
11
+ jobs:
12
+ ruff-check:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - name: Install Python
17
+ uses: actions/setup-python@v6
18
+ with:
19
+ python-version: "3.14"
20
+ - name: Install dependencies
21
+ run: |
22
+ python -m pip install --upgrade pip
23
+ pip install ruff
24
+ - name: Run Ruff
25
+ run: ruff check --output-format=github .
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Zensical site
13
+ /site/
@@ -0,0 +1 @@
1
+ 3.14.6
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## playwright-byob 0.1.0
4
+
5
+ ### New features
6
+
7
+ - Add sync and async helpers, `launch_chrome()` and `async_launch_chrome()`,
8
+ for launching Playwright with a persistent Google Chrome context.
9
+ - Add automatic detection for installed Google Chrome and the platform Chrome
10
+ user data directory on macOS, Windows, and Linux.
11
+ - Add sensible headed Chrome defaults: persistent profile,
12
+ `Default` profile selection, no fixed Playwright viewport, maximized window,
13
+ and removal of Playwright's `--enable-automation` default argument.
14
+ - Add explicit customization for Chrome executable path, user data directory,
15
+ profile directory, Chrome flags, and arbitrary Playwright launch options.
16
+ - Add environment variable overrides via `PLAYWRIGHT_BYOB_CHROME_PATH`,
17
+ `PLAYWRIGHT_BYOB_USER_DATA_DIR`, and `PLAYWRIGHT_BYOB_PROFILE_DIRECTORY`.
18
+ - Add `build_chrome_launch_config()` for tests, dry runs, and inspecting
19
+ resolved launch options without starting a browser.
20
+ - Add typed public exceptions for missing Chrome executables,
21
+ missing Chrome profile directories, and invalid configuration.
22
+
23
+ ### Documentation
24
+
25
+ - Add README and Zensical documentation covering installation,
26
+ sync and async usage, defaults, customization, environment variables,
27
+ and privacy guidance.
28
+ - Add coding-agent guidance documenting privacy rules, development commands,
29
+ and design preferences for future maintenance.
30
+
31
+ ### Testing
32
+
33
+ - Add a pytest suite that verifies launch configuration and platform detection
34
+ without reading real user browser data or requiring Chrome in CI.
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026, playwright-byob authors
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: playwright-byob
3
+ Version: 0.1.0
4
+ Summary: Playwright but bring your own browser
5
+ Project-URL: Homepage, https://nanx.me/playwright-byob/
6
+ Project-URL: Documentation, https://nanx.me/playwright-byob/
7
+ Project-URL: Repository, https://github.com/nanxstats/playwright-byob
8
+ Project-URL: Issues, https://github.com/nanxstats/playwright-byob/issues
9
+ Project-URL: Changelog, https://github.com/nanxstats/playwright-byob/blob/main/CHANGELOG.md
10
+ Author-email: Nan Xiao <me@nanx.me>
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
23
+ Classifier: Topic :: Software Development :: Testing
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: playwright>=1.60.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # playwright-byob
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/playwright-byob)](https://pypi.org/project/playwright-byob/)
31
+ ![Python versions](https://img.shields.io/pypi/pyversions/playwright-byob)
32
+ [![CI tests](https://github.com/nanxstats/playwright-byob/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/nanxstats/playwright-byob/actions/workflows/ci-tests.yml)
33
+ [![Mypy check](https://github.com/nanxstats/playwright-byob/actions/workflows/mypy.yml/badge.svg)](https://github.com/nanxstats/playwright-byob/actions/workflows/mypy.yml)
34
+ [![Ruff check](https://github.com/nanxstats/playwright-byob/actions/workflows/ruff-check.yml/badge.svg)](https://github.com/nanxstats/playwright-byob/actions/workflows/ruff-check.yml)
35
+ [![Documentation](https://github.com/nanxstats/playwright-byob/actions/workflows/docs.yml/badge.svg)](https://nanx.me/playwright-byob/)
36
+ ![License](https://img.shields.io/pypi/l/playwright-byob)
37
+
38
+ Bring your own browser to Playwright.
39
+
40
+ playwright-byob is a tiny Python helper for launching Playwright against the
41
+ real Google Chrome installation and profile already present on a machine.
42
+ It keeps the API close to Playwright, but chooses practical defaults for headed,
43
+ persistent Chrome automation.
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install playwright-byob
49
+ ```
50
+
51
+ With `uv`:
52
+
53
+ ```bash
54
+ uv add playwright-byob
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ ```python
60
+ from playwright.sync_api import sync_playwright
61
+ from playwright_byob import launch_chrome
62
+
63
+ with sync_playwright() as p:
64
+ context = launch_chrome(p)
65
+ page = context.new_page()
66
+ page.goto("https://example.com")
67
+ print(page.title())
68
+ context.close()
69
+ ```
70
+
71
+ The default launch uses installed Chrome when detected, falls back to
72
+ Playwright's `channel="chrome"`, opens headed, uses the platform Chrome user
73
+ data directory, selects the `Default` profile, disables Playwright's fixed
74
+ viewport, and removes the `--enable-automation` default argument.
75
+
76
+ ## Customize the browser or profile
77
+
78
+ ```python
79
+ from playwright.sync_api import sync_playwright
80
+ from playwright_byob import launch_chrome
81
+
82
+ with sync_playwright() as p:
83
+ context = launch_chrome(
84
+ p,
85
+ browser_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
86
+ user_data_dir="~/Library/Application Support/Google/Chrome",
87
+ profile_directory="Profile 1",
88
+ args=["--window-size=1440,1000"],
89
+ timeout=30_000,
90
+ )
91
+ ```
92
+
93
+ You can also use environment variables:
94
+
95
+ - `PLAYWRIGHT_BYOB_CHROME_PATH`
96
+ - `PLAYWRIGHT_BYOB_USER_DATA_DIR`
97
+ - `PLAYWRIGHT_BYOB_PROFILE_DIRECTORY`
98
+
99
+ ## Privacy note
100
+
101
+ A real Chrome profile can contain cookies, local storage, saved sessions,
102
+ and other sensitive state, **so use this intentionally**.
103
+ Tests in this project never read or launch a real user profile.
104
+ They use temporary directories and fake Playwright objects.
@@ -0,0 +1,77 @@
1
+ # playwright-byob
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/playwright-byob)](https://pypi.org/project/playwright-byob/)
4
+ ![Python versions](https://img.shields.io/pypi/pyversions/playwright-byob)
5
+ [![CI tests](https://github.com/nanxstats/playwright-byob/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/nanxstats/playwright-byob/actions/workflows/ci-tests.yml)
6
+ [![Mypy check](https://github.com/nanxstats/playwright-byob/actions/workflows/mypy.yml/badge.svg)](https://github.com/nanxstats/playwright-byob/actions/workflows/mypy.yml)
7
+ [![Ruff check](https://github.com/nanxstats/playwright-byob/actions/workflows/ruff-check.yml/badge.svg)](https://github.com/nanxstats/playwright-byob/actions/workflows/ruff-check.yml)
8
+ [![Documentation](https://github.com/nanxstats/playwright-byob/actions/workflows/docs.yml/badge.svg)](https://nanx.me/playwright-byob/)
9
+ ![License](https://img.shields.io/pypi/l/playwright-byob)
10
+
11
+ Bring your own browser to Playwright.
12
+
13
+ playwright-byob is a tiny Python helper for launching Playwright against the
14
+ real Google Chrome installation and profile already present on a machine.
15
+ It keeps the API close to Playwright, but chooses practical defaults for headed,
16
+ persistent Chrome automation.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install playwright-byob
22
+ ```
23
+
24
+ With `uv`:
25
+
26
+ ```bash
27
+ uv add playwright-byob
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from playwright.sync_api import sync_playwright
34
+ from playwright_byob import launch_chrome
35
+
36
+ with sync_playwright() as p:
37
+ context = launch_chrome(p)
38
+ page = context.new_page()
39
+ page.goto("https://example.com")
40
+ print(page.title())
41
+ context.close()
42
+ ```
43
+
44
+ The default launch uses installed Chrome when detected, falls back to
45
+ Playwright's `channel="chrome"`, opens headed, uses the platform Chrome user
46
+ data directory, selects the `Default` profile, disables Playwright's fixed
47
+ viewport, and removes the `--enable-automation` default argument.
48
+
49
+ ## Customize the browser or profile
50
+
51
+ ```python
52
+ from playwright.sync_api import sync_playwright
53
+ from playwright_byob import launch_chrome
54
+
55
+ with sync_playwright() as p:
56
+ context = launch_chrome(
57
+ p,
58
+ browser_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
59
+ user_data_dir="~/Library/Application Support/Google/Chrome",
60
+ profile_directory="Profile 1",
61
+ args=["--window-size=1440,1000"],
62
+ timeout=30_000,
63
+ )
64
+ ```
65
+
66
+ You can also use environment variables:
67
+
68
+ - `PLAYWRIGHT_BYOB_CHROME_PATH`
69
+ - `PLAYWRIGHT_BYOB_USER_DATA_DIR`
70
+ - `PLAYWRIGHT_BYOB_PROFILE_DIRECTORY`
71
+
72
+ ## Privacy note
73
+
74
+ A real Chrome profile can contain cookies, local storage, saved sessions,
75
+ and other sensitive state, **so use this intentionally**.
76
+ Tests in this project never read or launch a real user profile.
77
+ They use temporary directories and fake Playwright objects.
@@ -0,0 +1,82 @@
1
+ [project]
2
+ name = "playwright-byob"
3
+ version = "0.1.0"
4
+ description = "Playwright but bring your own browser"
5
+ authors = [{ name = "Nan Xiao", email = "me@nanx.me" }]
6
+ readme = "README.md"
7
+ dependencies = ["playwright>=1.60.0"]
8
+
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+
12
+ "Intended Audience :: Developers",
13
+
14
+ "Topic :: Software Development :: Testing",
15
+ "Topic :: Internet :: WWW/HTTP :: Browsers",
16
+
17
+ "Operating System :: OS Independent",
18
+
19
+ "License :: OSI Approved :: MIT License",
20
+
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ ]
28
+
29
+ requires-python = ">=3.10"
30
+
31
+ [project.urls]
32
+ Homepage = "https://nanx.me/playwright-byob/"
33
+ Documentation = "https://nanx.me/playwright-byob/"
34
+ Repository = "https://github.com/nanxstats/playwright-byob"
35
+ Issues = "https://github.com/nanxstats/playwright-byob/issues"
36
+ Changelog = "https://github.com/nanxstats/playwright-byob/blob/main/CHANGELOG.md"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ exclude = ["/docs", "/scripts", "/AGENTS.md", "/uv.lock", "/zensical.toml"]
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ exclude = ["/docs", "/scripts", "/AGENTS.md", "/uv.lock", "/zensical.toml"]
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "isort>=8.0.1",
51
+ "mkdocstrings-python>=2.0.5",
52
+ "mypy>=2.1.0",
53
+ "pytest>=9.1.1",
54
+ "ruff>=0.15.19",
55
+ "zensical>=0.0.46",
56
+ ]
57
+
58
+ [tool.ruff]
59
+ exclude = ["vendor", ".venv"]
60
+
61
+ [tool.ruff.lint]
62
+ select = [
63
+ # pycodestyle
64
+ "E",
65
+ # Pyflakes
66
+ "F",
67
+ # pyupgrade
68
+ "UP",
69
+ # flake8-bugbear
70
+ "B",
71
+ # flake8-simplify
72
+ "SIM",
73
+ # isort
74
+ "I",
75
+ ]
76
+
77
+ [tool.isort]
78
+ profile = "black"
79
+ skip = ["vendor", ".venv"]
80
+
81
+ [tool.mypy]
82
+ exclude = ["vendor/"]
@@ -0,0 +1,53 @@
1
+ """Bring your own browser to Playwright."""
2
+
3
+ from ._chrome import (
4
+ CHROME_PATH_ENV,
5
+ DEFAULT_CHANNEL,
6
+ DEFAULT_CHROME_ARGS,
7
+ DEFAULT_IGNORE_DEFAULT_ARGS,
8
+ DEFAULT_PROFILE_DIRECTORY,
9
+ PROFILE_DIRECTORY_ENV,
10
+ USER_DATA_DIR_ENV,
11
+ ChromeLaunchConfig,
12
+ ChromeNotFoundError,
13
+ ChromeProfileNotFoundError,
14
+ ConfigurationError,
15
+ IgnoreDefaultArgs,
16
+ PathLike,
17
+ PathSetting,
18
+ PlaywrightByobError,
19
+ ProfileSetting,
20
+ async_launch_chrome,
21
+ build_chrome_launch_config,
22
+ chrome_executable_candidates,
23
+ chrome_user_data_dir_candidates,
24
+ detect_chrome_executable,
25
+ detect_chrome_user_data_dir,
26
+ launch_chrome,
27
+ )
28
+
29
+ __all__ = [
30
+ "CHROME_PATH_ENV",
31
+ "DEFAULT_CHANNEL",
32
+ "DEFAULT_CHROME_ARGS",
33
+ "DEFAULT_IGNORE_DEFAULT_ARGS",
34
+ "DEFAULT_PROFILE_DIRECTORY",
35
+ "PROFILE_DIRECTORY_ENV",
36
+ "USER_DATA_DIR_ENV",
37
+ "ChromeLaunchConfig",
38
+ "ChromeNotFoundError",
39
+ "ChromeProfileNotFoundError",
40
+ "ConfigurationError",
41
+ "IgnoreDefaultArgs",
42
+ "PathLike",
43
+ "PathSetting",
44
+ "ProfileSetting",
45
+ "PlaywrightByobError",
46
+ "async_launch_chrome",
47
+ "build_chrome_launch_config",
48
+ "chrome_executable_candidates",
49
+ "chrome_user_data_dir_candidates",
50
+ "detect_chrome_executable",
51
+ "detect_chrome_user_data_dir",
52
+ "launch_chrome",
53
+ ]
@@ -0,0 +1,502 @@
1
+ """Utilities for launching Playwright with an installed Google Chrome profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Literal, TypeAlias
12
+
13
+ from playwright.async_api import BrowserContext as AsyncBrowserContext
14
+ from playwright.async_api import Playwright as AsyncPlaywright
15
+ from playwright.sync_api import BrowserContext as SyncBrowserContext
16
+ from playwright.sync_api import Playwright as SyncPlaywright
17
+
18
+ Auto: TypeAlias = Literal["auto"]
19
+ PathLike: TypeAlias = str | os.PathLike[str]
20
+ PathSetting: TypeAlias = PathLike | Auto | None
21
+ ProfileSetting: TypeAlias = str | Auto | None
22
+ IgnoreDefaultArgs: TypeAlias = bool | Sequence[str] | None
23
+
24
+ CHROME_PATH_ENV = "PLAYWRIGHT_BYOB_CHROME_PATH"
25
+ USER_DATA_DIR_ENV = "PLAYWRIGHT_BYOB_USER_DATA_DIR"
26
+ PROFILE_DIRECTORY_ENV = "PLAYWRIGHT_BYOB_PROFILE_DIRECTORY"
27
+ DEFAULT_CHANNEL = "chrome"
28
+ DEFAULT_PROFILE_DIRECTORY = "Default"
29
+ DEFAULT_IGNORE_DEFAULT_ARGS: tuple[str, ...] = ("--enable-automation",)
30
+ DEFAULT_CHROME_ARGS: tuple[str, ...] = (
31
+ "--disable-blink-features=AutomationControlled",
32
+ )
33
+
34
+
35
+ class PlaywrightByobError(RuntimeError):
36
+ """Base exception for playwright-byob failures."""
37
+
38
+
39
+ class ChromeNotFoundError(PlaywrightByobError):
40
+ """Raised when an explicitly requested Chrome executable cannot be found."""
41
+
42
+
43
+ class ChromeProfileNotFoundError(PlaywrightByobError):
44
+ """Raised when the default Chrome user data directory cannot be found."""
45
+
46
+
47
+ class ConfigurationError(PlaywrightByobError, ValueError):
48
+ """Raised when launch configuration is invalid."""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ChromeLaunchConfig:
53
+ """Resolved arguments for ``chromium.launch_persistent_context``.
54
+
55
+ ``user_data_dir`` is passed as the first positional argument. ``options`` is
56
+ expanded as keyword arguments.
57
+ """
58
+
59
+ user_data_dir: Path
60
+ options: Mapping[str, Any]
61
+
62
+ def to_playwright_kwargs(self) -> dict[str, Any]:
63
+ """Return a mutable copy of the keyword arguments for Playwright."""
64
+ return dict(self.options)
65
+
66
+
67
+ def chrome_executable_candidates(
68
+ *,
69
+ sys_platform: str | None = None,
70
+ env: Mapping[str, str] | None = None,
71
+ ) -> tuple[Path, ...]:
72
+ """Return plausible Google Chrome executable paths for the current platform.
73
+
74
+ The function only builds candidates; it does not read profile data or launch
75
+ Chrome. The ``PLAYWRIGHT_BYOB_CHROME_PATH`` environment variable, when set,
76
+ is returned first.
77
+ """
78
+ platform = sys_platform or sys.platform
79
+ environ = os.environ if env is None else env
80
+ candidates: list[Path] = []
81
+
82
+ env_path = environ.get(CHROME_PATH_ENV)
83
+ if env_path:
84
+ candidates.append(Path(env_path).expanduser())
85
+
86
+ if platform == "darwin":
87
+ candidates.append(
88
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
89
+ )
90
+ home = _home_path(environ)
91
+ if home is not None:
92
+ candidates.append(
93
+ home
94
+ / "Applications"
95
+ / "Google Chrome.app"
96
+ / "Contents"
97
+ / "MacOS"
98
+ / "Google Chrome"
99
+ )
100
+ elif platform.startswith("win"):
101
+ for key in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"):
102
+ root = environ.get(key)
103
+ if root:
104
+ candidates.append(
105
+ Path(root) / "Google" / "Chrome" / "Application" / "chrome.exe"
106
+ )
107
+ else:
108
+ for command in (
109
+ "google-chrome",
110
+ "google-chrome-stable",
111
+ "chrome",
112
+ ):
113
+ found = shutil.which(
114
+ command,
115
+ path=environ.get("PATH") if env is None else environ.get("PATH", ""),
116
+ )
117
+ if found:
118
+ candidates.append(Path(found))
119
+ candidates.extend(
120
+ [
121
+ Path("/usr/bin/google-chrome"),
122
+ Path("/usr/bin/google-chrome-stable"),
123
+ Path("/opt/google/chrome/chrome"),
124
+ ]
125
+ )
126
+
127
+ return _dedupe_paths(candidates)
128
+
129
+
130
+ def detect_chrome_executable(
131
+ browser_path: PathSetting = "auto",
132
+ *,
133
+ sys_platform: str | None = None,
134
+ env: Mapping[str, str] | None = None,
135
+ ) -> Path | None:
136
+ """Return an existing Google Chrome executable, or ``None`` if not found.
137
+
138
+ Pass ``browser_path`` to check one explicit path. With the default
139
+ ``"auto"``, common platform locations and ``PLAYWRIGHT_BYOB_CHROME_PATH``
140
+ are checked.
141
+ """
142
+ explicit = _coerce_optional_path(browser_path)
143
+ if explicit is not None:
144
+ return explicit if explicit.exists() else None
145
+
146
+ for candidate in chrome_executable_candidates(
147
+ sys_platform=sys_platform,
148
+ env=env,
149
+ ):
150
+ if candidate.exists():
151
+ return candidate
152
+ return None
153
+
154
+
155
+ def chrome_user_data_dir_candidates(
156
+ *,
157
+ sys_platform: str | None = None,
158
+ env: Mapping[str, str] | None = None,
159
+ ) -> tuple[Path, ...]:
160
+ """Return plausible Google Chrome user data directories.
161
+
162
+ These are profile roots such as ``.../Google/Chrome`` on macOS or
163
+ ``.../Google/Chrome/User Data`` on Windows. They may contain profile folders
164
+ named ``Default``, ``Profile 1``, and so on.
165
+ """
166
+ platform = sys_platform or sys.platform
167
+ environ = os.environ if env is None else env
168
+ candidates: list[Path] = []
169
+
170
+ env_path = environ.get(USER_DATA_DIR_ENV)
171
+ if env_path:
172
+ candidates.append(Path(env_path).expanduser())
173
+
174
+ if platform == "darwin":
175
+ home = _home_path(environ)
176
+ if home is not None:
177
+ candidates.append(
178
+ home / "Library" / "Application Support" / "Google" / "Chrome"
179
+ )
180
+ elif platform.startswith("win"):
181
+ local_app_data = environ.get("LOCALAPPDATA")
182
+ if local_app_data:
183
+ candidates.append(Path(local_app_data) / "Google" / "Chrome" / "User Data")
184
+ else:
185
+ home = _home_path(environ)
186
+ if home is not None:
187
+ candidates.append(home / ".config" / "google-chrome")
188
+
189
+ return _dedupe_paths(candidates)
190
+
191
+
192
+ def detect_chrome_user_data_dir(
193
+ user_data_dir: PathSetting = "auto",
194
+ *,
195
+ sys_platform: str | None = None,
196
+ env: Mapping[str, str] | None = None,
197
+ ) -> Path | None:
198
+ """Return an existing Google Chrome user data directory, or ``None``."""
199
+ explicit = _coerce_optional_path(user_data_dir)
200
+ if explicit is not None:
201
+ return explicit if explicit.exists() else None
202
+
203
+ for candidate in chrome_user_data_dir_candidates(
204
+ sys_platform=sys_platform,
205
+ env=env,
206
+ ):
207
+ if candidate.exists():
208
+ return candidate
209
+ return None
210
+
211
+
212
+ def build_chrome_launch_config(
213
+ *,
214
+ browser_path: PathSetting = "auto",
215
+ user_data_dir: PathSetting = "auto",
216
+ profile_directory: ProfileSetting = "auto",
217
+ channel: str | None = DEFAULT_CHANNEL,
218
+ headless: bool | None = False,
219
+ args: Sequence[str] | None = None,
220
+ default_args: bool = True,
221
+ ignore_default_args: IgnoreDefaultArgs = DEFAULT_IGNORE_DEFAULT_ARGS,
222
+ no_viewport: bool | None = True,
223
+ sys_platform: str | None = None,
224
+ env: Mapping[str, str] | None = None,
225
+ **launch_options: Any,
226
+ ) -> ChromeLaunchConfig:
227
+ """Build resolved launch parameters for Playwright's persistent context.
228
+
229
+ Defaults are intentionally tuned for using installed Google Chrome in headed
230
+ mode with a persistent profile:
231
+
232
+ - use the installed Chrome executable when it can be found;
233
+ - otherwise ask Playwright to use the branded ``chrome`` channel;
234
+ - use the existing platform Chrome user data directory;
235
+ - select the ``Default`` Chrome profile directory;
236
+ - run headed with Playwright's fixed viewport disabled;
237
+ - hide Playwright's ``--enable-automation`` default argument.
238
+
239
+ Extra ``launch_options`` are passed directly to Playwright and can override
240
+ most defaults. Use ``args`` for additional Chrome flags, or set
241
+ ``default_args=False`` to opt out of this package's default Chrome flags.
242
+ """
243
+ environ = os.environ if env is None else env
244
+ resolved_user_data_dir = _resolve_user_data_dir(
245
+ user_data_dir,
246
+ sys_platform=sys_platform,
247
+ env=environ,
248
+ )
249
+ resolved_profile_directory = _resolve_profile_directory(
250
+ profile_directory,
251
+ env=environ,
252
+ )
253
+ resolved_browser_path = _resolve_browser_path(
254
+ browser_path,
255
+ sys_platform=sys_platform,
256
+ env=environ,
257
+ )
258
+
259
+ options: dict[str, Any] = {}
260
+ if resolved_browser_path is not None:
261
+ options["executable_path"] = resolved_browser_path
262
+ elif channel is not None:
263
+ options["channel"] = channel
264
+
265
+ if headless is not None:
266
+ options["headless"] = headless
267
+
268
+ launch_args = _build_chrome_args(
269
+ profile_directory=resolved_profile_directory,
270
+ headless=headless,
271
+ args=args,
272
+ default_args=default_args,
273
+ )
274
+ if launch_args:
275
+ options["args"] = launch_args
276
+
277
+ if ignore_default_args is not None:
278
+ options["ignore_default_args"] = ignore_default_args
279
+
280
+ if no_viewport is not None and "viewport" not in launch_options:
281
+ options["no_viewport"] = no_viewport
282
+
283
+ options.update(launch_options)
284
+ return ChromeLaunchConfig(
285
+ user_data_dir=resolved_user_data_dir,
286
+ options=options,
287
+ )
288
+
289
+
290
+ def launch_chrome(
291
+ playwright: SyncPlaywright,
292
+ *,
293
+ browser_path: PathSetting = "auto",
294
+ user_data_dir: PathSetting = "auto",
295
+ profile_directory: ProfileSetting = "auto",
296
+ channel: str | None = DEFAULT_CHANNEL,
297
+ headless: bool | None = False,
298
+ args: Sequence[str] | None = None,
299
+ default_args: bool = True,
300
+ ignore_default_args: IgnoreDefaultArgs = DEFAULT_IGNORE_DEFAULT_ARGS,
301
+ no_viewport: bool | None = True,
302
+ **launch_options: Any,
303
+ ) -> SyncBrowserContext:
304
+ """Launch a sync Playwright persistent context with installed Chrome.
305
+
306
+ Example:
307
+ ```python
308
+ from playwright.sync_api import sync_playwright
309
+ from playwright_byob import launch_chrome
310
+
311
+ with sync_playwright() as p:
312
+ context = launch_chrome(p)
313
+ page = context.new_page()
314
+ page.goto("https://example.com")
315
+ context.close()
316
+ ```
317
+ """
318
+ config = build_chrome_launch_config(
319
+ browser_path=browser_path,
320
+ user_data_dir=user_data_dir,
321
+ profile_directory=profile_directory,
322
+ channel=channel,
323
+ headless=headless,
324
+ args=args,
325
+ default_args=default_args,
326
+ ignore_default_args=ignore_default_args,
327
+ no_viewport=no_viewport,
328
+ **launch_options,
329
+ )
330
+ return playwright.chromium.launch_persistent_context(
331
+ config.user_data_dir,
332
+ **config.to_playwright_kwargs(),
333
+ )
334
+
335
+
336
+ async def async_launch_chrome(
337
+ playwright: AsyncPlaywright,
338
+ *,
339
+ browser_path: PathSetting = "auto",
340
+ user_data_dir: PathSetting = "auto",
341
+ profile_directory: ProfileSetting = "auto",
342
+ channel: str | None = DEFAULT_CHANNEL,
343
+ headless: bool | None = False,
344
+ args: Sequence[str] | None = None,
345
+ default_args: bool = True,
346
+ ignore_default_args: IgnoreDefaultArgs = DEFAULT_IGNORE_DEFAULT_ARGS,
347
+ no_viewport: bool | None = True,
348
+ **launch_options: Any,
349
+ ) -> AsyncBrowserContext:
350
+ """Launch an async Playwright persistent context with installed Chrome."""
351
+ config = build_chrome_launch_config(
352
+ browser_path=browser_path,
353
+ user_data_dir=user_data_dir,
354
+ profile_directory=profile_directory,
355
+ channel=channel,
356
+ headless=headless,
357
+ args=args,
358
+ default_args=default_args,
359
+ ignore_default_args=ignore_default_args,
360
+ no_viewport=no_viewport,
361
+ **launch_options,
362
+ )
363
+ return await playwright.chromium.launch_persistent_context(
364
+ config.user_data_dir,
365
+ **config.to_playwright_kwargs(),
366
+ )
367
+
368
+
369
+ def _resolve_user_data_dir(
370
+ user_data_dir: PathSetting,
371
+ *,
372
+ sys_platform: str | None,
373
+ env: Mapping[str, str],
374
+ ) -> Path:
375
+ explicit = _coerce_optional_path(user_data_dir)
376
+ if explicit is not None:
377
+ return explicit
378
+
379
+ env_user_data_dir = env.get(USER_DATA_DIR_ENV)
380
+ if env_user_data_dir:
381
+ env_user_data_path = Path(env_user_data_dir).expanduser()
382
+ if env_user_data_path.exists():
383
+ return env_user_data_path
384
+ msg = (
385
+ f"Chrome user data directory from {USER_DATA_DIR_ENV} "
386
+ f"does not exist: {env_user_data_path}"
387
+ )
388
+ raise ChromeProfileNotFoundError(msg)
389
+
390
+ detected = detect_chrome_user_data_dir(
391
+ "auto",
392
+ sys_platform=sys_platform,
393
+ env=env,
394
+ )
395
+ if detected is not None:
396
+ return detected
397
+
398
+ candidates = ", ".join(
399
+ str(path)
400
+ for path in chrome_user_data_dir_candidates(sys_platform=sys_platform, env=env)
401
+ )
402
+ msg = (
403
+ "Could not find an existing Google Chrome user data directory. "
404
+ f"Set {USER_DATA_DIR_ENV} or pass user_data_dir=... explicitly. "
405
+ f"Checked: {candidates}."
406
+ )
407
+ raise ChromeProfileNotFoundError(msg)
408
+
409
+
410
+ def _resolve_browser_path(
411
+ browser_path: PathSetting,
412
+ *,
413
+ sys_platform: str | None,
414
+ env: Mapping[str, str],
415
+ ) -> Path | None:
416
+ if browser_path is None:
417
+ return None
418
+
419
+ explicit = _coerce_optional_path(browser_path)
420
+ if explicit is not None:
421
+ if explicit.exists():
422
+ return explicit
423
+ msg = f"Chrome executable does not exist: {explicit}"
424
+ raise ChromeNotFoundError(msg)
425
+
426
+ env_browser_path = env.get(CHROME_PATH_ENV)
427
+ if env_browser_path:
428
+ env_browser = Path(env_browser_path).expanduser()
429
+ if env_browser.exists():
430
+ return env_browser
431
+ msg = f"Chrome executable from {CHROME_PATH_ENV} does not exist: {env_browser}"
432
+ raise ChromeNotFoundError(msg)
433
+
434
+ return detect_chrome_executable(
435
+ "auto",
436
+ sys_platform=sys_platform,
437
+ env=env,
438
+ )
439
+
440
+
441
+ def _resolve_profile_directory(
442
+ profile_directory: ProfileSetting,
443
+ *,
444
+ env: Mapping[str, str],
445
+ ) -> str | None:
446
+ resolved: str | None
447
+ if profile_directory == "auto":
448
+ resolved = env.get(PROFILE_DIRECTORY_ENV, DEFAULT_PROFILE_DIRECTORY)
449
+ else:
450
+ resolved = profile_directory
451
+ if resolved is None:
452
+ return None
453
+ if not resolved:
454
+ msg = "profile_directory must be a non-empty folder name or None"
455
+ raise ConfigurationError(msg)
456
+ if "/" in resolved or "\\" in resolved or resolved in {".", ".."}:
457
+ msg = "profile_directory must be a Chrome profile folder name, not a path"
458
+ raise ConfigurationError(msg)
459
+ return resolved
460
+
461
+
462
+ def _build_chrome_args(
463
+ *,
464
+ profile_directory: str | None,
465
+ headless: bool | None,
466
+ args: Sequence[str] | None,
467
+ default_args: bool,
468
+ ) -> list[str]:
469
+ launch_args: list[str] = []
470
+ if default_args:
471
+ launch_args.extend(DEFAULT_CHROME_ARGS)
472
+ if headless is False:
473
+ launch_args.append("--start-maximized")
474
+ if profile_directory is not None:
475
+ launch_args.append(f"--profile-directory={profile_directory}")
476
+ if args:
477
+ launch_args.extend(args)
478
+ return launch_args
479
+
480
+
481
+ def _coerce_optional_path(value: PathSetting) -> Path | None:
482
+ if value is None or value == "auto":
483
+ return None
484
+ return Path(value).expanduser()
485
+
486
+
487
+ def _home_path(env: Mapping[str, str]) -> Path | None:
488
+ home = env.get("HOME") or env.get("USERPROFILE")
489
+ if home:
490
+ return Path(home).expanduser()
491
+ return None
492
+
493
+
494
+ def _dedupe_paths(paths: Sequence[Path]) -> tuple[Path, ...]:
495
+ seen: set[str] = set()
496
+ result: list[Path] = []
497
+ for path in paths:
498
+ key = os.fspath(path)
499
+ if key not in seen:
500
+ seen.add(key)
501
+ result.append(path)
502
+ return tuple(result)
File without changes
@@ -0,0 +1,314 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ import playwright_byob._chrome as chrome_module
10
+ from playwright_byob import (
11
+ CHROME_PATH_ENV,
12
+ DEFAULT_IGNORE_DEFAULT_ARGS,
13
+ PROFILE_DIRECTORY_ENV,
14
+ USER_DATA_DIR_ENV,
15
+ ChromeNotFoundError,
16
+ ChromeProfileNotFoundError,
17
+ ConfigurationError,
18
+ async_launch_chrome,
19
+ build_chrome_launch_config,
20
+ chrome_executable_candidates,
21
+ chrome_user_data_dir_candidates,
22
+ detect_chrome_executable,
23
+ detect_chrome_user_data_dir,
24
+ launch_chrome,
25
+ )
26
+
27
+
28
+ class FakeSyncChromium:
29
+ def __init__(self) -> None:
30
+ self.calls: list[tuple[Path, dict[str, Any]]] = []
31
+
32
+ def launch_persistent_context(self, user_data_dir: Path, **kwargs: Any) -> str:
33
+ self.calls.append((user_data_dir, kwargs))
34
+ return "sync-context"
35
+
36
+
37
+ class FakeSyncPlaywright:
38
+ def __init__(self) -> None:
39
+ self.chromium = FakeSyncChromium()
40
+
41
+
42
+ class FakeAsyncChromium:
43
+ def __init__(self) -> None:
44
+ self.calls: list[tuple[Path, dict[str, Any]]] = []
45
+
46
+ async def launch_persistent_context(
47
+ self, user_data_dir: Path, **kwargs: Any
48
+ ) -> str:
49
+ self.calls.append((user_data_dir, kwargs))
50
+ return "async-context"
51
+
52
+
53
+ class FakeAsyncPlaywright:
54
+ def __init__(self) -> None:
55
+ self.chromium = FakeAsyncChromium()
56
+
57
+
58
+ def test_chrome_executable_candidates_prefer_env_path(tmp_path: Path) -> None:
59
+ chrome = tmp_path / "chrome"
60
+ env = {CHROME_PATH_ENV: str(chrome), "HOME": str(tmp_path)}
61
+
62
+ candidates = chrome_executable_candidates(sys_platform="darwin", env=env)
63
+
64
+ assert candidates[0] == chrome
65
+ assert (
66
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
67
+ in candidates
68
+ )
69
+
70
+
71
+ def test_chrome_user_data_dir_candidates_are_platform_specific(tmp_path: Path) -> None:
72
+ mac_candidates = chrome_user_data_dir_candidates(
73
+ sys_platform="darwin",
74
+ env={"HOME": str(tmp_path)},
75
+ )
76
+ linux_candidates = chrome_user_data_dir_candidates(
77
+ sys_platform="linux",
78
+ env={"HOME": str(tmp_path)},
79
+ )
80
+ windows_candidates = chrome_user_data_dir_candidates(
81
+ sys_platform="win32",
82
+ env={"LOCALAPPDATA": str(tmp_path)},
83
+ )
84
+
85
+ assert mac_candidates == (
86
+ tmp_path / "Library" / "Application Support" / "Google" / "Chrome",
87
+ )
88
+ assert linux_candidates == (tmp_path / ".config" / "google-chrome",)
89
+ assert windows_candidates == (tmp_path / "Google" / "Chrome" / "User Data",)
90
+
91
+
92
+ def test_empty_env_does_not_fall_back_to_process_home_or_path(
93
+ monkeypatch: pytest.MonkeyPatch,
94
+ ) -> None:
95
+ which_paths: list[str | None] = []
96
+
97
+ def fake_which(command: str, path: str | None = None) -> str | None:
98
+ del command
99
+ which_paths.append(path)
100
+ return "/real-path/chrome" if path is None else None
101
+
102
+ monkeypatch.setattr(chrome_module.shutil, "which", fake_which)
103
+
104
+ executable_candidates = chrome_executable_candidates(sys_platform="linux", env={})
105
+ user_data_candidates = chrome_user_data_dir_candidates(sys_platform="linux", env={})
106
+
107
+ assert which_paths == ["", "", ""]
108
+ assert Path("/real-path/chrome") not in executable_candidates
109
+ assert user_data_candidates == ()
110
+
111
+
112
+ def test_build_config_empty_env_does_not_use_real_user_profile() -> None:
113
+ with pytest.raises(ChromeProfileNotFoundError):
114
+ build_chrome_launch_config(browser_path=None, sys_platform="linux", env={})
115
+
116
+
117
+ def test_detect_chrome_executable_checks_explicit_existing_path(tmp_path: Path) -> None:
118
+ chrome = tmp_path / "chrome"
119
+ chrome.write_text("fake chrome", encoding="utf-8")
120
+
121
+ assert detect_chrome_executable(chrome) == chrome
122
+ assert detect_chrome_executable(tmp_path / "missing") is None
123
+
124
+
125
+ def test_detect_user_data_dir_checks_existing_path_only(tmp_path: Path) -> None:
126
+ user_data_dir = tmp_path / "Chrome User Data"
127
+ user_data_dir.mkdir()
128
+
129
+ assert detect_chrome_user_data_dir(user_data_dir) == user_data_dir
130
+ assert detect_chrome_user_data_dir(tmp_path / "missing") is None
131
+
132
+
133
+ def test_build_config_uses_explicit_browser_profile_and_sensible_defaults(
134
+ tmp_path: Path,
135
+ ) -> None:
136
+ chrome = tmp_path / "chrome"
137
+ chrome.write_text("fake chrome", encoding="utf-8")
138
+ user_data_dir = tmp_path / "User Data"
139
+ user_data_dir.mkdir()
140
+
141
+ config = build_chrome_launch_config(
142
+ browser_path=chrome,
143
+ user_data_dir=user_data_dir,
144
+ profile_directory="Profile 1",
145
+ args=["--window-size=1440,1000"],
146
+ timeout=12_000,
147
+ )
148
+
149
+ options = config.to_playwright_kwargs()
150
+ assert config.user_data_dir == user_data_dir
151
+ assert options["executable_path"] == chrome
152
+ assert "channel" not in options
153
+ assert options["headless"] is False
154
+ assert options["no_viewport"] is True
155
+ assert options["ignore_default_args"] == DEFAULT_IGNORE_DEFAULT_ARGS
156
+ assert options["timeout"] == 12_000
157
+ assert options["args"] == [
158
+ "--disable-blink-features=AutomationControlled",
159
+ "--start-maximized",
160
+ "--profile-directory=Profile 1",
161
+ "--window-size=1440,1000",
162
+ ]
163
+
164
+
165
+ def test_build_config_can_use_playwright_chrome_channel_without_detection(
166
+ tmp_path: Path,
167
+ ) -> None:
168
+ user_data_dir = tmp_path / "User Data"
169
+
170
+ config = build_chrome_launch_config(
171
+ browser_path=None,
172
+ user_data_dir=user_data_dir,
173
+ profile_directory=None,
174
+ default_args=False,
175
+ ignore_default_args=None,
176
+ no_viewport=False,
177
+ )
178
+
179
+ options = config.to_playwright_kwargs()
180
+ assert config.user_data_dir == user_data_dir
181
+ assert options == {"channel": "chrome", "headless": False, "no_viewport": False}
182
+
183
+
184
+ def test_build_config_honors_environment_overrides(tmp_path: Path) -> None:
185
+ chrome = tmp_path / "chrome"
186
+ chrome.write_text("fake chrome", encoding="utf-8")
187
+ user_data_dir = tmp_path / "Chrome User Data"
188
+ user_data_dir.mkdir()
189
+ env = {
190
+ CHROME_PATH_ENV: str(chrome),
191
+ USER_DATA_DIR_ENV: str(user_data_dir),
192
+ PROFILE_DIRECTORY_ENV: "Profile 2",
193
+ }
194
+
195
+ config = build_chrome_launch_config(env=env)
196
+ options = config.to_playwright_kwargs()
197
+
198
+ assert config.user_data_dir == user_data_dir
199
+ assert options["executable_path"] == chrome
200
+ assert "--profile-directory=Profile 2" in options["args"]
201
+
202
+
203
+ def test_build_config_rejects_missing_environment_paths(tmp_path: Path) -> None:
204
+ with pytest.raises(ChromeNotFoundError):
205
+ build_chrome_launch_config(
206
+ user_data_dir=tmp_path,
207
+ env={CHROME_PATH_ENV: str(tmp_path / "missing-chrome")},
208
+ )
209
+
210
+ with pytest.raises(ChromeProfileNotFoundError):
211
+ build_chrome_launch_config(
212
+ browser_path=None,
213
+ env={USER_DATA_DIR_ENV: str(tmp_path / "missing-profile")},
214
+ )
215
+
216
+
217
+ def test_build_config_does_not_create_or_use_real_profile_in_auto_mode(
218
+ tmp_path: Path,
219
+ ) -> None:
220
+ home = tmp_path / "home"
221
+ home.mkdir()
222
+
223
+ with pytest.raises(ChromeProfileNotFoundError):
224
+ build_chrome_launch_config(
225
+ browser_path=None,
226
+ sys_platform="linux",
227
+ env={"HOME": str(home), "PATH": ""},
228
+ )
229
+
230
+ assert not (home / ".config" / "google-chrome").exists()
231
+
232
+
233
+ def test_build_config_rejects_missing_explicit_browser(tmp_path: Path) -> None:
234
+ with pytest.raises(ChromeNotFoundError):
235
+ build_chrome_launch_config(
236
+ browser_path=tmp_path / "missing-chrome",
237
+ user_data_dir=tmp_path,
238
+ )
239
+
240
+
241
+ def test_build_config_rejects_profile_paths(tmp_path: Path) -> None:
242
+ with pytest.raises(ConfigurationError):
243
+ build_chrome_launch_config(
244
+ browser_path=None,
245
+ user_data_dir=tmp_path,
246
+ profile_directory="Default/Nested",
247
+ )
248
+
249
+
250
+ def test_launch_chrome_passes_resolved_config_to_sync_playwright(
251
+ tmp_path: Path,
252
+ ) -> None:
253
+ fake = FakeSyncPlaywright()
254
+
255
+ context = launch_chrome(
256
+ fake, # type: ignore[arg-type]
257
+ browser_path=None,
258
+ user_data_dir=tmp_path,
259
+ profile_directory=None,
260
+ args=["--foo"],
261
+ base_url="https://example.com",
262
+ )
263
+
264
+ assert context == "sync-context"
265
+ assert fake.chromium.calls == [
266
+ (
267
+ tmp_path,
268
+ {
269
+ "channel": "chrome",
270
+ "headless": False,
271
+ "args": [
272
+ "--disable-blink-features=AutomationControlled",
273
+ "--start-maximized",
274
+ "--foo",
275
+ ],
276
+ "ignore_default_args": DEFAULT_IGNORE_DEFAULT_ARGS,
277
+ "no_viewport": True,
278
+ "base_url": "https://example.com",
279
+ },
280
+ ),
281
+ ]
282
+
283
+
284
+ def test_async_launch_chrome_passes_resolved_config_to_async_playwright(
285
+ tmp_path: Path,
286
+ ) -> None:
287
+ async def run() -> None:
288
+ fake = FakeAsyncPlaywright()
289
+ context = await async_launch_chrome(
290
+ fake, # type: ignore[arg-type]
291
+ browser_path=None,
292
+ user_data_dir=tmp_path,
293
+ profile_directory="Default",
294
+ headless=True,
295
+ )
296
+
297
+ assert context == "async-context"
298
+ assert fake.chromium.calls == [
299
+ (
300
+ tmp_path,
301
+ {
302
+ "channel": "chrome",
303
+ "headless": True,
304
+ "args": [
305
+ "--disable-blink-features=AutomationControlled",
306
+ "--profile-directory=Default",
307
+ ],
308
+ "ignore_default_args": DEFAULT_IGNORE_DEFAULT_ARGS,
309
+ "no_viewport": True,
310
+ },
311
+ ),
312
+ ]
313
+
314
+ asyncio.run(run())