Skip to content
Draft
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
52 changes: 52 additions & 0 deletions .github/workflows/python-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Manual PyPI Publish

# Mirrors release.yml's manual, button-triggered shape for the Python adapter.
# Unlike npm (NPM_TOKEN), PyPI uses trusted publishing (OIDC) — no secret.
# Bump the version in packages/selenium-py-devtools/pyproject.toml before running.

on:
workflow_dispatch:
inputs:
target:
description: 'Publish target'
required: true
type: choice
default: pypi
options:
- pypi
- testpypi

defaults:
run:
working-directory: packages/selenium-py-devtools

jobs:
release:
runs-on: ubuntu-latest
environment: ${{ inputs.target }}
permissions:
id-token: write # PyPI trusted publishing (OIDC) — no token/secret needed
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: 'main'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- name: 🧪 Unit tests (release guard)
run: PYTHONPATH=src python -m unittest discover -s tests
- name: 📦 Build sdist + wheel
run: |
python -m pip install --upgrade build
python -m build
- name: 🚀 Publish to PyPI
if: ${{ inputs.target == 'pypi' }}
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: packages/selenium-py-devtools/dist
- name: 🚀 Publish to TestPyPI
if: ${{ inputs.target == 'testpypi' }}
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: packages/selenium-py-devtools/dist
repository-url: https://test.pypi.org/legacy/
37 changes: 37 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Python Adapter

