Skip to content

Commit 9e66159

Browse files
authored
Merge pull request #246 from webdriverio/vv/selenium-python-integration
feat(selenium-devtools-py): Python Selenium adapter, and a runnable backend entry
2 parents 5de8334 + 07941a3 commit 9e66159

52 files changed

Lines changed: 5979 additions & 17 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/backend-server-entry.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@wdio/devtools-backend": minor
3+
---
4+
5+
The backend ships a runnable entry, so a non-Node adapter can start the dashboard itself.
6+
7+
- `dist/server.js` is a new CLI entry, exposed as the `devtools-backend` bin. `node packages/backend/dist/server.js` and `npx @wdio/devtools-backend` both start the live dashboard, and `--port` / `--hostname` / `--help` are accepted.
8+
- `dist/index.js` stays the library entry the JS adapters import in-process. Its "start if run directly" guard is gone rather than repaired: `show-trace.ts` imports `start` from index, which makes index a shared module whose body tsup hoists into `dist/chunk-*.js`, and there `import.meta.url` is the chunk's path and can never equal `process.argv[1]`. The guard was therefore dead in every build, which is why `node dist/index.js` imported a module and exited 0 without serving. A leaf entry keeps its body in its own output file, so the new file needs no guard at all.
9+
- `dev:app` now runs `dist/server.js`, since it was watching an entry that could not start.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Manual PyPI Publish
2+
3+
# Mirrors release.yml's manual, button-triggered shape for the Python adapter.
4+
# Unlike npm (NPM_TOKEN), PyPI uses trusted publishing (OIDC) — no secret.
5+
# Bump the version in packages/selenium-devtools-py/pyproject.toml before running.
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
target:
11+
description: 'Publish target'
12+
required: true
13+
type: choice
14+
default: pypi
15+
options:
16+
- pypi
17+
- testpypi
18+
19+
defaults:
20+
run:
21+
working-directory: packages/selenium-devtools-py
22+
23+
jobs:
24+
release:
25+
runs-on: ubuntu-latest
26+
environment: ${{ inputs.target }}
27+
permissions:
28+
id-token: write # PyPI trusted publishing (OIDC) — no token/secret needed
29+
steps:
30+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
31+
with:
32+
ref: 'main'
33+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
34+
with:
35+
python-version: '3.12'
36+
- name: 🧪 Unit tests (release guard)
37+
run: PYTHONPATH=src python -m unittest discover -s tests
38+
- name: 📦 Build sdist + wheel
39+
run: |
40+
python -m pip install --upgrade build
41+
python -m build
42+
- name: 🚀 Publish to PyPI
43+
if: ${{ inputs.target == 'pypi' }}
44+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
45+
with:
46+
packages-dir: packages/selenium-devtools-py/dist
47+
- name: 🚀 Publish to TestPyPI
48+
if: ${{ inputs.target == 'testpypi' }}
49+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
50+
with:
51+
packages-dir: packages/selenium-devtools-py/dist
52+
repository-url: https://test.pypi.org/legacy/

