Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6a1c33c
feat(selenium-py-devtools): add Python Selenium adapter
vishnuv688 Jul 1, 2026
d441d6a
chore(selenium-py-devtools): remove python-spike proof-of-concept
vishnuv688 Jul 2, 2026
6bd69a9
ci(selenium-py-devtools): add path-filtered test + OIDC release workf…
vishnuv688 Jul 2, 2026
2aa29c5
feat(selenium-py-devtools): WS transport, backend launch, dashboard w…
vishnuv688 Jul 2, 2026
7046401
feat(selenium-py-devtools): BiDi console + network capture
vishnuv688 Jul 2, 2026
2fec768
feat(selenium-py-devtools): DOM snapshot capture via injected collector
vishnuv688 Jul 2, 2026
cdb5ff9
feat(selenium-py-devtools): main-thread per-command screencast recorder
vishnuv688 Jul 2, 2026
326fb59
feat(selenium-py-devtools): send test-file source for the Source tab
vishnuv688 Jul 2, 2026
85815da
feat(selenium-py-devtools): stream runner logs + stdout to the Console
vishnuv688 Jul 2, 2026
efc57a3
feat(selenium-py-devtools): command capture core + per-command screen…
vishnuv688 Jul 2, 2026
71e9a8e
feat(selenium-py-devtools): pytest plugin
vishnuv688 Jul 2, 2026
abc0cf3
docs(selenium-py-devtools): README + runnable web-form example
vishnuv688 Jul 2, 2026
2d49142
Merge branch 'main' into vv/selenium-python-integration
vishnuv688 Aug 11, 2026
2dec0b8
Merge branch 'main' into vv/selenium-python-integration
vishnuv688 Aug 11, 2026
6a423b8
Potential fix for pull request finding 'CodeQL / Workflow does not co…
vishnuv688 Aug 12, 2026
eb6969d
refactor(selenium-py-devtools): rename the package to devtools-selenium
vishnuv688 Aug 12, 2026
c25dcde
refactor(selenium-devtools-py): name the package, module and director…
vishnuv688 Aug 12, 2026
7fdbe55
chore(py-backend): Wired backend for selenium py demo
vishnuv688 Aug 13, 2026
14e12d6
Merge branch 'main' into vv/selenium-python-integration
vishnuv688 Aug 14, 2026
0cb56bb
Merge branch 'main' into vv/selenium-python-integration
vishnuv688 Aug 14, 2026
91b2ee2
test(selenium-devtools-py): stop three tests depending on a build art…
vishnuv688 Aug 14, 2026
2b09465
fix(selenium-devtools-py): write run output to test-results, like the…
vishnuv688 Aug 14, 2026
f867b03
chore(selenium-devtools-py): stop the python job running on shared, a…
vishnuv688 Aug 14, 2026
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
9 changes: 9 additions & 0 deletions .changeset/backend-server-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@wdio/devtools-backend": minor
---

The backend ships a runnable entry, so a non-Node adapter can start the dashboard itself.

- `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.
- `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.
- `dev:app` now runs `dist/server.js`, since it was watching an entry that could not start.
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-devtools-py/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-devtools-py

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-devtools-py/dist
- name: 🚀 Publish to TestPyPI
if: ${{ inputs.target == 'testpypi' }}
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: packages/selenium-devtools-py/dist
repository-url: https://test.pypi.org/legacy/
46 changes: 46 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Python Adapter

