Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/selenium/python-test/test_login_pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
Run it (the plugin is inert unless a devtools env var opts the run in):

pip install -e packages/selenium-devtools-py
DEVTOOLS_ENABLE=1 python -m pytest examples/selenium/python-test/test_login_pytest.py
python -m pytest --devtools examples/selenium/python-test/test_login_pytest.py

The driver fixture is function-scoped, so each test gets its own browser session
— which also exercises the adapter's per-driver capture state.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"demo:selenium": "pnpm --filter @wdio/selenium-devtools example",
"demo:python": "python3 examples/selenium/python-test/web_form.py",
"demo:python:login": "python3 examples/selenium/python-test/login.py",
"demo:python:pytest": "DEVTOOLS_ENABLE=1 python3 -m pytest examples/selenium/python-test/test_login_pytest.py",
"demo:python:pytest": "python3 -m pytest --devtools examples/selenium/python-test/test_login_pytest.py",
"dev": "pnpm --parallel dev",
"preview": "pnpm --parallel preview",
"test": "vitest run",
Expand Down
28 changes: 27 additions & 1 deletion packages/selenium-devtools-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,35 @@ adapter says so on the first command instead of degrading quietly.
**With pytest (recommended) — no code changes to your tests:**

```bash
DEVTOOLS_ENABLE=1 pytest tests/ # DEVTOOLS_PORT=<n> also opts in (and attaches)
pytest --devtools tests/ # dashboard
pytest --devtools-trace tests/ # trace archive instead (implies --devtools)
```

Or commit it, so everyone on the project gets it without remembering a flag:

```toml
[tool.pytest.ini_options]
devtools = true
# devtools_trace = true # trace archive instead of a dashboard
```

Capture is always opt-in — the plugin auto-loads when the package is installed,
so installing it must never change how an existing suite behaves. What you
choose is only *how* you say yes:

| | |
|---|---|
| `--devtools` / `--devtools-trace` | this run |
| `devtools` / `devtools_trace` in `[tool.pytest.ini_options]` | this project |
| `DEVTOOLS_ENABLE=1` (or `DEVTOOLS_PORT=<n>`, which also attaches) | this shell — for CI |

Highest wins: CLI, then ini, then environment. `pytest -o devtools=false` turns
a project default off for one run, which is why there is no `--no-devtools`.

`DEVTOOLS_TRACE=1` selects trace mode but does **not** switch capture on by
itself — it is a mode fallback you may have exported for your own scripts, and
reading it as an opt-in would capture pytest runs you never asked for.

The bundled plugin auto-captures the run, opens the dashboard in a dedicated
window, and — after the run — **keeps it open so you can inspect it**; close the
window (or Ctrl-C) to finish. Nothing devtools-specific goes in your test files.
Expand Down
131 changes: 125 additions & 6 deletions packages/selenium-devtools-py/src/selenium_devtools/pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""pytest plugin — feeds the suite/test tree to the dashboard.

The analogue of the JS adapter's mocha/jest hooks. Inert unless the run opts in
via ``DEVTOOLS_ENABLE=1`` or ``DEVTOOLS_PORT=...`` so installing the package never
hijacks an unrelated pytest run. On opt-in it enables capture at session start,
stamps per-test timing, and re-sends the ``suites`` frame as each test reports.
— ``--devtools``, a ``devtools`` ini option, or ``DEVTOOLS_ENABLE``/``DEVTOOLS_PORT``,
in that precedence — so installing the package never hijacks an unrelated pytest
run. On opt-in it enables capture at session start, stamps per-test timing, and
re-sends the ``suites`` frame as each test reports.
"""

from __future__ import annotations
Expand All @@ -25,10 +26,119 @@
_comparisons = assertions.ComparisonBuffer()


def _opted_in() -> bool:
#: Resolved once in `pytest_configure`. Most hooks — `pytest_runtest_logstart`,
#: `pytest_runtest_logreport` — are handed no `config`, so the answer cannot be
#: recomputed where it is needed.
_enabled = False


def pytest_addoption(parser) -> None: # noqa: ANN001
"""Register the CLI flags and ini options that switch capture on.

`default=None` on all four, so "not given" is distinguishable from "given
false" — that is what makes CLI -> ini -> env a real precedence chain rather
than three sources ORed together.