‎.github/workflows/python.yml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Python Adapter
2+
3+
# Deliberately NOT triggered by packages/shared/**, even though the adapter's
4+
# wire contract is generated from it. Doing so put a Python check on every PR
5+
# that touched shared, which is most of them, and a red Python job would block
6+
# work unrelated to this adapter. The trade-off: a shared change that breaks the
7+
# contract is not caught here at the moment it lands. It is still caught by the
8+
# drift check below on the next PR that touches this package, which regenerates
9+
# _contract.py and fails on any diff. Revisit once the adapter is published and
10+
# the coupling is worth the noise.
11+
on:
12+
push:
13+
branches:
14+
- main
15+
paths:
16+
- packages/selenium-devtools-py/**
17+
- .github/workflows/python.yml
18+
pull_request:
19+
paths:
20+
- packages/selenium-devtools-py/**
21+
- .github/workflows/python.yml
22+
23+
permissions:
24+
contents: read
25+
26+
defaults:
27+
run:
28+
working-directory: packages/selenium-devtools-py
29+
30+
jobs:
31+
test:
32+
runs-on: ubuntu-latest
33+
strategy:
34+
matrix:
35+
python-version: ['3.9', '3.12']
36+
steps:
37+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
38+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
39+
with:
40+
python-version: ${{ matrix.python-version }}
41+
- name: Contract is in sync with shared
42+
run: |
43+
python scripts/gen_contract.py
44+
git diff --exit-code src/selenium_devtools/_contract.py
45+
- name: 🧪 Unit tests
46+
run: PYTHONPATH=src python -m unittest discover -s tests

‎ARCHITECTURE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ The server adapters connect to and the app talks to.
106106

107107
Contains:
108108

109+
- Two build entries: `index.ts` is the **library** entry (`dist/index.js`) the adapters import in-process for `start`/`stop`, and `server.ts` is the **CLI** entry (`dist/server.js`, executable, shipped as the `devtools-backend` bin) that starts the same server standalone. `server.ts` is a leaf entry on purpose: a self-start guard inside `index.ts` is dead, because `show-trace.ts` imports it and tsup hoists a shared entry's body into `dist/chunk-*.js`, where `import.meta.url` never matches `process.argv[1]`.
109110
- Fastify HTTP server.
110111
- WebSocket gateway: one connection per adapter worker, one per app client.
111112
- Baseline store (in-memory) for preserve-and-rerun; reuses `shared` types directly via thin `*Like` aliases (`baseline/types.ts`).

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ Documented divergences from the conventions above. They exist today as debt to b
287287
- **Assert outcomes are correlated off the assertion's own promise, because the results bag does not exist.** Cucumber's Nightwatch client is built by `createClient` with **no reporter**, so `SimplifiedReporter.logAssertResult` no-ops and `results.assertions`/`results.testcases` are *never* populated — no scenario-level reconcile can recover them, which is why `currentTest: undefined` was a dead end. `nativeAssertions.ts` `observedAssertOutcome` reads the outcome from the returned promise instead (`lib/core/asynctree.js` `shouldRejectNodePromise`: a failing `assert.*` rejects its deferred, a failing `verify.*` resolves *with* the AssertionError, a pass resolves with the command value). A fulfilment of `undefined` stays **neutral** — that is an assertion enqueued but never executed after an earlier `assert.*` emptied the queue, and reading it as a pass would paint a never-run assertion green. The results bag still wins where it exists and the row's window comes from whichever source supplied the outcome, so the describe/it timeline is byte-stable. Measured: **4 of 4 assert rows with a real pass/fail** (was 2 of 4 rows, 0 correlated), spanning real 44–372 ms windows instead of a synthetic 1 ms.
288288
- Relatedly, cucumber's **per-step** `resetCommandTracking()` was wiping the native-assert buffer, so each scenario kept only its *last* step's assertions (measured 1 of 2). The buffer is per **test unit** — `resetTestTracking()` at `wrapBrowserOnce` now clears it.
289289
- **DOM `mutations` ARE captured**, and the old `ECONNRESET` / "collector not found" attribution is **wrong** — neither appears any more. The gap closed itself with document-start injection (`core/bidi-preload.ts`), confirmed on a *baseline* build so the credit is A10's. Measured per scenario with `traceGranularity:'test'` + `webSocketUrl: true`: 39 and 19 mutation entries, 2 DOM anchors each, **0 of 19 rows on the wrong document**. What remains is noise, not a gap: the screencast poller issues `/screenshot` every 200 ms into the session Nightwatch quits per scenario, logging 9–26 `WARN webdriverHttp: … socket hang up` per run. Fix is to stop the recorder before the per-scenario quit, or suppress the warn for a session in teardown.
290+
- **A tsup entry that another entry imports is not a leaf, so an `import.meta.url === process.argv[1]` self-start check inside it is dead code; a CLI has to be its own leaf entry.** tsup hoists a module body shared by two entries into `dist/chunk-*.js`, and there `import.meta.url` is the chunk's path, which can never equal `process.argv[1]`. `packages/backend/src/show-trace.ts` imports `start` from `index.ts`, which is what made index shared, so index's old "start if run directly" guard was dead in **every** build: `node dist/index.js` exited 0 without ever serving, while `dist/show-trace.js` self-started correctly for exactly the same reason inverted, being a leaf whose body stays in its own output file (it also compares realpaths, because the invoked path is the `node_modules/.bin` symlink). The live dashboard server is therefore its own leaf entry, `packages/backend/src/server.ts` (shebang, built to an executable `dist/server.js`, shipped as the `devtools-backend` bin, accepting `--port`, `--hostname`, `-h`/`--help`), and `index.ts` stays library-only for the three adapters' in-process `start`/`stop`. Same family as the `cucumberHooks.cts` entry above: the tsup entry list is part of the contract, and in both cases the symptom was silence rather than an error.
290291
- **A captured text locator is generated in the recording runner's dialect; every other branch is portable CSS.** `shared/locator-dialect.ts` `locatorDialect(runner)` is the one fact table — WDIO runners (`mocha`/`jasmine`/`cucumber`) get `a*=Logout`, `nightwatch*`/`selenium-webdriver` and an **unidentified recorder** get `//a[contains(., "Logout")]`. The id reaches the page script as `CaptureActionSnapshotInput.runner` and the zip as an extension field on `context-options`, read back through `isTestRunnerId` onto `Metadata.runner`; absent in older and foreign zips, where the player shows no hint. Under WDIO a text carrying a `"` still emits XPath — WDIO compiles `tag*=` to XPath with `"` quoting and would build a broken expression — and WDIO resolves `//` itself. `locatorsMatch` decomposes **both** sides from either dialect, so `after.point` survives whichever way round the two grammars fall; the `concat()`-stitched literal is still left to exact comparison. `@wdio/elements`' standalone `getSnapshot` deliberately keeps the portable XPath default (its output is pasted into arbitrary tools), so a WDIO run's `browser.getSnapshot()` and its trace A11y tab disagree on that one branch.
291292
- `Metadata.runner` (typed `TestRunnerId`) and `metadata.options.framework` (untyped `string`, read by the sidebar's `getFramework`) are two carriers of the same fact. All three adapters now set both; the next change to either should collapse `getFramework` onto `Metadata.runner`.
292293
- Selenium's `ctx.runner` (the *detected JS runner* — mocha/jest/cucumber, from `detectRunner()`) shadows the new `Metadata.runner` concept. Renamed to `detectedRunner` at the `driverMetadata` boundary only, where the two met inside one function and a swap would have been a silent dialect bug; the repo-wide rename in `plugin-internals.ts`/`session-lifecycle.ts` is still open.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Run output goes to test-results/, which the root .gitignore already covers.
2+
# *.webm stays for videos written by older versions of the adapter.
3+
*.webm
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""The smallest useful DevTools example: three devtools lines, one Selenium test.
2+
3+
This is the script reproduced in the integration one-pager, so keep the two in
4+
sync when either changes.
5+
6+
Run it:
7+
8+
pip install -e packages/selenium-devtools-py
9+
python examples/selenium/python-test/web_form.py
10+
11+
``enable()`` starts the dashboard backend itself when none is running. Set
12+
DEVTOOLS_PORT instead to attach to one you already have open. Run output (the
13+
screencast .webm) lands in ``test-results/`` beside this file, matching the JS
14+
adapters.
15+
"""
16+
17+
import selenium_devtools as devtools
18+
from selenium import webdriver
19+
from selenium.webdriver.common.by import By
20+
from selenium.webdriver.chrome.options import Options
21+
22+
devtools.enable() # opens the dashboard, starts capturing
23+
24+
options = Options()
25+
options.add_argument("--headless=new") # drop this line to watch the browser
26+
options.add_argument("--window-size=1280,1024") # bigger viewport, fuller screencast
27+
driver = webdriver.Chrome(options=options)
28+
try:
29+
driver.get("https://www.selenium.dev/selenium/web/web-form.html")
30+
driver.find_element(By.NAME, "my-text").send_keys("Selenium")
31+
driver.find_element(By.CSS_SELECTOR, "button").click()
32+
print(driver.find_element(By.ID, "message").text)
33+
finally:
34+
driver.quit()
35+
devtools.wait_for_dashboard_close() # hold the UI open to inspect

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
"scripts": {
77
"build": "pnpm -r build",
88
"show-trace": "node packages/backend/dist/show-trace.js",
9+
"backend": "node packages/backend/dist/server.js",
910
"demo:wdio": "wdio run ./examples/wdio/cucumber/wdio.conf.ts",
1011
"demo:wdio:mocha": "wdio run ./examples/wdio/mocha/wdio.conf.ts",
1112
"demo:wdio:retry": "wdio run ./examples/wdio/mocha/wdio.retry.conf.ts",
1213
"demo:nightwatch": "pnpm --filter @wdio/nightwatch-devtools example",
1314
"demo:nightwatch:retry": "pnpm --filter @wdio/nightwatch-devtools example:retry",
1415
"demo:selenium": "pnpm --filter @wdio/selenium-devtools example",
16+
"demo:python": "python3 examples/selenium/python-test/web_form.py",
1517
"dev": "pnpm --parallel dev",
1618
"preview": "pnpm --parallel preview",
1719
"test": "vitest run",

‎packages/backend/README.md‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @wdio/devtools-backend
22

3-
The server that the three adapter packages connect to and the dashboard UI talks to. Internal to the monorepo — not published.
3+
The server that the three adapter packages connect to and the dashboard UI talks to. Published to npm as `@wdio/devtools-backend` (it's on [CONTRIBUTING.md](../../CONTRIBUTING.md)'s list of published packages, has a `prepublishOnly` build, and ships the `devtools-backend` and `show-trace` bins); the adapters depend on it rather than vendoring it.
44

55
## Responsibilities
66

@@ -25,7 +25,7 @@ Trace mode (see the [root README](../../README.md#-trace-mode-tracezip)) writes
2525
pnpm show-trace path/to/trace.zip
2626
```
2727

28-
The same entry is shipped as a `show-trace` **bin** by the backend (`./dist/show-trace.js`) and by each adapter (`@wdio/devtools-service`, `@wdio/selenium-devtools`, `@wdio/nightwatch-devtools` each ship a thin `bin/show-trace.mjs`), so `npx show-trace <trace.zip>` works in any project that installs an adapter — backend need only be a transitive dependency. In trace-serve mode `start({ trace })` exposes the reconstructed payload at `TRACE_API.get` and skips the worker/rerun machinery.
28+
The same entry is shipped as a `show-trace` **bin** by the backend (`./dist/show-trace.js`) and by each adapter (`@wdio/devtools-service`, `@wdio/selenium-devtools`, `@wdio/nightwatch-devtools` each ship a thin `bin/show-trace.mjs`), so `npx show-trace <trace.zip>` works in any project that installs an adapter — backend need only be a transitive dependency. In trace-serve mode `start({ trace })` exposes the reconstructed payload at `TRACE_API.get` and skips the worker/rerun machinery. It is one of the backend's two bins; the other, `devtools-backend` (`./dist/server.js`), starts the live dashboard server described under [Public API](#public-api).
2929

3030
- **Reader** (`src/trace-reader.ts`, with sibling `trace-reader-{constants,types,utils,groups}.ts`) — `parseTraceZip` / `readTraceZip` reconstruct a `TracePlayerData` payload from the archive. It accepts this repo's own exporter output **and foreign zips** written by other tools (every `.trace` entry is an action-event stream, every `.network` a HAR stream, `.stacks` sidecars carry call stacks). It rebuilds:
3131
- **commands** — from `before`/`after` action events, with call source, result, error, nearest screenshot frame, and pointer hit point;
@@ -40,6 +40,20 @@ Because the archive uses that portable, standard trace-viewer format, the same `
4040

4141
## Public API
4242

43-
The backend is consumed only by other workspace packages. Adapter launchers call `start({ port, hostname })` and receive the bound port. The dashboard accesses it via the documented HTTP routes (`packages/shared/src/baseline.ts`, `packages/shared/src/runner.ts`) and WS scopes (`packages/shared/src/ws.ts`, `packages/shared/src/routes.ts`).
43+
Two build entries, and they are deliberately separate files.
44+
45+
- **Library entry** (`src/index.ts` → `dist/index.js`), consumed in-process by the other workspace packages: adapter launchers call `start({ port, hostname })` and receive the bound port, then `stop()` on teardown. The dashboard accesses the running server via the documented HTTP routes (`packages/shared/src/baseline.ts`, `packages/shared/src/runner.ts`) and WS scopes (`packages/shared/src/ws.ts`, `packages/shared/src/routes.ts`).
46+
47+
- **CLI entry** (`src/server.ts` → the executable `dist/server.js`), shipped as the `devtools-backend` bin, which starts the same server on its own instead of from an adapter's launcher:
48+
49+
```sh
50+
npx @wdio/devtools-backend --port 8080 --hostname 0.0.0.0
51+
```
52+
53+
- `--port <number>` (or `--port=<number>`): preferred port; a free one is chosen if it's taken.
54+
- `--hostname <host>` (or `--hostname=<host>`): host to bind, `localhost` by default.
55+
- `-h`, `--help`: print usage and exit without starting.
56+
57+
Don't collapse the CLI back into `index.ts` behind a "start if run directly" guard: `show-trace.ts` imports `start` from index, which makes index a shared module whose body tsup hoists into `dist/chunk-*.js`, and there `import.meta.url` is the chunk's own path and can never equal `process.argv[1]`, so the guard is dead in every build (`node dist/index.js` exited 0 without ever serving); a leaf entry keeps its body in its own output file, which is why `show-trace.js` self-starts correctly.
4458

4559
For the full picture of how events flow adapter → backend → dashboard, see [ARCHITECTURE.md](../../ARCHITECTURE.md).

‎packages/backend/package.json‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@
1818
}
1919
},
2020
"bin": {
21+
"devtools-backend": "./dist/server.js",
2122
"show-trace": "./dist/show-trace.js"
2223
},
2324
"types": "./dist/index.d.ts",
2425
"typeScriptVersion": "^5.0.0",
2526
"scripts": {
2627
"dev": "run-p dev:*",
27-
"dev:ts": "tsup src/index.ts src/show-trace.ts --format esm --dts --watch",
28-
"dev:app": "nodemon --watch ./dist ./dist/index.js",
29-
"build": "tsup src/index.ts src/show-trace.ts --format esm --dts --clean",
28+
"dev:ts": "tsup src/index.ts src/show-trace.ts src/server.ts --format esm --dts --watch",
29+
"dev:app": "nodemon --watch ./dist ./dist/server.js",
30+
"build": "tsup src/index.ts src/show-trace.ts src/server.ts --format esm --dts --clean",
3031
"lint": "eslint .",
3132
"prepublishOnly": "pnpm build"
3233
},

0 commit comments

Comments
 (0)