From 2ff793dd16e364b70f78215c4a2d9f9aafe6dcd2 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 24 Sep 2026 16:12:00 +0530 Subject: [PATCH 1/6] test(selenium-devtools-py): stop the config tests spawning a dashboard --- packages/selenium-devtools-py/tests/test_pytest_config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/selenium-devtools-py/tests/test_pytest_config.py b/packages/selenium-devtools-py/tests/test_pytest_config.py index c0198b29..984e68a6 100644 --- a/packages/selenium-devtools-py/tests/test_pytest_config.py +++ b/packages/selenium-devtools-py/tests/test_pytest_config.py @@ -293,10 +293,16 @@ def setUpClass(cls): cls._dir = tempfile.TemporaryDirectory() d = pathlib.Path(cls._dir.name) (d / "test_probe.py").write_text("def test_one():\n assert True\n") + # These cases opt capture in, and a real opt-in spawns a backend and a + # dashboard window that outlive the child. Only resolution is asserted. (d / "conftest.py").write_text( textwrap.dedent( """ + import selenium_devtools as devtools from selenium_devtools import pytest_plugin as plugin + + devtools.enable = lambda *args, **kwargs: None + def pytest_configure(config): print(f"RESOLVED={plugin._resolve_enabled(config)}" f",{plugin._resolve_trace(config)}") From b76ddd832be13e3a0875a0b2524e07e49751f3e3 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 24 Sep 2026 16:12:15 +0530 Subject: [PATCH 2/6] build(selenium-devtools-py): state the distribution's metadata --- packages/selenium-devtools-py/LICENSE | 21 +++++++++ packages/selenium-devtools-py/pyproject.toml | 47 +++++++++++++++++-- .../src/selenium_devtools/py.typed | 0 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 packages/selenium-devtools-py/LICENSE create mode 100644 packages/selenium-devtools-py/src/selenium_devtools/py.typed diff --git a/packages/selenium-devtools-py/LICENSE b/packages/selenium-devtools-py/LICENSE new file mode 100644 index 00000000..bdb3aa65 --- /dev/null +++ b/packages/selenium-devtools-py/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 WebdriverIO + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/selenium-devtools-py/pyproject.toml b/packages/selenium-devtools-py/pyproject.toml index 49aaa8ca..8128a909 100644 --- a/packages/selenium-devtools-py/pyproject.toml +++ b/packages/selenium-devtools-py/pyproject.toml @@ -1,19 +1,48 @@ [build-system] -requires = ["hatchling"] +# >=1.27 is where PEP 639 landed; below it the `license` expression and +# `license-files` below fail with an opaque metadata error. +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [project] name = "selenium-devtools-py" -version = "0.1.0" +# Read from `selenium_devtools.__version__`, which has to carry it either way, so +# the wheel and the running package cannot disagree. +dynamic = ["version"] description = "Python Selenium adapter for the WebdriverIO DevTools dashboard" readme = "README.md" # >=3.10 because selenium 4.44 requires it, and network capture needs 4.44+. # Not an independent choice: 3.9 left security support in October 2025. requires-python = ">=3.10" -license = { text = "MIT" } +# SPDX expression plus the file, per PEP 639. The file is a copy because +# `license-files` cannot glob outside the project root, where pnpm pack copies +# the repo's for the npm packages. +license = "MIT" +license-files = ["LICENSE"] authors = [{ name = "WebdriverIO" }] keywords = ["selenium", "webdriver", "devtools", "pytest", "debugging"] +# No `License ::` classifier: PyPI rejects a distribution that carries both that +# and the `license` expression above. +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Framework :: Pytest", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Debuggers", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", + # Backed by `py.typed`; without that marker PEP 561 has a consumer's type + # checker treat every annotation here as `Any`. + "Typing :: Typed", +] + # The transport itself is stdlib-only; selenium is the one real dependency, and # it is REQUIRED rather than an extra. This is a Selenium adapter — it can do # nothing without it — and stating the floor here is the only way pip enforces @@ -27,6 +56,15 @@ keywords = ["selenium", "webdriver", "devtools", "pytest", "debugging"] # also what sets `requires-python` — 4.44 needs 3.10. dependencies = ["selenium>=4.44"] +# The sidebar on the project page. Without these it carries no route back to the +# source, and an adapter whose backend is a separate npm package is one a reader +# has to be able to navigate out of. +[project.urls] +Homepage = "https://github.com/webdriverio/devtools" +Source = "https://github.com/webdriverio/devtools/tree/main/packages/selenium-devtools-py" +Issues = "https://github.com/webdriverio/devtools/issues" +Changelog = "https://github.com/webdriverio/devtools/blob/main/packages/selenium-devtools-py/CHANGELOG.md" + [project.optional-dependencies] # Test-only additions. selenium is not repeated: it is a hard dependency above, # so a second declaration here could drift from it. @@ -41,6 +79,9 @@ test = ["pytest>=7", "pytest-xdist>=3"] [project.entry-points.pytest11] selenium_devtools = "selenium_devtools.pytest_plugin" +[tool.hatch.version] +path = "src/selenium_devtools/__init__.py" + [tool.hatch.build.targets.wheel] packages = ["src/selenium_devtools"] diff --git a/packages/selenium-devtools-py/src/selenium_devtools/py.typed b/packages/selenium-devtools-py/src/selenium_devtools/py.typed new file mode 100644 index 00000000..e69de29b From aa788571566246219bd410ad84a38fe931b8648f Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 24 Sep 2026 16:12:32 +0530 Subject: [PATCH 3/6] build(selenium-devtools-py): decide the version from change fragments --- packages/selenium-devtools-py/CHANGELOG.md | 33 +++ .../selenium-devtools-py/changes/README.md | 27 ++ .../selenium-devtools-py/scripts/changes.py | 227 +++++++++++++++++ .../tests/test_changes.py | 233 ++++++++++++++++++ 4 files changed, 520 insertions(+) create mode 100644 packages/selenium-devtools-py/CHANGELOG.md create mode 100644 packages/selenium-devtools-py/changes/README.md create mode 100644 packages/selenium-devtools-py/scripts/changes.py create mode 100644 packages/selenium-devtools-py/tests/test_changes.py diff --git a/packages/selenium-devtools-py/CHANGELOG.md b/packages/selenium-devtools-py/CHANGELOG.md new file mode 100644 index 00000000..c05c220e --- /dev/null +++ b/packages/selenium-devtools-py/CHANGELOG.md @@ -0,0 +1,33 @@ +# selenium-devtools-py + +Assembled at release from the fragments in `changes/` — see that directory's +README. Changesets generates the npm packages' changelogs from the pnpm +workspace, which this package is not a member of, so it has its own mechanism +of the same shape. + +## 0.1.0 + +First release. + +Python Selenium adapter for the WebdriverIO DevTools dashboard, feeding the same +backend and UI as the JavaScript adapters over the language-neutral +`{scope, data}` WebSocket contract. + +- **Live mode** — command capture and the test tree, browser console and network + over BiDi, assertion rows, per-command screenshots and selectors, DOM replay, + and a pushed CDP screencast. +- **Trace mode** — the same portable `trace.zip` the JavaScript adapters write, + with action snapshots, sources and a transcript. Built by the backend on the + adapter's behalf, since this package ships no Node. +- **Run controls** — Run, Rerun, Run-all and Preserve & Rerun, with reruns + selected by pytest nodeid and spawned in pytest's own rootdir. +- **pytest plugin** — auto-discovered, opt-in per run (`--devtools`, + `--devtools-trace`), per project (`[tool.pytest.ini_options]`) or per shell + (`DEVTOOLS_ENABLE`). Installing it never changes how an existing suite behaves. +- Ships `py.typed`, so the annotations already on the public API reach a + consumer's type checker instead of resolving to `Any`. +- Requires Python 3.10+, `selenium>=4.44`, and Node.js 18+ on PATH for the + backend. + +Known gaps against the JavaScript adapters are listed under Roadmap in the +README. diff --git a/packages/selenium-devtools-py/changes/README.md b/packages/selenium-devtools-py/changes/README.md new file mode 100644 index 00000000..f2adcae2 --- /dev/null +++ b/packages/selenium-devtools-py/changes/README.md @@ -0,0 +1,27 @@ +# Pending changes + +One file per user-visible change to this package, deleted when a release +consumes them. The same idea as `.changeset/` at the repo root, kept separate +because changesets reads the pnpm workspace and this package is not in it — +a changeset naming `selenium-devtools-py` is a hard error that fails the npm +release for every other package. + +Add one with any filename ending `.md`: + +```md +--- +minor +--- + +Serve the page collector from the backend, so DOM replay works from a published +install. +``` + +The frontmatter is the bump level alone — `patch`, `minor` or `major`. The body +is what a user reading the changelog needs to know; write it for them, not for a +reviewer. + +At release, `scripts/changes.py apply` takes the highest level of all pending +files, bumps `__version__`, writes the section into `CHANGELOG.md`, and deletes +the files it consumed. CI refuses a pull request that changes `src/` without +adding one. diff --git a/packages/selenium-devtools-py/scripts/changes.py b/packages/selenium-devtools-py/scripts/changes.py new file mode 100644 index 00000000..4c38ddaf --- /dev/null +++ b/packages/selenium-devtools-py/scripts/changes.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Pending-change fragments for this package — the changesets stand-in. + +Changesets cannot serve this package: it discovers packages through the pnpm +workspace and identifies them by `package.json`, and this one is in neither. A +changeset naming it does not degrade, it raises "not in the workspace" and fails +`changeset version`, taking the whole npm release with it. + +So the same shape lives here: one fragment per change declaring a bump level, +assembled at release into a version and a changelog section. + +Run: python3 scripts/changes.py {check,next-version,apply} [--base REF] +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import re +import subprocess +import sys +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parent.parent +CHANGES_DIR = PACKAGE_ROOT / "changes" +CHANGELOG = PACKAGE_ROOT / "CHANGELOG.md" +VERSION_FILE = PACKAGE_ROOT / "src" / "selenium_devtools" / "__init__.py" + +# Ordered weakest to strongest: a release takes the strongest level pending. +BUMPS = ("patch", "minor", "major") + +VERSION_RE = re.compile(r'^__version__ = "(?P[^"]+)"$', re.MULTILINE) +FRAGMENT_RE = re.compile(r"\A---\s*\n(?P\w+)\s*\n---\s*\n(?P.*)\Z", re.S) + +# Where a release inserts its section: after the header prose, before the +# newest existing entry. +FIRST_RELEASE_HEADING_RE = re.compile(r"^## ", re.MULTILINE) + + +class ChangeError(Exception): + """A fragment or version that cannot be read as intended.""" + + +def parse_fragment(text: str) -> tuple[str, str]: + """Return ``(bump, body)`` for one fragment's source.""" + match = FRAGMENT_RE.match(text.strip() + "\n") + if not match: + raise ChangeError( + "expected a fragment opening with `---`, a bump level, and `---`" + ) + bump = match.group("bump").lower() + if bump not in BUMPS: + raise ChangeError(f"unknown bump level {bump!r}; expected one of {BUMPS}") + body = match.group("body").strip() + if not body: + raise ChangeError("fragment has no body — say what changed, for a user") + return bump, body + + +def load_fragments(directory: Path | None = None) -> list[tuple[Path, str, str]]: + """Every pending fragment as ``(path, bump, body)``, in filename order. + + The paths resolve at call time, not as default arguments: bound at import a + default freezes a copy, so the module constants above would stop being the + thing that decides where this reads. + """ + found = [] + for path in sorted((directory or CHANGES_DIR).glob("*.md")): + if path.name == "README.md": + continue + try: + found.append((path, *parse_fragment(path.read_text()))) + except ChangeError as exc: + raise ChangeError(f"{path.name}: {exc}") from exc + return found + + +def highest_bump(bumps: list[str]) -> str: + return max(bumps, key=BUMPS.index) + + +def next_version(current: str, bump: str) -> str: + try: + major, minor, patch = (int(part) for part in current.split(".")) + except ValueError as exc: + raise ChangeError(f"{current!r} is not a three-part version") from exc + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def read_version(path: Path | None = None) -> str: + path = path or VERSION_FILE + match = VERSION_RE.search(path.read_text()) + if not match: + raise ChangeError(f"no __version__ assignment in {path}") + return match.group("version") + + +def write_version(version: str, path: Path | None = None) -> None: + path = path or VERSION_FILE + text = path.read_text() + updated, count = VERSION_RE.subn(f'__version__ = "{version}"', text, count=1) + if count != 1: + raise ChangeError(f"no __version__ assignment in {path}") + path.write_text(updated) + + +def as_bullet(body: str) -> str: + """One fragment body as a list item. + + Continuation lines are indented under the bullet, or a second paragraph + closes the list and reads as prose belonging to the release rather than to + the entry. Blank lines are left empty rather than indented, which would + otherwise leave trailing whitespace on every one of them. + """ + first, *rest = body.split("\n") + lines = [f"- {first}"] + lines += [f" {line}" if line.strip() else "" for line in rest] + return "\n".join(lines) + + +def render_section(version: str, bodies: list[str], today: dt.date) -> str: + entries = "\n\n".join(as_bullet(body) for body in bodies) + return f"## {version} — {today.isoformat()}\n\n{entries}\n" + + +def insert_section(changelog: str, section: str) -> str: + """Put the new section above the newest existing one.""" + match = FIRST_RELEASE_HEADING_RE.search(changelog) + if not match: + return changelog.rstrip() + "\n\n" + section + head, tail = changelog[: match.start()], changelog[match.start() :] + return f"{head}{section}\n{tail}" + + +def apply_release(today: dt.date | None = None) -> str: + """Consume the pending fragments. Returns the version to publish.""" + fragments = load_fragments() + current = read_version() + if not fragments: + # Not an error: the first release publishes a version written by hand, + # and the workflow's index preflight is what refuses a version already + # published with nothing new to say. + return current + + version = next_version(current, highest_bump([bump for _, bump, _ in fragments])) + write_version(version) + section = render_section( + version, [body for _, _, body in fragments], today or dt.date.today() + ) + CHANGELOG.write_text(insert_section(CHANGELOG.read_text(), section)) + for path, _, _ in fragments: + path.unlink() + return version + + +def changed_files(base: str) -> list[str]: + result = subprocess.run( + ["git", "diff", "--name-only", f"{base}...HEAD"], + capture_output=True, + text=True, + check=True, + cwd=PACKAGE_ROOT, + ) + return [line for line in result.stdout.splitlines() if line] + + +def check(base: str) -> int: + """Refuse a source change that documents nothing.""" + files = changed_files(base) + package = "packages/selenium-devtools-py/" + touched_src = [f for f in files if f.startswith(f"{package}src/")] + if not touched_src: + print("no change to src/ — no fragment needed") + return 0 + if load_fragments(): + print("src/ changed and a change fragment is present") + return 0 + # An edit to the changelog itself also counts. Before the first release + # there is nothing to bump from, so the pending entry IS the changelog + # section, and a fragment would invent a version nobody publishes. + if f"{package}CHANGELOG.md" in files: + print("src/ changed and the changelog was edited directly") + return 0 + print( + "::error::this branch changes packages/selenium-devtools-py/src/ but " + "documents nothing.\nAdd a fragment under " + "packages/selenium-devtools-py/changes/ — see that directory's\nREADME " + "— or edit CHANGELOG.md directly. Changed:\n " + + "\n ".join(touched_src[:10]), + file=sys.stderr, + ) + return 1 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + check_cmd = sub.add_parser("check", help="fail if src/ changed with no fragment") + check_cmd.add_argument("--base", required=True, help="the ref to compare against") + sub.add_parser("next-version", help="print the version a release would publish") + sub.add_parser("apply", help="consume fragments, bump the version and changelog") + args = parser.parse_args(argv) + + try: + if args.command == "check": + return check(args.base) + if args.command == "next-version": + fragments = load_fragments() + current = read_version() + if not fragments: + print(current) + return 0 + print(next_version(current, highest_bump([b for _, b, _ in fragments]))) + return 0 + print(apply_release()) + return 0 + except ChangeError as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/selenium-devtools-py/tests/test_changes.py b/packages/selenium-devtools-py/tests/test_changes.py new file mode 100644 index 00000000..7c2bb922 --- /dev/null +++ b/packages/selenium-devtools-py/tests/test_changes.py @@ -0,0 +1,233 @@ +"""The fragment-to-release machinery. + +It runs once per release and rewrites the version, the changelog and the +fragments in one pass, so a bug in it is discovered by a bad release. The pure +parts are exercised here against a temp package tree. +""" + +from __future__ import annotations + +import datetime as dt +import importlib.util +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parent.parent / "scripts" + + +def _load_script(): + spec = importlib.util.spec_from_file_location("changes", SCRIPTS / "changes.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +changes = _load_script() + + +def _fragment(bump: str, body: str) -> str: + return f"---\n{bump}\n---\n\n{body}\n" + + +class ParseFragmentTest(unittest.TestCase): + def test_a_well_formed_fragment_yields_bump_and_body(self) -> None: + self.assertEqual( + changes.parse_fragment(_fragment("minor", "Serve the collector.")), + ("minor", "Serve the collector."), + ) + + def test_a_multi_line_body_is_kept_whole(self) -> None: + body = "First line.\n\nSecond paragraph." + self.assertEqual(changes.parse_fragment(_fragment("patch", body))[1], body) + + def test_an_unknown_bump_is_refused(self) -> None: + with self.assertRaises(changes.ChangeError): + changes.parse_fragment(_fragment("huge", "x")) + + def test_a_bodyless_fragment_is_refused(self) -> None: + # A changelog line nobody wrote is worse than an absent one. + with self.assertRaises(changes.ChangeError): + changes.parse_fragment("---\npatch\n---\n") + + def test_prose_without_frontmatter_is_refused(self) -> None: + with self.assertRaises(changes.ChangeError): + changes.parse_fragment("just a note\n") + + +class BumpTest(unittest.TestCase): + def test_the_strongest_level_pending_wins(self) -> None: + self.assertEqual(changes.highest_bump(["patch", "major", "minor"]), "major") + self.assertEqual(changes.highest_bump(["patch", "minor"]), "minor") + self.assertEqual(changes.highest_bump(["patch"]), "patch") + + def test_each_level_moves_the_expected_part(self) -> None: + self.assertEqual(changes.next_version("0.1.0", "patch"), "0.1.1") + self.assertEqual(changes.next_version("0.1.0", "minor"), "0.2.0") + self.assertEqual(changes.next_version("0.1.0", "major"), "1.0.0") + + def test_a_bump_resets_the_parts_below_it(self) -> None: + self.assertEqual(changes.next_version("1.4.7", "minor"), "1.5.0") + self.assertEqual(changes.next_version("1.4.7", "major"), "2.0.0") + + def test_a_malformed_version_is_refused(self) -> None: + with self.assertRaises(changes.ChangeError): + changes.next_version("0.1", "patch") + + +class ChangelogTest(unittest.TestCase): + def test_a_new_section_lands_above_the_newest_existing_one(self) -> None: + existing = "# Title\n\nPreamble.\n\n## 0.1.0 — 2026-01-01\n\n- First.\n" + section = changes.render_section("0.2.0", ["Second."], dt.date(2026, 2, 2)) + merged = changes.insert_section(existing, section) + self.assertLess(merged.index("## 0.2.0"), merged.index("## 0.1.0")) + self.assertIn("Preamble.", merged) + # The preamble must stay above both, not be pushed under the new entry. + self.assertLess(merged.index("Preamble."), merged.index("## 0.2.0")) + + def test_a_changelog_with_no_sections_yet_still_gains_one(self) -> None: + section = changes.render_section("0.1.0", ["First."], dt.date(2026, 2, 2)) + merged = changes.insert_section("# Title\n\nPreamble.\n", section) + self.assertIn("## 0.1.0", merged) + self.assertLess(merged.index("Preamble."), merged.index("## 0.1.0")) + + def test_every_body_reaches_the_section(self) -> None: + section = changes.render_section( + "0.2.0", ["One.", "Two."], dt.date(2026, 2, 2) + ) + self.assertIn("- One.", section) + self.assertIn("- Two.", section) + + def test_a_second_paragraph_stays_inside_its_bullet(self) -> None: + # Unindented it closes the list, and the paragraph reads as belonging to + # the release rather than to the entry. + section = changes.render_section( + "0.2.0", ["Headline.\n\nDetail."], dt.date(2026, 2, 2) + ) + self.assertIn("- Headline.\n\n Detail.", section) + + +class CheckTest(unittest.TestCase): + """The PR gate, with the diff stubbed so no git history is needed.""" + + def setUp(self) -> None: + self._saved = changes.changed_files + self._fragments = changes.load_fragments + changes.load_fragments = lambda *a, **k: [] + + def tearDown(self) -> None: + changes.changed_files = self._saved + changes.load_fragments = self._fragments + + def _files(self, *paths: str) -> None: + changes.changed_files = lambda base: list(paths) + + def test_a_docs_only_change_needs_nothing(self) -> None: + self._files("packages/selenium-devtools-py/README.md") + self.assertEqual(changes.check("main"), 0) + + def test_a_src_change_with_no_record_is_refused(self) -> None: + self._files("packages/selenium-devtools-py/src/selenium_devtools/bidi.py") + self.assertEqual(changes.check("main"), 1) + + def test_a_fragment_satisfies_it(self) -> None: + self._files("packages/selenium-devtools-py/src/selenium_devtools/bidi.py") + changes.load_fragments = lambda *a, **k: [(Path("a.md"), "patch", "Fixed.")] + self.assertEqual(changes.check("main"), 0) + + def test_a_changelog_edit_satisfies_it(self) -> None: + # The bootstrap case: before the first release the pending entry is the + # changelog section itself. + self._files( + "packages/selenium-devtools-py/src/selenium_devtools/bidi.py", + "packages/selenium-devtools-py/CHANGELOG.md", + ) + self.assertEqual(changes.check("main"), 0) + + def test_another_package_src_is_not_this_gate_s_business(self) -> None: + self._files("packages/selenium-devtools/src/index.ts") + self.assertEqual(changes.check("main"), 0) + + +class ApplyReleaseTest(unittest.TestCase): + """The whole pass, against a throwaway copy of the package's layout.""" + + def setUp(self) -> None: + self._dir = tempfile.TemporaryDirectory() + root = Path(self._dir.name) + (root / "changes").mkdir() + (root / "src" / "selenium_devtools").mkdir(parents=True) + (root / "src" / "selenium_devtools" / "__init__.py").write_text( + '"""doc."""\n\n__version__ = "0.1.0"\n\nX = 1\n' + ) + (root / "CHANGELOG.md").write_text( + "# selenium-devtools-py\n\nPreamble.\n\n## 0.1.0\n\nFirst release.\n" + ) + self._patched = { + "CHANGES_DIR": root / "changes", + "CHANGELOG": root / "CHANGELOG.md", + "VERSION_FILE": root / "src" / "selenium_devtools" / "__init__.py", + } + self._saved = {k: getattr(changes, k) for k in self._patched} + for key, value in self._patched.items(): + setattr(changes, key, value) + self.root = root + + def tearDown(self) -> None: + for key, value in self._saved.items(): + setattr(changes, key, value) + self._dir.cleanup() + + def _write(self, name: str, bump: str, body: str) -> None: + (self.root / "changes" / name).write_text(_fragment(bump, body)) + + def test_a_release_bumps_writes_and_consumes(self) -> None: + self._write("a.md", "patch", "Fixed a thing.") + self._write("b.md", "minor", "Added a thing.") + + version = changes.apply_release(today=dt.date(2026, 3, 4)) + + self.assertEqual(version, "0.2.0") + self.assertEqual(changes.read_version(self._patched["VERSION_FILE"]), "0.2.0") + changelog = self._patched["CHANGELOG"].read_text() + self.assertIn("## 0.2.0 — 2026-03-04", changelog) + self.assertIn("- Added a thing.", changelog) + self.assertIn("- Fixed a thing.", changelog) + # The previous release survives, below the new one. + self.assertLess(changelog.index("## 0.2.0"), changelog.index("## 0.1.0")) + self.assertEqual(list(self._patched["CHANGES_DIR"].glob("*.md")), []) + + def test_the_rest_of_the_version_module_is_untouched(self) -> None: + self._write("a.md", "patch", "Fixed.") + changes.apply_release(today=dt.date(2026, 3, 4)) + text = self._patched["VERSION_FILE"].read_text() + self.assertIn('"""doc."""', text) + self.assertIn("X = 1", text) + + def test_no_fragments_leaves_everything_alone(self) -> None: + # The first release publishes a hand-written version; the workflow's + # index preflight is what refuses a version already published. + before = self._patched["CHANGELOG"].read_text() + self.assertEqual(changes.apply_release(today=dt.date(2026, 3, 4)), "0.1.0") + self.assertEqual(self._patched["CHANGELOG"].read_text(), before) + + def test_the_directory_readme_is_not_a_fragment(self) -> None: + (self.root / "changes" / "README.md").write_text("How to add a fragment.\n") + self.assertEqual(changes.apply_release(today=dt.date(2026, 3, 4)), "0.1.0") + + def test_a_broken_fragment_names_itself_and_writes_nothing(self) -> None: + self._write("good.md", "patch", "Fixed.") + (self.root / "changes" / "bad.md").write_text("no frontmatter\n") + with self.assertRaises(changes.ChangeError) as caught: + changes.apply_release(today=dt.date(2026, 3, 4)) + self.assertIn("bad.md", str(caught.exception)) + self.assertEqual(changes.read_version(self._patched["VERSION_FILE"]), "0.1.0") + self.assertTrue((self.root / "changes" / "good.md").exists()) + + +if __name__ == "__main__": + unittest.main() From 611fe4adb7547a257dc4b1f82e1d7ceb9dcd6137 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 24 Sep 2026 16:12:49 +0530 Subject: [PATCH 4/6] ci: refuse a Python release the pinned backend cannot serve --- .../scripts/check_backend_pin.py | 132 ++++++++++++++++++ .../tests/test_check_backend_pin.py | 86 ++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 packages/selenium-devtools-py/scripts/check_backend_pin.py create mode 100644 packages/selenium-devtools-py/tests/test_check_backend_pin.py diff --git a/packages/selenium-devtools-py/scripts/check_backend_pin.py b/packages/selenium-devtools-py/scripts/check_backend_pin.py new file mode 100644 index 00000000..eab98f59 --- /dev/null +++ b/packages/selenium-devtools-py/scripts/check_backend_pin.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Assert the pinned backend can actually serve this adapter. + +``BACKEND_NPM_VERSION`` is the backend a *published* install gets: nobody who +ran ``pip install`` has the monorepo's ``dist/server.js``, so the ``npx`` tier is +the only one that ever runs for them. A pin older than the routes and scopes the +adapter sends ships a feature dead, and dead quietly — a missing collector +settles as "DOM replay disabled" and an unanswered ``traceExport`` times out, +both leaving the run green. + +`python.yml`'s drift check cannot catch it: that compares `_contract.py` against +`shared`, the monorepo's source, while this compares it against the npm tarball +a user downloads. The two agreed for every commit between the backend's last +release and this adapter's first. + +Run: python3 scripts/check_backend_pin.py +""" + +from __future__ import annotations + +import io +import json +import sys +import tarfile +import urllib.error +import urllib.request +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "src" +sys.path.insert(0, str(SRC)) + +from selenium_devtools import _contract as contract # noqa: E402 +from selenium_devtools.constants import ( # noqa: E402 + BACKEND_NPM_PACKAGE, + BACKEND_NPM_VERSION, +) + +REGISTRY = "https://registry.npmjs.org" +NETWORK_TIMEOUT_S = 60 + +# Named by contract constant, not by literal, so a rename in shared reaches this +# check through `_contract.py` rather than leaving a hand-copied string behind. +REQUIRED_BACKEND_NAMES = { + "COLLECTOR_PATH": "serves the page collector — without it DOM replay is off", + "ELEMENT_SCRIPTS_PATH": "serves the page-side element scripts", + "SCOPE_TRACE_EXPORT": "builds the trace archive this adapter cannot build itself", + "SCOPE_TRACE_EXPORTED": "answers the trace export request", + "SCOPE_ACTION_SNAPSHOTS": "accumulates per-action DOM snapshots", + "SCOPE_SCREENCAST_FRAMES": "accepts pushed screencast frames", + "RERUN_SLOT_TEST_ID": "substitutes a pytest nodeid into a rerun command", +} + +QUOTES = ('"', "'", "`") + + +def carries(dist: str, literal: str) -> bool: + """Whether the bundle contains ``literal`` as a whole string. + + Quoted on both sides rather than searched bare, because `traceExport` is a + prefix of `traceExported`: bare, a backend that dropped the request handler + but kept the reply name passes. Any quote style counts so the check does not + turn vacuous again if the bundler changes how it emits strings. + """ + return any(f"{q}{literal}{q}" in dist for q in QUOTES) + + +def published_dist(package: str, version: str) -> str: + """Return every published ``dist/*.js`` of one npm version, concatenated.""" + meta_url = f"{REGISTRY}/{package}/{version}" + with urllib.request.urlopen(meta_url, timeout=NETWORK_TIMEOUT_S) as response: + tarball = json.load(response)["dist"]["tarball"] + with urllib.request.urlopen(tarball, timeout=NETWORK_TIMEOUT_S) as response: + payload = response.read() + + sources = [] + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive: + for member in archive.getmembers(): + name = member.name.removeprefix("package/") + if not member.isfile() or not name.startswith("dist/"): + continue + if not name.endswith(".js"): + continue + handle = archive.extractfile(member) + if handle is not None: + sources.append(handle.read().decode("utf-8", "replace")) + if not sources: + raise RuntimeError(f"{package}@{version} published no dist/*.js") + return "\n".join(sources) + + +def missing_names(dist: str) -> list[tuple[str, str, str]]: + """The ``(constant, literal, why)`` the given bundle does not carry.""" + return [ + (name, getattr(contract, name), why) + for name, why in REQUIRED_BACKEND_NAMES.items() + if not carries(dist, getattr(contract, name)) + ] + + +def main() -> int: + pin = f"{BACKEND_NPM_PACKAGE}@{BACKEND_NPM_VERSION}" + try: + dist = published_dist(BACKEND_NPM_PACKAGE, BACKEND_NPM_VERSION) + except urllib.error.HTTPError as exc: + if exc.code == 404: + print(f"FAIL: {pin} is not published on npm", file=sys.stderr) + return 1 + raise + + missing = missing_names(dist) + if not missing: + print(f"{pin} serves all {len(REQUIRED_BACKEND_NAMES)} pinned contract names") + return 0 + + print( + f"FAIL: {pin} does not serve this adapter's contract.\n" + f" A `pip install` user gets that backend, so each name below is a\n" + f" feature that would ship broken:\n", + file=sys.stderr, + ) + for name, literal, why in missing: + print(f" {name} ({literal!r}) — {why}", file=sys.stderr) + print( + "\n Release the backend to npm first, then raise " + "BACKEND_NPM_VERSION in constants.py.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/selenium-devtools-py/tests/test_check_backend_pin.py b/packages/selenium-devtools-py/tests/test_check_backend_pin.py new file mode 100644 index 00000000..b4f12253 --- /dev/null +++ b/packages/selenium-devtools-py/tests/test_check_backend_pin.py @@ -0,0 +1,86 @@ +"""The release gate that compares the pinned backend against the contract. + +Worth testing rather than trusting to CI: the gate's only observable behaviour +in a green run is silence, so a bug that made it pass unconditionally would look +exactly like a correct check and be discovered by a broken release. +""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parent.parent / "scripts" + + +def _load_script(): + """Import the script by path — `scripts/` is not a package on sys.path.""" + spec = importlib.util.spec_from_file_location( + "check_backend_pin", SCRIPTS / "check_backend_pin.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +gate = _load_script() + + +def _bundle(*literals: str, quote: str = '"') -> str: + """A stand-in bundle carrying each literal the way a bundler emits it.""" + return " ".join(f"{quote}{literal}{quote}" for literal in literals) + + +def _all_literals() -> list[str]: + return [getattr(gate.contract, name) for name in gate.REQUIRED_BACKEND_NAMES] + + +class MissingNamesTest(unittest.TestCase): + def test_a_bundle_carrying_every_literal_is_clean(self) -> None: + self.assertEqual(gate.missing_names(_bundle(*_all_literals())), []) + + def test_every_quote_style_counts(self) -> None: + for quote in gate.QUOTES: + with self.subTest(quote=quote): + bundle = _bundle(*_all_literals(), quote=quote) + self.assertEqual(gate.missing_names(bundle), []) + + def test_an_empty_bundle_reports_every_name(self) -> None: + missing = gate.missing_names("") + self.assertEqual( + {name for name, _, _ in missing}, set(gate.REQUIRED_BACKEND_NAMES) + ) + + def test_a_prefix_of_another_literal_does_not_satisfy_it(self) -> None: + # `traceExport` is a strict prefix of `traceExported`, so a bare + # substring search reports the request handler present in a bundle that + # only ever mentions the reply — the one shape this gate must not miss. + absent = gate.contract.SCOPE_TRACE_EXPORT + bundle = _bundle(*[lit for lit in _all_literals() if lit != absent]) + missing = gate.missing_names(bundle) + self.assertEqual([name for name, _, _ in missing], ["SCOPE_TRACE_EXPORT"]) + self.assertEqual(missing[0][1], absent) + + def test_an_unquoted_mention_does_not_count(self) -> None: + # A path echoed in a comment or a log line is not a route the backend + # serves. + self.assertIn( + "COLLECTOR_PATH", + [name for name, _, _ in gate.missing_names(gate.contract.COLLECTOR_PATH)], + ) + + def test_every_required_name_exists_on_the_contract(self) -> None: + # A renamed constant must break here, not silently drop a check: an + # absent attribute would otherwise raise only on the release run. + for name in gate.REQUIRED_BACKEND_NAMES: + self.assertTrue( + hasattr(gate.contract, name), f"{name} is no longer a contract constant" + ) + + +if __name__ == "__main__": + unittest.main() From 79057add7a4c0786ac1a6448313524d9095bcd73 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 24 Sep 2026 16:13:10 +0530 Subject: [PATCH 5/6] ci(selenium-devtools-py): run the release checks CI already runs --- .github/actions/python-package/action.yml | 46 +++++++++++++++ .github/workflows/python-release.yml | 69 +++++++++++++++++++++-- .github/workflows/python.yml | 39 +++++++------ CLAUDE.md | 13 +++++ CONTRIBUTING.md | 20 ++++++- packages/selenium-devtools-py/README.md | 46 +++++++++++---- 6 files changed, 198 insertions(+), 35 deletions(-) create mode 100644 .github/actions/python-package/action.yml diff --git a/.github/actions/python-package/action.yml b/.github/actions/python-package/action.yml new file mode 100644 index 00000000..9b2cd77b --- /dev/null +++ b/.github/actions/python-package/action.yml @@ -0,0 +1,46 @@ +name: Python package checks +description: Install, test and build the Python adapter — the checks CI and the release share. + +# One definition because both callers must agree. Running the release guard +# weaker than CI is the failure this replaces: it used to test without +# installing, and the tests that pin selenium's BiDi surface skip when selenium +# is absent, so a bump that moved those internals passed the guard and degraded +# at runtime. + +inputs: + working-directory: + description: The package root. + required: false + default: packages/selenium-devtools-py + +runs: + using: composite + steps: + # Every step names the directory: a composite action does not inherit the + # caller's `defaults.run.working-directory`. + - name: Install the adapter and its runtime dependency + shell: bash + working-directory: ${{ inputs.working-directory }} + run: pip install -e '.[test]' + + - name: Contract is in sync with shared + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + python scripts/gen_contract.py + git diff --exit-code src/selenium_devtools/_contract.py + + - name: 🧪 Unit tests + shell: bash + working-directory: ${{ inputs.working-directory }} + run: PYTHONPATH=src python -m unittest discover -s tests + + # Packaging used to be exercised for the first time at release, where a + # broken `pyproject.toml` surfaces with the publish button already pressed. + - name: 📦 Build sdist + wheel + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + python -m pip install --upgrade build twine + python -m build + twine check --strict dist/* diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index cef410c0..c13b8b87 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -2,7 +2,7 @@ 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. +# Bump `__version__` in src/selenium_devtools/__init__.py before running. on: workflow_dispatch: @@ -20,25 +20,63 @@ defaults: run: working-directory: packages/selenium-devtools-py +# A version uploads once, so an overlapping run fails on the winner's upload. +concurrency: + group: python-release-${{ inputs.target }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest environment: ${{ inputs.target }} permissions: id-token: write # PyPI trusted publishing (OIDC) — no token/secret needed + contents: write # commits the version bump and tags the published tree steps: + # main, and with history: the release DECIDES the version by consuming + # `changes/`, then commits and tags the result. A tag cannot be the input + # to that, because it would have to name a version nothing has computed + # yet — so `py-v` is an output, pointing at exactly the tree that + # was published. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: 'main' + fetch-depth: 0 - 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 + # Consumes changes/ — bumps `__version__` by the strongest level pending + # and writes the changelog section. On testpypi it stays in the working + # tree so a dry run builds the real next artifact without spending the + # fragments; only a pypi release commits it below. + - name: Apply pending change fragments + run: python scripts/changes.py apply + # Before the build, because the alternative is finding out from twine's + # 400 once the checks have already run. Also what refuses a release with + # no fragments and nothing new to say: the version would be unchanged and + # therefore already published. + - name: Version is not already on the index + env: + INDEX_HOST: ${{ inputs.target == 'testpypi' && 'test.pypi.org' || 'pypi.org' }} run: | - python -m pip install --upgrade build - python -m build + VERSION=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/selenium_devtools/__init__.py) + if [ -z "$VERSION" ]; then + echo "::error::could not read __version__ from src/selenium_devtools/__init__.py" + exit 1 + fi + CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + "https://$INDEX_HOST/pypi/selenium-devtools-py/$VERSION/json") + if [ "$CODE" = "200" ]; then + echo "::error::selenium-devtools-py $VERSION is already on $INDEX_HOST — a version uploads once, so bump __version__" + exit 1 + fi + echo "$VERSION is free on $INDEX_HOST (HTTP $CODE)" + - uses: ./.github/actions/python-package + # The pinned backend is the one a `pip install` user runs, and an older + # one serves none of the adapter's routes and scopes while leaving the run + # green, so this refuses the publish rather than the merge. + - name: Pinned backend serves this adapter's contract + run: python scripts/check_backend_pin.py - name: 🚀 Publish to PyPI if: ${{ inputs.target == 'pypi' }} uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 @@ -50,3 +88,22 @@ jobs: with: packages-dir: packages/selenium-devtools-py/dist repository-url: https://test.pypi.org/legacy/ + + # After the publish, so main never claims a release that did not happen. + # A dry run reaches none of this: its bump stays in the runner's tree and + # the fragments live on for the real release to spend. + - name: Record the release + if: ${{ inputs.target == 'pypi' }} + run: | + VERSION=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/selenium_devtools/__init__.py) + git config user.email "bot@webdriver.io" + git config user.name "WebdriverIO Release Bot" + git add src/selenium_devtools/__init__.py CHANGELOG.md changes/ + if git diff --cached --quiet; then + echo "no version change to record" + exit 0 + fi + git commit -m "chore(selenium-devtools-py): release $VERSION" + git tag "py-v$VERSION" + git push origin HEAD:main + git push origin "py-v$VERSION" diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 2571011a..224f56d5 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -20,19 +20,17 @@ on: - packages/selenium-devtools-py/** - packages/shared/src/** - .github/workflows/python.yml + - .github/actions/python-package/** pull_request: paths: - packages/selenium-devtools-py/** - packages/shared/src/** - .github/workflows/python.yml + - .github/actions/python-package/** permissions: contents: read -defaults: - run: - working-directory: packages/selenium-devtools-py - jobs: test: runs-on: ubuntu-latest @@ -42,20 +40,27 @@ jobs: # 4.44 requires it, and network capture requires 4.44+. python-version: ['3.10', '3.13'] steps: + # Full history: the change-fragment gate diffs this branch against the + # base, which a depth-1 checkout cannot see. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} - # selenium is the adapter's only runtime dependency, and the tests that - # pin its BiDi surface (tests/test_selenium_surface.py) skip without it — - # so a bump that moved those internals used to pass CI green and degrade - # at runtime. Installing the package is what makes those guards real. - - name: Install the adapter and its runtime dependency - run: pip install -e '.[test]' - - - 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 + # A source change with no fragment is a release that cannot describe + # itself, and the version it would ship is decided by these files. + - name: Source changes carry a change fragment + if: github.event_name == 'pull_request' + working-directory: packages/selenium-devtools-py + env: + BASE_REF: ${{ github.base_ref }} + run: python scripts/changes.py check --base "origin/$BASE_REF" + - uses: ./.github/actions/python-package + # The pin is the backend a `pip install` user actually runs, and the drift + # check above cannot see it: that compares `_contract.py` against shared, + # this compares it against the tarball npm serves. Red here means the next + # PyPI release would ship features the pinned backend cannot answer. + - name: Pinned backend serves this adapter's contract + working-directory: packages/selenium-devtools-py + run: python scripts/check_backend_pin.py diff --git a/CLAUDE.md b/CLAUDE.md index 953f2050..7abcba88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ Run from repo root unless noted. | `pnpm lint` | Lint all packages in parallel. Includes `eslint-plugin-security` for a subset of CodeQL findings; deeper taint-flow checks surface on the PR's CodeQL scan. | | `pnpm demo:wdio` / `pnpm demo:nightwatch` / `pnpm demo:selenium` | Run the per-framework example projects. Useful for manual verification of UI or runtime changes. | | `pnpm dev` | Run all packages in parallel dev mode. | +| `python3 packages/selenium-devtools-py/scripts/changes.py next-version` | The version a Python-adapter release would publish, from the fragments pending in `changes/`. `check --base ` is the CI gate; `apply` is what the release runs. | `selenium-devtools` exposes per-runner variants of its example via `pnpm --filter @wdio/selenium-devtools example:mocha` / `:mocha:allure` / `:jest` / `:cucumber`. @@ -223,9 +224,21 @@ When the right place is ambiguous (something between `shared` and `core`, or bet ### Before pushing - `pnpm build`, `pnpm test`, `pnpm lint`. Don't push red. +- A changeset for a published npm package, or a `changes/` fragment for the Python adapter — see § Releasing a change. - For UI or runtime changes: verify in `examples//`. - Deeper security findings (taint flow, polynomial-redos with adjacent quantifiers) surface on the PR's CodeQL scan; review and fix those before merge. +### Releasing a change + +Two mechanisms, and the Python one exists because the npm one cannot reach it. Changesets discovers packages through the pnpm workspace and identifies them by `package.json`; `packages/selenium-devtools-py` is in neither, so a changeset naming `selenium-devtools-py` does not degrade — it raises "not in the workspace", fails `changeset version`, and takes the npm release for every other package down with it. + +- **Published npm package changed** → `pnpm changeset`, committed as `.changeset/*.md`. +- **`packages/selenium-devtools-py/src/` changed** → a fragment under `packages/selenium-devtools-py/changes/`, frontmatter carrying the bump level alone (`patch`/`minor`/`major`). `python.yml` refuses a branch that changes `src/` and documents nothing; a fragment or a direct `CHANGELOG.md` edit satisfies it, the latter because before the first release there is nothing to bump from and the pending entry IS the changelog section. + +Neither is hand-versioned: both assemble the version and the changelog at release. The Python release additionally consumes its fragments, bumps `__version__` (the single source — `pyproject.toml` reads it via `dynamic = ["version"]`), and tags `py-v` **after** a successful publish, so the tag is an output pointing at the published tree rather than an input naming a version nothing has computed yet. + +`BACKEND_NPM_VERSION` is the backend a `pip install` user actually runs, so the npm release goes first; `release.yml` opens the pin bump as a PR, and `scripts/check_backend_pin.py` refuses a PyPI publish whose pinned backend cannot serve the contract. + ### Commits - Small, focused. Don't bundle unrelated changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01d3d7ce..998d3ef2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,11 +82,29 @@ Then pick: Commit the generated `.changeset/*.md` with your change. You don't edit `CHANGELOG.md` or version numbers — the release generates those from your changeset. Publishing itself is a **manual step a maintainer runs** (the "Manual NPM Publish" GitHub Action), so your job ends at landing the changeset. +### The Python adapter has its own + +**If your change touches `packages/selenium-devtools-py/src/`, add a change fragment** — a file under `packages/selenium-devtools-py/changes/`: + +```md +--- +minor +--- + +Serve the page collector from the backend, so DOM replay works from a published install. +``` + +The frontmatter is the bump level alone (`patch`, `minor`, `major`); the body is what a user reading the changelog needs to know. CI refuses a branch that changes `src/` and documents nothing. As with changesets you don't edit the version or `CHANGELOG.md` — the release consumes the fragments, takes the strongest level pending, bumps `__version__`, writes the changelog section and tags `py-v`. + +```bash +python3 packages/selenium-devtools-py/scripts/changes.py next-version # what a release would publish +``` + ## Before you push - `pnpm build`, `pnpm test`, and `pnpm lint` all green — don't push red. - UI / runtime changes verified in `examples//`. -- A changeset added if a published package changed (`pnpm changeset`). +- A changeset added if a published package changed (`pnpm changeset`), or a change fragment under `packages/selenium-devtools-py/changes/` if the Python adapter's `src/` changed. - User-facing changes (a new option, CLI, flag, output, or workflow) update the relevant README **and** are mirrored to the [WebdriverIO devtools webpage](https://webdriver.io/docs/devtools) in the same change. ## Pull requests diff --git a/packages/selenium-devtools-py/README.md b/packages/selenium-devtools-py/README.md index 283f0b88..ec18bd6c 100644 --- a/packages/selenium-devtools-py/README.md +++ b/packages/selenium-devtools-py/README.md @@ -490,16 +490,30 @@ DEVTOOLS_PORT=3000 PYTHONPATH=src pytest e2e/test_smoke.py -p selenium_devtools. 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.10 + 3.13, and a contract-drift check (regenerate - `_contract.py`, fail on any diff). Zero repo config needed. + unit tests on Python 3.10 + 3.13, a contract-drift check (regenerate + `_contract.py`, fail on any diff), and a build + `twine check --strict` so a + packaging mistake surfaces on the PR rather than under the publish button. + 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. + "Manual NPM Publish"), target `pypi` or `testpypi`. Runs the same four checks, + then 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. +That pin is the one thing a release here cannot get wrong, because it is the +backend every published install runs — nobody who typed `pip install` has the +monorepo's `dist/server.js`. A pin older than the routes and scopes the adapter +sends is not a stale number but a feature shipped dead, and dead quietly: a +missing collector settles as "DOM replay disabled" and an unanswered +`traceExport` times out, both leaving the run green. `scripts/check_backend_pin.py` +downloads the pinned version from npm and fails the release if its `dist/` does +not carry every contract literal. **So the npm release comes first**: publish the +backend, take the pin bump it opens as a PR (`release.yml` raises +`BACKEND_NPM_VERSION` whenever a `latest` release leaves it behind), then publish +here. + **One-time setup before the first publish** (this is what claims the PyPI name): 1. On PyPI, add a **pending trusted publisher** for project @@ -509,8 +523,21 @@ The wheel does **not** bundle the backend — approach A fetches a pinned 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). +Each release: run the workflow. It consumes the fragments in `changes/`, takes +the strongest bump level pending, rewrites `__version__` in +`src/selenium_devtools/__init__.py` (the only place a version is written — +`pyproject.toml` reads it through `dynamic`), writes the `CHANGELOG.md` section, +publishes, then commits the result and tags `py-v`. Nothing is +hand-versioned. + +Every change to `src/` needs a fragment; CI refuses a branch without one. See +[`changes/README.md`](./changes/README.md) for the format, and +`scripts/changes.py next-version` for what a release would publish. Changesets +cannot do this job — it reads the pnpm workspace, which this package is not in, +and a changeset naming it fails the npm release for every other package. + +A `testpypi` run bumps in the runner's tree only: the fragments survive, so a +dry run never spends them. ## Roadmap @@ -519,12 +546,9 @@ per-command screenshots and selectors, performance timings, run controls (Run / Rerun / Run-all) and Preserve & Rerun are all done — see the sections above. What the JavaScript adapters have and this one does not: -- **Trace slicing and retention.** A run produces one archive; there is no - `traceGranularity` (session / spec / test) and no `tracePolicy` - (`retain-on-failure` and friends). Per-test slicing needs boundaries only the - adapter knows, and the backend's accumulator is run-scoped. - **Per-test artifacts.** No `screenshot` / `video` options and no Allure - attachment; those are per-test-slice features and follow the item above. + attachment. The policy types and the slicing they key off exist; what is + missing is producing the artifacts and handing them to a reporter. - **Shared capture code.** The adapter reimplements the wire producers rather than calling `core`, which is what [#278](https://github.com/webdriverio/devtools/issues/278) exists to address. From f55642e6e7d8e2be8287cb7620bbf122f9957f91 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 25 Sep 2026 09:08:11 +0530 Subject: [PATCH 6/6] fix(ci): close the gaps review found in the Python release flow --- .github/workflows/python-release.yml | 67 +++++++++++++++---- .github/workflows/python.yml | 2 + CLAUDE.md | 2 +- .../selenium-devtools-py/scripts/changes.py | 36 +++++++--- .../tests/test_changes.py | 25 ++++++- 5 files changed, 107 insertions(+), 25 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index c13b8b87..168cc005 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -64,13 +64,22 @@ jobs: echo "::error::could not read __version__ from src/selenium_devtools/__init__.py" exit 1 fi - CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ - "https://$INDEX_HOST/pypi/selenium-devtools-py/$VERSION/json") - if [ "$CODE" = "200" ]; then - echo "::error::selenium-devtools-py $VERSION is already on $INDEX_HOST — a version uploads once, so bump __version__" - exit 1 - fi - echo "$VERSION is free on $INDEX_HOST (HTTP $CODE)" + # Only a 404 is evidence the version is free. Treating "not 200" as + # free makes a 5xx or a DNS failure read as availability, which is the + # opposite of what this step is for: the preflight would pass on no + # information and the duplicate would surface as an upload error. + CODE=$(curl -sS --retry 3 --retry-delay 2 --retry-all-errors \ + -o /dev/null -w '%{http_code}' \ + "https://$INDEX_HOST/pypi/selenium-devtools-py/$VERSION/json" || echo 000) + case "$CODE" in + 404) echo "$VERSION is free on $INDEX_HOST" ;; + 200) + echo "::error::selenium-devtools-py $VERSION is already on $INDEX_HOST — a version uploads once, so add a change fragment or bump __version__" + exit 1 ;; + *) + echo "::error::$INDEX_HOST answered HTTP $CODE; cannot establish whether $VERSION is free" + exit 1 ;; + esac - uses: ./.github/actions/python-package # The pinned backend is the one a `pip install` user runs, and an older # one serves none of the adapter's routes and scopes while leaving the run @@ -99,11 +108,43 @@ jobs: git config user.email "bot@webdriver.io" git config user.name "WebdriverIO Release Bot" git add src/selenium_devtools/__init__.py CHANGELOG.md changes/ + + # The tag hangs off the PUBLISH, not off whether files changed. A + # first release has no fragments to consume, so nothing is staged and + # the older form returned here — publishing a version that then + # carried no tag, which is the one case the tag exists to cover. if git diff --cached --quiet; then - echo "no version change to record" - exit 0 + echo "nothing to commit; tagging the published tree" + else + git commit -m "chore(selenium-devtools-py): release $VERSION" + # PyPI has already accepted the upload, so failing to land this + # leaves a published version unrecorded and a rerun refused by the + # index preflight. Concurrency only serialises this workflow, and + # anyone may push to main meanwhile — so rebase onto whatever + # arrived and try again rather than giving up on the first reject. + pushed=false + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + pushed=true + break + fi + echo "main moved; rebasing onto it (attempt $attempt)" + git fetch origin main + git rebase origin/main || { + git rebase --abort || true + echo "::error::could not rebase the release commit onto main" + break + } + done + if [ "$pushed" != true ]; then + echo "::error::selenium-devtools-py $VERSION is PUBLISHED but its release commit is not on main. Land __version__, CHANGELOG.md and the changes/ deletions by hand before the next release." + exit 1 + fi + fi + + if git rev-parse -q --verify "refs/tags/py-v$VERSION" >/dev/null; then + echo "py-v$VERSION already exists" + else + git tag "py-v$VERSION" + git push origin "py-v$VERSION" fi - git commit -m "chore(selenium-devtools-py): release $VERSION" - git tag "py-v$VERSION" - git push origin HEAD:main - git push origin "py-v$VERSION" diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 224f56d5..23a79a4c 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -20,12 +20,14 @@ on: - packages/selenium-devtools-py/** - packages/shared/src/** - .github/workflows/python.yml + - .github/workflows/python-release.yml - .github/actions/python-package/** pull_request: paths: - packages/selenium-devtools-py/** - packages/shared/src/** - .github/workflows/python.yml + - .github/workflows/python-release.yml - .github/actions/python-package/** permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 7abcba88..9cb4b493 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,7 +233,7 @@ When the right place is ambiguous (something between `shared` and `core`, or bet Two mechanisms, and the Python one exists because the npm one cannot reach it. Changesets discovers packages through the pnpm workspace and identifies them by `package.json`; `packages/selenium-devtools-py` is in neither, so a changeset naming `selenium-devtools-py` does not degrade — it raises "not in the workspace", fails `changeset version`, and takes the npm release for every other package down with it. - **Published npm package changed** → `pnpm changeset`, committed as `.changeset/*.md`. -- **`packages/selenium-devtools-py/src/` changed** → a fragment under `packages/selenium-devtools-py/changes/`, frontmatter carrying the bump level alone (`patch`/`minor`/`major`). `python.yml` refuses a branch that changes `src/` and documents nothing; a fragment or a direct `CHANGELOG.md` edit satisfies it, the latter because before the first release there is nothing to bump from and the pending entry IS the changelog section. +- **`packages/selenium-devtools-py/src/` changed** → a fragment under `packages/selenium-devtools-py/changes/`, frontmatter carrying the bump level alone (`patch`/`minor`/`major`). `python.yml` refuses a branch that changes `src/` and documents nothing. A direct `CHANGELOG.md` edit satisfies it **only until the first release** — before one there is nothing to bump from and the pending entry IS the changelog section; after one (detected by a `py-v*` tag existing) it documents the change but bumps nothing, so the release would find no fragment and republish a version the index already holds. Neither is hand-versioned: both assemble the version and the changelog at release. The Python release additionally consumes its fragments, bumps `__version__` (the single source — `pyproject.toml` reads it via `dynamic = ["version"]`), and tags `py-v` **after** a successful publish, so the tag is an output pointing at the published tree rather than an input naming a version nothing has computed yet. diff --git a/packages/selenium-devtools-py/scripts/changes.py b/packages/selenium-devtools-py/scripts/changes.py index 4c38ddaf..fb3295b2 100644 --- a/packages/selenium-devtools-py/scripts/changes.py +++ b/packages/selenium-devtools-py/scripts/changes.py @@ -168,6 +168,23 @@ def changed_files(base: str) -> list[str]: return [line for line in result.stdout.splitlines() if line] +def ever_released() -> bool: + """Whether a release has ever been published from this tree. + + The release tags `py-v` only after the index has accepted the + upload, so the presence of one is the local evidence — no network, and + true exactly when a version exists that a later change must bump past. + """ + result = subprocess.run( + ["git", "tag", "--list", "py-v*"], + capture_output=True, + text=True, + check=True, + cwd=PACKAGE_ROOT, + ) + return bool(result.stdout.strip()) + + def check(base: str) -> int: """Refuse a source change that documents nothing.""" files = changed_files(base) @@ -179,17 +196,20 @@ def check(base: str) -> int: if load_fragments(): print("src/ changed and a change fragment is present") return 0 - # An edit to the changelog itself also counts. Before the first release - # there is nothing to bump from, so the pending entry IS the changelog - # section, and a fragment would invent a version nobody publishes. - if f"{package}CHANGELOG.md" in files: - print("src/ changed and the changelog was edited directly") + # Only until the first release. Before it the pending entry IS the + # changelog section and a fragment would invent a version nobody + # publishes; after it, an edit here documents the change but bumps + # nothing, so `apply` finds no fragment and the release republishes a + # version the index already has. + if not ever_released() and f"{package}CHANGELOG.md" in files: + print("src/ changed and the changelog was edited, before any release") return 0 print( "::error::this branch changes packages/selenium-devtools-py/src/ but " - "documents nothing.\nAdd a fragment under " - "packages/selenium-devtools-py/changes/ — see that directory's\nREADME " - "— or edit CHANGELOG.md directly. Changed:\n " + "adds no change\nfragment. Add one under " + "packages/selenium-devtools-py/changes/ — see that\ndirectory's README. " + "It is what decides the next version; editing CHANGELOG.md\nby hand " + "documents the change but releases nothing. Changed:\n " + "\n ".join(touched_src[:10]), file=sys.stderr, ) diff --git a/packages/selenium-devtools-py/tests/test_changes.py b/packages/selenium-devtools-py/tests/test_changes.py index 7c2bb922..0ef942e8 100644 --- a/packages/selenium-devtools-py/tests/test_changes.py +++ b/packages/selenium-devtools-py/tests/test_changes.py @@ -117,11 +117,14 @@ class CheckTest(unittest.TestCase): def setUp(self) -> None: self._saved = changes.changed_files self._fragments = changes.load_fragments + self._released = changes.ever_released changes.load_fragments = lambda *a, **k: [] + changes.ever_released = lambda: False def tearDown(self) -> None: changes.changed_files = self._saved changes.load_fragments = self._fragments + changes.ever_released = self._released def _files(self, *paths: str) -> None: changes.changed_files = lambda base: list(paths) @@ -139,15 +142,31 @@ def test_a_fragment_satisfies_it(self) -> None: changes.load_fragments = lambda *a, **k: [(Path("a.md"), "patch", "Fixed.")] self.assertEqual(changes.check("main"), 0) - def test_a_changelog_edit_satisfies_it(self) -> None: - # The bootstrap case: before the first release the pending entry is the - # changelog section itself. + def test_a_changelog_edit_satisfies_it_before_the_first_release(self) -> None: + # The bootstrap case: the pending entry is the changelog section itself. self._files( "packages/selenium-devtools-py/src/selenium_devtools/bidi.py", "packages/selenium-devtools-py/CHANGELOG.md", ) self.assertEqual(changes.check("main"), 0) + def test_a_changelog_edit_stops_satisfying_it_once_released(self) -> None: + # After a release the changelog documents but bumps nothing, so the + # release would find no fragment and republish a version the index + # already holds. + changes.ever_released = lambda: True + self._files( + "packages/selenium-devtools-py/src/selenium_devtools/bidi.py", + "packages/selenium-devtools-py/CHANGELOG.md", + ) + self.assertEqual(changes.check("main"), 1) + + def test_a_fragment_still_satisfies_it_once_released(self) -> None: + changes.ever_released = lambda: True + self._files("packages/selenium-devtools-py/src/selenium_devtools/bidi.py") + changes.load_fragments = lambda *a, **k: [(Path("a.md"), "patch", "Fixed.")] + self.assertEqual(changes.check("main"), 0) + def test_another_package_src_is_not_this_gate_s_business(self) -> None: self._files("packages/selenium-devtools/src/index.ts") self.assertEqual(changes.check("main"), 0)