No `--no-devtools`: pytest's own `-o devtools=false` already overrides an ini
option for one run, and a second spelling of the same thing is a second
thing to keep consistent.
"""
group = parser.getgroup("devtools", "WebdriverIO DevTools")
group.addoption(
"--devtools",
action="store_true",
default=None,
help="Capture this run for the DevTools dashboard.",
)
group.addoption(
"--devtools-trace",
action="store_true",
default=None,
help="Write a trace archive instead of opening a dashboard. Implies --devtools.",
)
parser.addini(
"devtools",
"Capture pytest runs for the DevTools dashboard.",
type="bool",
default=None,
)
parser.addini(
"devtools_trace",
"Write a trace archive instead of opening a dashboard. Implies devtools.",
type="bool",
default=None,
)


def _ini(config, name: str) -> Optional[bool]: # noqa: ANN001
"""An ini option's value, or None when the project did not set it."""
try:
value = config.getini(name)
except (ValueError, KeyError): # option not registered (another plugin's parser)
return None
return None if value is None or value == "" else bool(value)


def _resolve_trace(config) -> Optional[bool]: # noqa: ANN001
"""Whether this run writes a trace archive: CLI, else ini, else undecided.

None rather than False when nothing said, so `enable()` still reads
DEVTOOLS_TRACE — the env layer lives there and is not duplicated here.
"""
if config.getoption("--devtools-trace", None):
return True
return _ini(config, "devtools_trace")


def _resolve_enabled(config) -> bool: # noqa: ANN001
"""Whether to capture at all: CLI, else ini, else the environment.

Asking for a trace is asking for capture, so `--devtools-trace` and
`devtools_trace` imply it. DEVTOOLS_TRACE deliberately does NOT: it is a
mode fallback that a user may have exported for their own scripts, and
reading it as an opt-in would capture pytest runs they never asked for.
"""
# Nothing runs under --collect-only, so there is nothing to capture: opting
# in would still launch a backend and open a dashboard window, and leave it
# sitting empty for a run that never happened.
if config.getoption("--collect-only", False):
return False
if config.getoption("--devtools", None) or config.getoption(
"--devtools-trace", None
):
return True
for name in ("devtools", "devtools_trace"):
value = _ini(config, name)
if value is not None:
if value:
return True
# An explicit `devtools = false` is the project's answer; the
# environment does not get to overturn it.
if name == "devtools":
return False
return bool(os.environ.get(ENV_OPT_IN) or os.environ.get(ENV_PORT))


def _opted_in() -> bool:
return _enabled


def _teardown_unused_run() -> None:
"""Undo an opt-in for a run that turned out to have nothing to capture.

Closes the dashboard window and drops every later hook out, so the run ends
the way it would have without the flag. `disable()` exports nothing here —
the backend refuses a trace with no commands, console or network.
"""
global _enabled
_enabled = False
_log.info("no tests collected; capture is off for this run")
try:
devtools.disable()
except Exception as exc: # noqa: BLE001 — teardown must never fail a run
_log.debug("could not tear down an unused run: %s", exc)


def _rolled_up_state(tests: list, suites: list) -> str:
"""A group is failed if anything under it failed, else running while any
child still has work left, else its children's outcome. Mirrors how the JS
Expand Down Expand Up @@ -247,14 +357,16 @@ def _configure_rerun(config, rootdir: Optional[str]) -> None: # noqa: ANN001


def pytest_configure(config) -> None: # noqa: ANN001
global _enabled
_enabled = _resolve_enabled(config)
if _opted_in():
_enable_assertion_pass_hook(config)
global _rootdir
# `rootpath` on pytest 7+, `rootdir` before it.
root = getattr(config, "rootpath", None) or getattr(config, "rootdir", None)
_rootdir = str(root) if root else None
_configure_rerun(config, _rootdir)
capturer = devtools.enable()
capturer = devtools.enable(trace=_resolve_trace(config))
# pytest owns the suite tree — suppress the adapter's default script suite.
from . import instrumentation

Expand All @@ -279,10 +391,17 @@ def pytest_collection_finish(session) -> None: # noqa: ANN001
"""
if not _opted_in():
return
items = getattr(session, "items", []) or []
if not items:
# A run that collected nothing has nothing to show, and `sessionfinish`
# would still park on the dashboard window — so a mistyped path leaves
# the terminal blocked on an empty UI. Collection is the first point
# this is knowable; `configure` opened the window before it.
_teardown_unused_run()
return
capturer = devtools.get_capturer()
if capturer is None:
return
items = getattr(session, "items", []) or []
for item in items:
file, line, name = item.location
_registry.record(
Expand Down
Loading
Loading