diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fcd1bf..925922a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: branches: [ main ] pull_request: branches: [ main ] +permissions: + contents: read jobs: lint: runs-on: ubuntu-latest @@ -53,3 +55,23 @@ jobs: - name: Test on Linux if: ${{ matrix.os == 'ubuntu-latest' }} run: xvfb-run pytest --cov=pytest_playwright --cov-report xml + test-without-base-url: + # pytest-base-url is an optional extra; run the base-url tests without it installed. + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: 3.12 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r local-requirements.txt + pip install -e pytest-playwright + pip install -e pytest-playwright-asyncio + pip uninstall -y pytest-base-url + playwright install chromium --with-deps + - name: Test + run: xvfb-run pytest -k base_url diff --git a/local-requirements.txt b/local-requirements.txt index df13bb0..fd6c35d 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -11,5 +11,6 @@ pytest-xdist==3.8.0 # Only used by the test suite; the inner pytest runs block it unless a test opts in. pytest-rerunfailures==16.7 pytest-html==4.2.0 +pytest-base-url==2.1.0 pytest-asyncio==1.3.0 playwright>=1.60 diff --git a/pytest-playwright-asyncio/pyproject.toml b/pytest-playwright-asyncio/pyproject.toml index 35f7b2e..38f559d 100644 --- a/pytest-playwright-asyncio/pyproject.toml +++ b/pytest-playwright-asyncio/pyproject.toml @@ -25,11 +25,13 @@ dynamic = ["version"] dependencies = [ "playwright>=1.18", "pytest>=6.2.4,<10.0.0", - "pytest-base-url>=1.0.0,<3.0.0", "python-slugify>=6.0.0,<10.0.0", "pytest-asyncio>=0.24.0", ] +[project.optional-dependencies] +base-url = ["pytest-base-url>=1.0.0,<3.0.0"] + [project.urls] homepage = "https://github.com/microsoft/playwright-pytest" diff --git a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py index 438ed81..20e91a4 100644 --- a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py +++ b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py @@ -762,6 +762,47 @@ def pytest_addoption( ) +@pytest.hookimpl(hookwrapper=True) +def pytest_load_initial_conftests( + early_config: Any, parser: pytest.Parser +) -> Generator[None, None, None]: + yield + # By now every plugin is registered: from -p, PYTEST_PLUGINS, the entry points + # (unless autoloading is disabled) and the initial conftests. So this is the + # first point where it is known whether pytest-base-url is in use, and options + # can still be added, as the command line is parsed afterwards. + if _is_pytest_base_url_loaded(early_config.pluginmanager): + return + # pytest-base-url is optional (pip install pytest-playwright[base-url]). When + # it is not in use, provide the same option, ini value and fixture. + parser.addini("base_url", help="base url for the application under test.") + parser.getgroup("playwright", "Playwright").addoption( + "--base-url", + metavar="url", + default=os.getenv("PYTEST_BASE_URL", None), + help="base url for the application under test.", + ) + early_config.pluginmanager.register(_BaseUrlFallback(), "playwright-base-url") + + +def _is_pytest_base_url_loaded(pluginmanager: pytest.PytestPluginManager) -> bool: + # Registered as "base_url" through its entry point, or under its module name + # with -p pytest_base_url.plugin; checking the module covers both. + module = sys.modules.get("pytest_base_url.plugin") + return module is not None and pluginmanager.is_registered(module) + + +class _BaseUrlFallback: + # Provides the base_url fixture when pytest-base-url is not in use. + @pytest.fixture(scope="session") + def base_url(self, pytestconfig: Any) -> Optional[str]: + return ( + pytestconfig.getoption("--base-url") + or pytestconfig.getini("base_url") + or None + ) + + class ArtifactsRecorder: def __init__( self, diff --git a/pytest-playwright/pyproject.toml b/pytest-playwright/pyproject.toml index aa5863d..cf78300 100644 --- a/pytest-playwright/pyproject.toml +++ b/pytest-playwright/pyproject.toml @@ -25,10 +25,12 @@ dynamic = ["version"] dependencies = [ "playwright>=1.18", "pytest>=6.2.4,<10.0.0", - "pytest-base-url>=1.0.0,<3.0.0", "python-slugify>=6.0.0,<10.0.0", ] +[project.optional-dependencies] +base-url = ["pytest-base-url>=1.0.0,<3.0.0"] + [project.urls] homepage = "https://github.com/microsoft/playwright-pytest" diff --git a/pytest-playwright/pytest_playwright/pytest_playwright.py b/pytest-playwright/pytest_playwright/pytest_playwright.py index b3673de..5135eb7 100644 --- a/pytest-playwright/pytest_playwright/pytest_playwright.py +++ b/pytest-playwright/pytest_playwright/pytest_playwright.py @@ -759,6 +759,47 @@ def pytest_addoption( ) +@pytest.hookimpl(hookwrapper=True) +def pytest_load_initial_conftests( + early_config: Any, parser: pytest.Parser +) -> Generator[None, None, None]: + yield + # By now every plugin is registered: from -p, PYTEST_PLUGINS, the entry points + # (unless autoloading is disabled) and the initial conftests. So this is the + # first point where it is known whether pytest-base-url is in use, and options + # can still be added, as the command line is parsed afterwards. + if _is_pytest_base_url_loaded(early_config.pluginmanager): + return + # pytest-base-url is optional (pip install pytest-playwright[base-url]). When + # it is not in use, provide the same option, ini value and fixture. + parser.addini("base_url", help="base url for the application under test.") + parser.getgroup("playwright", "Playwright").addoption( + "--base-url", + metavar="url", + default=os.getenv("PYTEST_BASE_URL", None), + help="base url for the application under test.", + ) + early_config.pluginmanager.register(_BaseUrlFallback(), "playwright-base-url") + + +def _is_pytest_base_url_loaded(pluginmanager: pytest.PytestPluginManager) -> bool: + # Registered as "base_url" through its entry point, or under its module name + # with -p pytest_base_url.plugin; checking the module covers both. + module = sys.modules.get("pytest_base_url.plugin") + return module is not None and pluginmanager.is_registered(module) + + +class _BaseUrlFallback: + # Provides the base_url fixture when pytest-base-url is not in use. + @pytest.fixture(scope="session") + def base_url(self, pytestconfig: Any) -> Optional[str]: + return ( + pytestconfig.getoption("--base-url") + or pytestconfig.getini("base_url") + or None + ) + + class ArtifactsRecorder: def __init__( self, diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index e04fa53..354ecdf 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib.util import os import signal import subprocess @@ -446,6 +447,77 @@ async def test_base_url(page, base_url): result.assert_outcomes(passed=1) +@pytest.mark.parametrize("source", ["option", "ini", "env", "none"]) +def test_base_url_without_pytest_base_url( + testdir: pytest.Testdir, + test_server: HTTPTestServer, + monkeypatch: pytest.MonkeyPatch, + source: str, +) -> None: + # https://github.com/microsoft/playwright-pytest/issues/311 + args = ["-p", "no:base_url"] + if source == "option": + args += ["--base-url", test_server.PREFIX] + elif source == "ini": + args += ["-o", f"base_url={test_server.PREFIX}"] + elif source == "env": + monkeypatch.setenv("PYTEST_BASE_URL", test_server.PREFIX) + expected = None if source == "none" else test_server.PREFIX + testdir.makepyfile( + f""" + import pytest + + @pytest.mark.asyncio + async def test_base_url(page, base_url): + assert base_url == {expected!r} + await page.goto("{test_server.PREFIX}/foobar") + if base_url: + await page.goto("/foobar") + assert page.url == "{test_server.PREFIX}/foobar" + """ + ) + result = testdir.runpytest(*args) + result.assert_outcomes(passed=1) + + +def test_base_url_without_plugin_autoload( + testdir: pytest.Testdir, + test_server: HTTPTestServer, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Only plugins that are actually loaded count, not installed ones. + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + testdir.makepyfile( + f""" + import pytest + + @pytest.mark.asyncio + async def test_base_url(page, base_url): + assert base_url == "{test_server.PREFIX}" + await page.goto("/foobar") + assert page.url == "{test_server.PREFIX}/foobar" + """ + ) + playwright_plugin = [ + "-p", + "pytest_playwright_asyncio.pytest_playwright", + "-p", + "pytest_asyncio.plugin", + ] + plugin_sets = [playwright_plugin] + if importlib.util.find_spec("pytest_base_url") is not None: + # Loaded explicitly, in either order and under either name, pytest-base-url + # provides the option instead. + plugin_sets += [ + playwright_plugin + ["-p", "base_url"], + ["-p", "base_url"] + playwright_plugin, + playwright_plugin + ["-p", "pytest_base_url.plugin"], + ] + for plugins in plugin_sets: + result = testdir.runpytest(*plugins, "--base-url", test_server.PREFIX) + result.assert_outcomes(passed=1) + + def test_skip_browsers(testdir: pytest.Testdir) -> None: testdir.makepyfile( """ diff --git a/tests/test_sync.py b/tests/test_sync.py index c203964..6ca84c5 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib.util import os from pathlib import Path import signal @@ -462,6 +463,66 @@ def test_base_url(page, base_url): result.assert_outcomes(passed=1) +@pytest.mark.parametrize("source", ["option", "ini", "env", "none"]) +def test_base_url_without_pytest_base_url( + testdir: pytest.Testdir, + test_server: HTTPTestServer, + monkeypatch: pytest.MonkeyPatch, + source: str, +) -> None: + # https://github.com/microsoft/playwright-pytest/issues/311 + args = ["-p", "no:base_url"] + if source == "option": + args += ["--base-url", test_server.PREFIX] + elif source == "ini": + args += ["-o", f"base_url={test_server.PREFIX}"] + elif source == "env": + monkeypatch.setenv("PYTEST_BASE_URL", test_server.PREFIX) + expected = None if source == "none" else test_server.PREFIX + testdir.makepyfile( + f""" + def test_base_url(page, base_url): + assert base_url == {expected!r} + page.goto("{test_server.PREFIX}/foobar") + if base_url: + page.goto("/foobar") + assert page.url == "{test_server.PREFIX}/foobar" + """ + ) + result = testdir.runpytest(*args) + result.assert_outcomes(passed=1) + + +def test_base_url_without_plugin_autoload( + testdir: pytest.Testdir, + test_server: HTTPTestServer, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Only plugins that are actually loaded count, not installed ones. + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + testdir.makepyfile( + f""" + def test_base_url(page, base_url): + assert base_url == "{test_server.PREFIX}" + page.goto("/foobar") + assert page.url == "{test_server.PREFIX}/foobar" + """ + ) + playwright_plugin = ["-p", "pytest_playwright.pytest_playwright"] + plugin_sets = [playwright_plugin] + if importlib.util.find_spec("pytest_base_url") is not None: + # Loaded explicitly, in either order and under either name, pytest-base-url + # provides the option instead. + plugin_sets += [ + playwright_plugin + ["-p", "base_url"], + ["-p", "base_url"] + playwright_plugin, + playwright_plugin + ["-p", "pytest_base_url.plugin"], + ] + for plugins in plugin_sets: + result = testdir.runpytest(*plugins, "--base-url", test_server.PREFIX) + result.assert_outcomes(passed=1) + + def test_skip_browsers(testdir: pytest.Testdir) -> None: testdir.makepyfile( """