on:
push:
branches:
- main
paths:
- packages/selenium-py-devtools/**
- packages/shared/**
- .github/workflows/python.yml
pull_request:
paths:
- packages/selenium-py-devtools/**
- packages/shared/**
- .github/workflows/python.yml

defaults:
run:
working-directory: packages/selenium-py-devtools

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.12']
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python-version }}
- name: Contract is in sync with shared
run: |
python scripts/gen_contract.py
git diff --exit-code src/wdio_selenium_devtools/_contract.py
- name: 🧪 Unit tests
run: PYTHONPATH=src python -m unittest discover -s tests

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +23 to +37
2 changes: 2 additions & 0 deletions examples/selenium/python-test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Screencast videos are generated next to the test file.
*.webm
49 changes: 49 additions & 0 deletions examples/selenium/python-test/web_form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""A normal Selenium (Python) test — with WebdriverIO DevTools added.

Only two lines differ from a plain Selenium script (marked ← devtools):
``import wdio_selenium_devtools`` and ``devtools.enable()``. Everything the
driver does is then captured and shown live in the DevTools dashboard.

Run it:

pip install wdio-selenium-devtools selenium
python examples/selenium/python-test/web_form.py

The dashboard opens in a dedicated window and captures every command. It stays
open after the test so you can inspect it — close the window (or Ctrl-C) to
finish. The screencast .webm is written next to this file. Requires a
ChromeDriver matching your Chrome (Selenium 4.6+ auto-manages one if none is on
PATH).
"""

import wdio_selenium_devtools as devtools # ← devtools
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By

devtools.enable() # ← devtools: open the dashboard + capture every command

options = Options()
options.add_argument("--headless=new") # remove this line to watch the browser
options.add_argument("--window-size=1280,1024") # bigger viewport → fuller screencast
driver = webdriver.Chrome(options=options)
try:
driver.get("https://www.selenium.dev/selenium/web/web-form.html")

title = driver.title

driver.implicitly_wait(0.5)

text_box = driver.find_element(by=By.NAME, value="my-text")
submit_button = driver.find_element(by=By.CSS_SELECTOR, value="button")

text_box.send_keys("Selenium")
submit_button.click()

message = driver.find_element(by=By.ID, value="message")
text = message.text
print("form submitted, received message:", text) # shows in the dashboard's Console
finally:
driver.quit()
devtools.wait_for_dashboard_close() # ← devtools: keep the UI open to inspect
devtools.disable() # ← devtools
8 changes: 8 additions & 0 deletions packages/selenium-py-devtools/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
venv/
build/
dist/
*.egg-info/
193 changes: 193 additions & 0 deletions packages/selenium-py-devtools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# wdio-selenium-devtools (Python)

Python Selenium adapter for the WebdriverIO DevTools dashboard — the fourth
adapter alongside the JS WebdriverIO / Nightwatch / Selenium-JS ones. It feeds
the **same backend and UI**, unchanged, over the language-neutral
`{scope, data}` WebSocket contract.

**Status: Phase 1 + 2.** Live command capture + test tree; browser console &
network via BiDi; screencast video; and a dashboard window that auto-opens and
tears down with the run. Verified against real headless Chrome. See
[Roadmap](#roadmap) for what's deferred.

## Install (dev)

```bash
pip install -e packages/selenium-py-devtools # or: pip install wdio-selenium-devtools (when published)
```

The transport is **dependency-free** (stdlib WebSocket client). The only thing
on top of your own `selenium` install is this package; `pytest` is optional.

## Use

**With pytest (recommended) — no code changes to your tests:**

```bash
WDIO_DEVTOOLS=1 pytest tests/ # DEVTOOLS_PORT=<n> also opts in (and attaches)
```

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.

**Without pytest** (any script / unittest) — add two lines to a normal Selenium
script (`devtools.enable()` + `devtools.wait_for_dashboard_close()`):

```python
import wdio_selenium_devtools as devtools

devtools.enable() # open dashboard + capture every command
# ... your normal selenium code, ending with driver.quit() ...
devtools.wait_for_dashboard_close() # keep the UI open to inspect (no-op if headless)
devtools.disable()
```

Runnable example: [`examples/selenium/python-test/web_form.py`](../../examples/selenium/python-test/web_form.py).
If the backend can't be launched or reached, `enable()` warns and returns
`None` — capture is skipped, your tests still run.

**ChromeDriver:** you need one matching your Chrome (a mismatch breaks all
Selenium, not just this). Selenium 4.6+ auto-manages it when no `chromedriver`
is on `PATH`; otherwise keep it current (`brew upgrade chromedriver`).

## What it captures

| Data | How | Scope | Phase |
|---|---|---|---|
| Commands (driver + element) | wrap `WebDriver.execute()` — the single chokepoint all commands flow through | `commands` | 1 |
| Session metadata | read `session_id` + `caps` on the first ready command | `metadata` | 1 |
| Test / suite tree | pytest plugin (`pytest_runtest_logreport` / `sessionfinish`) | `suites` | 1 |
| Browser console + JS errors | Selenium **BiDi** (`driver.script` handlers) | `consoleLogs` | 2 |
| Network requests | Selenium **BiDi** (low-level subscribe, no interception) | `networkRequests` | 2 |
| DOM snapshot (preview iframe) | inject `packages/script`, re-inject per navigation, drain mutations | `mutations` | 2 |
| Screencast video | screenshot polling → ffmpeg-encoded `.webm` | `screencast` | 2 |

Element actions (`click`, `send_keys`, `text`, …) are captured for free: they
delegate to `self._parent.execute`, so the one wrapper sees them as
`clickElement`, `getElementText`, etc.

**BiDi is auto-enabled** — the adapter injects the `webSocketUrl` capability
into the `newSession` request so console/network work out-of-box (opt out with
`WDIO_DEVTOOLS_BIDI=0`). **Screencast** needs `ffmpeg` on PATH to encode the
`.webm`; without it, recording is skipped (one warning, no error).

## Dashboard window lifecycle

Like the JS adapters, `enable()` opens the dashboard in a dedicated, closable
Chrome window; closing that window (backend `clientDisconnected`) shuts the run
down, and ending the process (exit / Ctrl-C) closes the window. Auto-open is on
when stdout is a TTY; force it with `WDIO_DEVTOOLS_OPEN=1` or disable with `=0`.

## Layout

```
src/wdio_selenium_devtools/
__init__.py public API — enable() / disable() / get_capturer()
constants.py defaults, env-var names, skip sets, pinned backend version
types.py TypedDicts for the wire payloads (mirror packages/shared)
_contract.py GENERATED from packages/shared — scope names + CONTRACT_VERSION
utils.py framework-agnostic helpers (now_ms, iso, to_jsonable, call_source)
frames.py pure builders for each {scope,data} payload
transport.py stdlib WebSocket client (handshake, masked frames, ping/pong, control reader)
capturer.py SessionCapturer: command IDs, normalize→send, metadata-once
instrumentation.py execute() wrap + BiDi auto-enable + session-setup hook
bidi.py BiDi console/JS-error + network capture (pure mapping + wiring)
screencast.py screenshot-polling recorder + ffmpeg webm encode
backend.py launch-or-attach the Node backend + port discovery
lifecycle.py dashboard window open/close + shutdown-on-disconnect
pytest_plugin.py suite/test tree feeder (opt-in)
scripts/gen_contract.py regenerate _contract.py from shared (dev-time; also a drift-guard)
tests/ stdlib-unittest unit tests (no selenium/pytest needed)
e2e_check.py real-Chrome smoke (plain script)
e2e/test_smoke.py real-Chrome smoke (pytest + plugin)
(examples live at repo root: examples/selenium/python-test/web_form.py)
```

## Backend & publishing

Two artifacts, two registries — pip can't resolve the Node backend, so each
coupling is handled explicitly rather than via a `workspace:^`-style resolver:

| | Local (monorepo) | Published |
|---|---|---|
| **Adapter** (this package) | `pip install -e` | PyPI: `pip install wdio-selenium-devtools` |
| **Backend + UI** (Node) | `node packages/backend/dist/index.js` | npm: `npx @wdio/devtools-backend@<pinned>` |
| **Wire contract** (`shared`) | regenerated into `_contract.py` | the generated `_contract.py` ships in the wheel |

`enable()` obtains the backend in this order (local vs published falls out of it):

1. `DEVTOOLS_PORT` set → attach to an already-running backend (CI, manual).
2. `DEVTOOLS_BACKEND_CMD` set → spawn that explicit command.
3. monorepo `packages/backend/dist/index.js` present → spawn it (**local dev**).
4. else → `npx @wdio/devtools-backend@<BACKEND_NPM_VERSION>` (**published**).

The pinned `BACKEND_NPM_VERSION` in `backend.py` is the version link — there is
no auto-resolution, so it's bumped deliberately alongside a contract change.

Regenerate the contract after any change to `packages/shared`:

```bash
python3 packages/selenium-py-devtools/scripts/gen_contract.py
```

It fails loudly if a scope the adapter needs disappeared from `shared` — a
build-time drift alarm.

## Test

```bash
# unit (no deps):
PYTHONPATH=src python3 -m unittest discover -s tests -v

# e2e (needs selenium + a running backend; Selenium Manager fetches the driver):
DEVTOOLS_PORT=3000 PYTHONPATH=src python3 e2e_check.py
DEVTOOLS_PORT=3000 PYTHONPATH=src pytest e2e/test_smoke.py -p wdio_selenium_devtools.pytest_plugin -q
```

## Release (approach A)

Two workflows, mirroring the JS split (`ci.yml` tests / `release.yml` publish):

- **`python.yml`** — runs on PRs + pushes touching this package or `shared`:
unit tests on Python 3.9 + 3.12, and a contract-drift check (regenerate
`_contract.py`, fail on any diff). Zero repo config needed.
- **`python-release.yml`** — **manual** (`workflow_dispatch`, like the JS
"Manual NPM Publish"), target `pypi` or `testpypi`. Builds the sdist + wheel
and publishes via **trusted publishing (OIDC)** — no token/secret.

The wheel does **not** bundle the backend — approach A fetches a pinned
`@wdio/devtools-backend` via `npx` at runtime (Node 18+ required). Bundling it
(approach B/C) is a GA-time change.

**One-time setup before the first publish** (this is what claims the PyPI name):

1. On PyPI, add a **pending trusted publisher** for project
`wdio-selenium-devtools` → owner `webdriverio`, repo `devtools`, workflow
`python-release.yml`, environment `pypi` (repeat on TestPyPI with env
`testpypi` if you want a dry run first).
2. Create matching GitHub **Environments** `pypi` (and `testpypi`).
3. Run the workflow — the first successful publish creates and claims the name.

Each release: bump `version` in `pyproject.toml`, then run the workflow (PyPI
rejects re-uploading an existing version).

## Roadmap

- **Phase 2 (done)** — BiDi console/network + screenshot-polling screencast.
Not yet: a CDP `Page.startScreencast` push-mode fast-path, per-command
screenshots, and performance capture.
- **Phase 3** — trace export, preserve-and-rerun, action snapshots. Per the
architecture, the heavy post-processing is a candidate to live server-side in
the backend (written once) rather than re-implemented here.

## Design notes

- **Backend/UI unchanged.** This adapter only produces the wire frames; the
server routes and renders them exactly as for the JS adapters.
- **Capture never breaks tests.** Commands are recorded around the real call;
errors are captured *and re-raised* unchanged; a missing dashboard is a no-op.
- **Contract drift** is the main long-term risk (see the integration artifact).
Mitigated two ways: `_contract.py` is generated from `packages/shared` (scope
names + `CONTRACT_VERSION`), and the generator fails if a required scope
vanishes. Full field-level type generation is a future step.
35 changes: 35 additions & 0 deletions packages/selenium-py-devtools/e2e/test_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""pytest smoke that exercises the plugin (suites) + instrumentation (commands).

Run against a running backend:
DEVTOOLS_PORT=63763 PYTHONPATH=src \
pytest e2e/test_smoke.py -p wdio_selenium_devtools.pytest_plugin -q

Uses a data: URL so it needs no network.
"""

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By

PAGE = "data:text/html,<h1>Hello DevTools</h1><a href='%23x'>link</a>"


@pytest.fixture
def driver():
opts = Options()
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
drv = webdriver.Chrome(options=opts)
yield drv
drv.quit()


def test_homepage_loads(driver):
driver.get(PAGE)
assert "Hello DevTools" in driver.find_element(By.CSS_SELECTOR, "h1").text


def test_link_is_clickable(driver):
driver.get(PAGE)
driver.find_element(By.CSS_SELECTOR, "a").click()
Loading
Loading