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()