diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b67ba95..b00f68b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,28 +53,3 @@ jobs: - name: Test on Linux if: ${{ matrix.os == 'ubuntu-latest' }} run: xvfb-run pytest --cov=pytest_playwright --cov-report xml - build-conda: - name: Conda Build - strategy: - fail-fast: false - matrix: - os: [ ubuntu-latest, macos-latest, windows-latest ] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Get conda - uses: conda-incubator/setup-miniconda@v3 - with: - python-version: "3.10" - channels: microsoft,conda-forge - - name: Prepare - run: | - conda install conda-build conda-verify - # Until https://github.com/anaconda/conda-anaconda-telemetry/issues/87 has been fixed - conda remove --name base conda-anaconda-telemetry - - name: Build pytest-playwright - run: conda build . - - name: Build pytest-playwright-asyncio - run: conda build --variant-config-file conda_build_config_asyncio.yaml . diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index a29644b..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,31 +0,0 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -name: Upload Python Package -on: - release: - types: [published] -jobs: - deploy-conda: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Get conda - uses: conda-incubator/setup-miniconda@v3 - with: - python-version: "3.10" - channels: microsoft,conda-forge - - name: Prepare - run: | - conda install anaconda-client conda-build conda-verify - # Until https://github.com/anaconda/conda-anaconda-telemetry/issues/87 has been fixed - conda remove --name base conda-anaconda-telemetry - - name: Build and Upload - env: - ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} - run: | - conda config --set anaconda_upload yes - conda build --user microsoft . - conda build --user microsoft --variant-config-file conda_build_config_asyncio.yaml . diff --git a/conda_build_config.yaml b/conda_build_config.yaml deleted file mode 100644 index dfaf573..0000000 --- a/conda_build_config.yaml +++ /dev/null @@ -1 +0,0 @@ -package: pytest-playwright diff --git a/conda_build_config_asyncio.yaml b/conda_build_config_asyncio.yaml deleted file mode 100644 index 29c44a0..0000000 --- a/conda_build_config_asyncio.yaml +++ /dev/null @@ -1 +0,0 @@ -package: pytest-playwright-asyncio diff --git a/meta.yaml b/meta.yaml deleted file mode 100644 index d5f0db4..0000000 --- a/meta.yaml +++ /dev/null @@ -1,44 +0,0 @@ -channels: - - microsoft - - conda-forge - -package: - name: "{{ package }}" - version: "{{ environ.get('GIT_DESCRIBE_TAG') | replace('v', '') }}" - -source: - path: . - -build: - number: 0 - noarch: python - script: python -m pip install --no-deps --ignore-installed ./{{ package }} - -requirements: - host: - - python >=3.10 - - setuptools-scm - - pip - run: - - python >=3.10 - - microsoft::playwright >=1.37.0 - - pytest >=6.2.4,<10.0.0 - - pytest-base-url >=1.0.0,<3.0.0 - - python-slugify >=6.0.0,<9.0.0 - {% if package == 'pytest-playwright-asyncio' %} - - pytest-asyncio >=0.24.0 - {% endif %} - -test: - imports: - - "{{ package | replace('-', '_') }}" - commands: - - pip check - requires: - - pip - -about: - home: https://github.com/microsoft/playwright-pytest - summary: A pytest wrapper with {% if package == 'pytest-playwright-asyncio' %} async{% endif %}fixtures for Playwright to automate web browsers - license: Apache-2.0 - license_file: LICENSE diff --git a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py index 50966c2..7fd5dee 100644 --- a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py +++ b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py @@ -14,6 +14,7 @@ import hashlib import json +import secrets import shutil import os import sys @@ -140,6 +141,18 @@ def pytest_configure(config: Any) -> None: "markers", "browser_context_args(**kwargs): provide additional arguments to browser.new_context()", ) + if config.getoption("--playwright-debug", default=None) == "cli": + if getattr(config.option, "capture", "fd") != "no": + raise pytest.UsageError( + "--playwright-debug=cli requires disabled output capture " + "(pass -s or --capture=no) so the attach command is visible." + ) + worker_count = _xdist_worker_count(config) + if worker_count is not None and worker_count != 1: + raise pytest.UsageError( + "--playwright-debug=cli requires a single worker " + "(do not use pytest-xdist -n > 1)." + ) # Making test result information available in fixtures @@ -377,6 +390,7 @@ async def new_context( additional_context_args = context_args_marker.kwargs if context_args_marker else {} browser_context_args.update(additional_context_args) contexts: List[BrowserContext] = [] + debug_cli = request.config.getoption("--playwright-debug", default=None) == "cli" async def _new_context(**kwargs: Any) -> BrowserContext: context = await browser.new_context(**browser_context_args, **kwargs) @@ -388,6 +402,10 @@ async def _close_wrapper(*args: Any, **kwargs: Any) -> None: await original_close(*args, **kwargs) context.close = _close_wrapper + if ( + debug_cli and not contexts + ): # CLI attach drives contexts()[0] only — bind/print/pause on first context. + await _run_debug_cli(browser, context, str(request.config.rootpath)) contexts.append(context) await _artifacts_recorder.on_did_create_browser_context(context) return context @@ -451,6 +469,44 @@ def device(pytestconfig: Any) -> Optional[str]: PLUGIN_INCOMPATIBLE_MESSAGE = "pytest-playwright and pytest-playwright-asyncio are not compatible. Please use only one of them." +async def _run_debug_cli( + browser: Browser, context: BrowserContext, workspace_dir: str +) -> None: + if ( + not callable(getattr(browser, "bind", None)) + or getattr(context, "debugger", None) is None + ): + raise pytest.UsageError("--playwright-debug=cli requires playwright>=1.59") + + session_name = f"tw-{secrets.token_hex(3)}" + await browser.bind(session_name, workspace_dir=workspace_dir) + + print( + "\n### The test is currently paused at the start\n" + "\n" + "### Debugging Instructions\n" + f"- Run `python -m playwright cli attach {session_name}` " + "to attach to this test\n", + flush=True, + ) + + context.set_default_timeout(0) + context.set_default_navigation_timeout(0) + await context.debugger.request_pause() + + +def _xdist_worker_count(config: Any) -> Optional[int]: + numprocesses = getattr(config.option, "numprocesses", None) + if numprocesses is None: + return None + if numprocesses == "logical" or numprocesses == "auto": + return 2 + try: + return int(numprocesses) + except (TypeError, ValueError): + return 2 + + def pytest_addoption( parser: pytest.Parser, pluginmanager: pytest.PytestPluginManager ) -> None: @@ -526,6 +582,12 @@ def pytest_addoption( default=False, help="Whether to take a full page screenshot", ) + group.addoption( + "--playwright-debug", + default=None, + choices=["cli"], + help="Enable Playwright CLI debugging. Requires -s/--capture=no.", + ) class ArtifactsRecorder: diff --git a/pytest-playwright/pytest_playwright/pytest_playwright.py b/pytest-playwright/pytest_playwright/pytest_playwright.py index 119a535..7aece1a 100644 --- a/pytest-playwright/pytest_playwright/pytest_playwright.py +++ b/pytest-playwright/pytest_playwright/pytest_playwright.py @@ -14,6 +14,7 @@ import hashlib import json +import secrets import shutil import os import sys @@ -137,6 +138,18 @@ def pytest_configure(config: Any) -> None: "markers", "browser_context_args(**kwargs): provide additional arguments to browser.new_context()", ) + if config.getoption("--playwright-debug", default=None) == "cli": + if getattr(config.option, "capture", "fd") != "no": + raise pytest.UsageError( + "--playwright-debug=cli requires disabled output capture " + "(pass -s or --capture=no) so the attach command is visible." + ) + worker_count = _xdist_worker_count(config) + if worker_count is not None and worker_count != 1: + raise pytest.UsageError( + "--playwright-debug=cli requires a single worker " + "(do not use pytest-xdist -n > 1)." + ) # Making test result information available in fixtures @@ -372,6 +385,7 @@ def new_context( additional_context_args = context_args_marker.kwargs if context_args_marker else {} browser_context_args.update(additional_context_args) contexts: List[BrowserContext] = [] + debug_cli = request.config.getoption("--playwright-debug", default=None) == "cli" def _new_context(**kwargs: Any) -> BrowserContext: context = browser.new_context(**browser_context_args, **kwargs) @@ -383,6 +397,10 @@ def _close_wrapper(*args: Any, **kwargs: Any) -> None: original_close(*args, **kwargs) context.close = _close_wrapper + if ( + debug_cli and not contexts + ): # CLI attach drives contexts()[0] only — bind/print/pause on first context. + _run_debug_cli(browser, context, str(request.config.rootpath)) contexts.append(context) _artifacts_recorder.on_did_create_browser_context(context) return context @@ -446,6 +464,46 @@ def device(pytestconfig: Any) -> Optional[str]: PLUGIN_INCOMPATIBLE_MESSAGE = "pytest-playwright and pytest-playwright-asyncio are not compatible. Please use only one of them." +def _run_debug_cli( + browser: Browser, context: BrowserContext, workspace_dir: str +) -> None: + if ( + not callable(getattr(browser, "bind", None)) + or getattr(context, "debugger", None) is None + ): + raise pytest.UsageError("--playwright-debug=cli requires playwright>=1.59") + + session_name = f"tw-{secrets.token_hex(3)}" + browser.bind(session_name, workspace_dir=workspace_dir) + + # Leading newline: with -s, pytest prints "test_foo.py " with no trailing + # newline before fixtures run; start our banner on its own line. + print( + "\n### The test is currently paused at the start\n" + "\n" + "### Debugging Instructions\n" + f"- Run `python -m playwright cli attach {session_name}` " + "to attach to this test\n", + flush=True, + ) + + context.set_default_timeout(0) + context.set_default_navigation_timeout(0) + context.debugger.request_pause() + + +def _xdist_worker_count(config: Any) -> Optional[int]: + numprocesses = getattr(config.option, "numprocesses", None) + if numprocesses is None: + return None + if numprocesses == "logical" or numprocesses == "auto": + return 2 + try: + return int(numprocesses) + except (TypeError, ValueError): + return 2 + + def pytest_addoption( parser: pytest.Parser, pluginmanager: pytest.PytestPluginManager ) -> None: @@ -521,6 +579,12 @@ def pytest_addoption( default=False, help="Whether to take a full page screenshot", ) + group.addoption( + "--playwright-debug", + default=None, + choices=["cli"], + help="Enable Playwright CLI debugging. Requires -s/--capture=no.", + ) class ArtifactsRecorder: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index ac634c5..b904c82 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -20,6 +20,7 @@ import pytest from tests.conftest import HTTPTestServer +from tests.utils import attach_snapshot_resume @pytest.fixture @@ -1168,3 +1169,47 @@ async def test_soft(page): out = "\n".join(result.outlines) assert "body-fail" in out assert "soft-fail" in out + + +def test_playwright_debug_cli_requires_no_capture(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ +import pytest +@pytest.mark.asyncio +async def test_dummy(): + assert True +""" + ) + result = testdir.runpytest("--playwright-debug=cli") + assert result.ret != 0 + assert "capture" in "\n".join(result.outlines + result.errlines).lower() + + +def test_playwright_debug_cli_rejects_multiple_xdist_workers( + testdir: pytest.Testdir, +) -> None: + pytest.importorskip("xdist") + testdir.makepyfile( + """ +import pytest +@pytest.mark.asyncio +async def test_dummy(): + assert True +""" + ) + result = testdir.runpytest("--playwright-debug=cli", "-s", "-n", "2") + assert result.ret != 0 + assert "single worker" in "\n".join(result.outlines + result.errlines) + + +def test_playwright_debug_cli_attach_snapshot_resume(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ +import pytest +@pytest.mark.asyncio +async def test_example(page): + await page.set_content("") + assert await page.get_by_role("button").is_visible() +""" + ) + attach_snapshot_resume(testdir) diff --git a/tests/test_sync.py b/tests/test_sync.py index cfaf4c9..6837d47 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -21,6 +21,7 @@ import pytest from tests.conftest import HTTPTestServer +from tests.utils import attach_snapshot_resume @pytest.fixture @@ -1146,3 +1147,31 @@ def test_soft(page): out = "\n".join(result.outlines) assert "body-fail" in out assert "soft-fail" in out + + +def test_playwright_debug_cli_requires_no_capture(testdir: pytest.Testdir) -> None: + testdir.makepyfile("def test_dummy(): assert True") + result = testdir.runpytest("--playwright-debug=cli") + assert result.ret != 0 + assert "capture" in "\n".join(result.outlines + result.errlines).lower() + + +def test_playwright_debug_cli_rejects_multiple_xdist_workers( + testdir: pytest.Testdir, +) -> None: + pytest.importorskip("xdist") + testdir.makepyfile("def test_dummy(): assert True") + result = testdir.runpytest("--playwright-debug=cli", "-s", "-n", "2") + assert result.ret != 0 + assert "single worker" in "\n".join(result.outlines + result.errlines) + + +def test_playwright_debug_cli_attach_snapshot_resume(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ +def test_example(page): + page.set_content("") + assert page.get_by_role("button").is_visible() +""" + ) + attach_snapshot_resume(testdir) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..a9b1131 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +import subprocess +import sys + +import pytest + + +def attach_snapshot_resume(testdir: pytest.Testdir) -> None: + proc = testdir.popen( + [ + sys.executable, + "-m", + "pytest", + str(testdir.tmpdir), + "--browser", + "chromium", + "--playwright-debug=cli", + "-s", + ], + cwd=str(testdir.tmpdir), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + assert proc.stdout is not None + try: + session_re = re.compile(rb"cli attach (tw-[0-9a-f]+)") + output = b"" + session_name = None + while True: + line = proc.stdout.readline() + if not line: + break + output += line + match = session_re.search(line) + if match: + session_name = match.group(1).decode() + break + + if session_name is None: + raise AssertionError( + "pytest exited before printing attach instructions:\n" + + output.decode(errors="replace") + ) + + cli = [sys.executable, "-m", "playwright", "cli"] + for args in ( + ["attach", session_name], + [f"--s={session_name}", "snapshot"], + [f"--s={session_name}", "resume"], + ): + result = subprocess.run( + [*cli, *args], + cwd=str(testdir.tmpdir), + capture_output=True, + check=False, + ) + assert result.returncode == 0, (result.stdout + result.stderr).decode( + errors="replace" + ) + + output += proc.stdout.read() + assert proc.wait(timeout=60) == 0 + out = output.decode(errors="replace") + assert "python -m playwright cli attach tw-" in out + assert "1 passed" in out + finally: + if proc.poll() is None: + proc.kill() + proc.wait()