# Deliberately NOT triggered by packages/shared/**, even though the adapter's
# wire contract is generated from it. Doing so put a Python check on every PR
# that touched shared, which is most of them, and a red Python job would block
# work unrelated to this adapter. The trade-off: a shared change that breaks the
# contract is not caught here at the moment it lands. It is still caught by the
# drift check below on the next PR that touches this package, which regenerates
# _contract.py and fails on any diff. Revisit once the adapter is published and
# the coupling is worth the noise.
on:
push:
branches:
- main
paths:
- packages/selenium-devtools-py/**
- .github/workflows/python.yml
pull_request:
paths:
- packages/selenium-devtools-py/**
- .github/workflows/python.yml

permissions:
contents: read

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

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/selenium_devtools/_contract.py
- name: 🧪 Unit tests
run: PYTHONPATH=src python -m unittest discover -s tests
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ The server adapters connect to and the app talks to.

Contains:

- 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]`.
- Fastify HTTP server.
- WebSocket gateway: one connection per adapter worker, one per app client.
- Baseline store (in-memory) for preserve-and-rerun; reuses `shared` types directly via thin `*Like` aliases (`baseline/types.ts`).
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ Documented divergences from the conventions above. They exist today as debt to b
- **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.
- 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.
- **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.
- **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.
- **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.
- `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`.
- 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.
Expand Down
3 changes: 3 additions & 0 deletions examples/selenium/python-test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Run output goes to test-results/, which the root .gitignore already covers.
# *.webm stays for videos written by older versions of the adapter.
*.webm
35 changes: 35 additions & 0 deletions examples/selenium/python-test/web_form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""The smallest useful DevTools example: three devtools lines, one Selenium test.

This is the script reproduced in the integration one-pager, so keep the two in
sync when either changes.

Run it:

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

``enable()`` starts the dashboard backend itself when none is running. Set
DEVTOOLS_PORT instead to attach to one you already have open. Run output (the
screencast .webm) lands in ``test-results/`` beside this file, matching the JS
adapters.
"""

import selenium_devtools as devtools
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options

devtools.enable() # opens the dashboard, starts capturing

options = Options()
options.add_argument("--headless=new") # drop 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")
driver.find_element(By.NAME, "my-text").send_keys("Selenium")
driver.find_element(By.CSS_SELECTOR, "button").click()
print(driver.find_element(By.ID, "message").text)
finally:
driver.quit()
devtools.wait_for_dashboard_close() # hold the UI open to inspect
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
"scripts": {
"build": "pnpm -r build",
"show-trace": "node packages/backend/dist/show-trace.js",
"backend": "node packages/backend/dist/server.js",
"demo:wdio": "wdio run ./examples/wdio/cucumber/wdio.conf.ts",
"demo:wdio:mocha": "wdio run ./examples/wdio/mocha/wdio.conf.ts",
"demo:wdio:retry": "wdio run ./examples/wdio/mocha/wdio.retry.conf.ts",
"demo:nightwatch": "pnpm --filter @wdio/nightwatch-devtools example",
"demo:nightwatch:retry": "pnpm --filter @wdio/nightwatch-devtools example:retry",
"demo:selenium": "pnpm --filter @wdio/selenium-devtools example",
"demo:python": "python3 examples/selenium/python-test/web_form.py",
"dev": "pnpm --parallel dev",
"preview": "pnpm --parallel preview",
"test": "vitest run",
Expand Down
20 changes: 17 additions & 3 deletions packages/backend/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @wdio/devtools-backend

The server that the three adapter packages connect to and the dashboard UI talks to. Internal to the monorepo — not published.
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.

## Responsibilities

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

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

- **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:
- **commands** — from `before`/`after` action events, with call source, result, error, nearest screenshot frame, and pointer hit point;
Expand All @@ -40,6 +40,20 @@ Because the archive uses that portable, standard trace-viewer format, the same `

## Public API

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`).
Two build entries, and they are deliberately separate files.

- **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`).

- **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:

```sh
npx @wdio/devtools-backend --port 8080 --hostname 0.0.0.0
```

- `--port <number>` (or `--port=<number>`): preferred port; a free one is chosen if it's taken.
- `--hostname <host>` (or `--hostname=<host>`): host to bind, `localhost` by default.
- `-h`, `--help`: print usage and exit without starting.

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.

For the full picture of how events flow adapter → backend → dashboard, see [ARCHITECTURE.md](../../ARCHITECTURE.md).
7 changes: 4 additions & 3 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@
}
},
"bin": {
"devtools-backend": "./dist/server.js",
"show-trace": "./dist/show-trace.js"
},
"types": "./dist/index.d.ts",
"typeScriptVersion": "^5.0.0",
"scripts": {
"dev": "run-p dev:*",
"dev:ts": "tsup src/index.ts src/show-trace.ts --format esm --dts --watch",
"dev:app": "nodemon --watch ./dist ./dist/index.js",
"build": "tsup src/index.ts src/show-trace.ts --format esm --dts --clean",
"dev:ts": "tsup src/index.ts src/show-trace.ts src/server.ts --format esm --dts --watch",
"dev:app": "nodemon --watch ./dist ./dist/server.js",
"build": "tsup src/index.ts src/show-trace.ts src/server.ts --format esm --dts --clean",
"lint": "eslint .",
"prepublishOnly": "pnpm build"
},
Expand Down
11 changes: 0 additions & 11 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import fs from 'node:fs'
import url from 'node:url'

import Fastify, {
type FastifyInstance,
Expand Down Expand Up @@ -443,13 +442,3 @@ export async function stop() {

await server.close()
}

/**
* start server if this file is called directly
*/
if (import.meta.url.startsWith('file:')) {
const modulePath = url.fileURLToPath(import.meta.url)
if (process.argv[1] === modulePath) {
start()
}
}
44 changes: 44 additions & 0 deletions packages/backend/src/server-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Argv parsing for the `devtools-backend` bin. Split from `server.ts` because
* that file starts a server as its module body, so a unit test cannot import it.
*/

export const SERVER_USAGE = `
Usage: devtools-backend [options]

Options:
--port <number> Preferred port; a free one is chosen if it is taken
--hostname <host> Host to bind (default: localhost)
-h, --help Show this message
`

export interface ServerArgs {
help: boolean
port?: number
hostname?: string
}

/** Read `--name value` or `--name=value`, whichever the caller used. */
function option(args: string[], name: string): string | undefined {
const flag = `--${name}`
const at = args.indexOf(flag)
if (at !== -1) {
return args[at + 1]
}
return args.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1)
}

/**
* A port is kept only when it parses as a positive integer, so `--port` with no
* value, `--port abc` and `--port 0` all fall through to the server's own
* default rather than binding something the caller did not ask for.
*/
export function parseServerArgs(args: string[]): ServerArgs {
const port = Number(option(args, 'port'))
const hostname = option(args, 'hostname')
return {
help: args.includes('-h') || args.includes('--help'),
...(Number.isInteger(port) && port > 0 ? { port } : {}),
...(hostname ? { hostname } : {})
}
}
Loading
Loading