Skip to content

[py] Install web extensions from the driver - #17970

Open
AutomatedTester wants to merge 1 commit into
trunkfrom
py-install-web-extension
Open

[py] Install web extensions from the driver#17970
AutomatedTester wants to merge 1 commit into
trunkfrom
py-install-web-extension

Conversation

@AutomatedTester

@AutomatedTester AutomatedTester commented Aug 29, 2026

Copy link
Copy Markdown
Member

🔗 Related Issues

Implements the Python binding tracked by #17933 (deliberately not Fixes, since Java, .NET and JavaScript are still outstanding).

Decision record: docs/decisions/17817-driver-extension-install.md (#17817).

Ported alongside the Ruby implementation in #17879 so the two bindings behave the same.

💥 What does this PR do?

Adds install_web_extension and uninstall_web_extension to the driver itself, so installing an extension no longer depends on the browser you happen to be driving.

extension = driver.install_web_extension("/path/to/extension")
driver.uninstall_web_extension(extension)

Against each decision in the ADR:

Decision Implementation
1. Methods on the driver, not a browser-specific type On remote.webdriver.WebDriver, so every driver inherits them
1. Accepts an archive, a directory, or base64 Directory → path, file → base64, anything else passed through as base64
1. Vendor-specific options permanent / allow_private_browsingmoz:permanent / moz:allowPrivateBrowsing
1. Must work with the Grid A remote session uploads the directory and installs from the returned path
1. Returns a WebExtension wrapping the id New public type in selenium.webdriver.common.web_extension
1. Uninstall accepts the object A raw id raises TypeError
2. Firefox falls back to WebDriver-Classic without BiDi moz/addon/install, mapping permanent onto the classic temporary flag
2. Classic methods deprecated install_addon / uninstall_addon now emit a DeprecationWarning
3. Raise when the target cannot honour the request Chromium without BiDi raises WebDriverException; Firefox-only options elsewhere raise ValueError

🔧 Implementation Notes

The upload has to keep the directory as the archive's single root entry. A directory path only resolves on the machine running the browser, so a remote session uploads it first. The Grid answers with the path of the one top-level entry it unpacked, so the archive is built relative to the directory's parent. The classic moz/addon endpoint wants the opposite — the extension's own contents at the archive root — so the two callers zip with different roots. There is a unit test pinning each.

Registering the classic addon endpoints on demand. INSTALL_ADDON and UNINSTALL_ADDON live only in FirefoxRemoteConnection, which a webdriver.Remote session against a Firefox node never constructs, so the classic fallback died on assert command_info is not None with a bare AssertionError: Unrecognised command INSTALL_ADDON. It was also intermittent, because RemoteConnection.__init__ assigns the shared module-level remote_commands dict rather than a copy, so building any local Firefox driver earlier in the process masked it. The driver now registers the two endpoints if the executor lacks them. Ruby avoids this by mixing Firefox::Features into remote bridges; Python has no equivalent seam.

Base64 rather than archivePath for packed extensions. The BiDi command also takes an archive path, but that is a remote-end path, so it would not survive a Grid hop. Sending the bytes inline works everywhere and matches Ruby.

Vendor fields are written directly into the enhancement manifest. Ruby's generator emits a Moz subclass from common/bidi/webextension-install-extensions.cddl; the Python generator emits the extensions bag on InstallParameters but no vendor variants, so the two moz: keys are set in py/private/bidi_enhancements_manifest.py. I verified the wire output is identical to Ruby's — Record.to_json splats the extensions map with payload.update(extras), so both send {"extensionData": …, "moz:permanent": true}. See the follow-up note below.

🤖 AI assistance

  • No substantial AI assistance used
  • AI assisted (complete below)
    • Tool(s): Claude Opus 5 via Claude Code
    • What was generated: the implementation and tests in this PR were drafted with AI assistance, then reviewed line-by-line against the ADR and the Ruby implementation in #17879
    • I reviewed all AI output and can explain the change

💡 Additional Considerations

Tests. 31 unit tests in py/test/unit/selenium/webdriver/common/web_extension_tests.py covering both transports, the archive layouts, the deprecation warnings and the error paths; integration tests for the BiDi path on Firefox and Chromium, and for the Firefox classic fallback in ff_installs_addons_tests.py (that file is outside the BIDI_TESTS glob, so it runs without --bidi). //py:unit, //py:ruff-check, //py:ruff-format and //py:mypy are green locally; the browser tests need CI.

Follow-up work:

  • The other four bindings still need this — #17933 tracks them.
  • common/bidi/webextension-install-extensions.cddl is a placeholder for Mozilla bug 2057588. When that lands upstream, Ruby picks the vendor fields up automatically and Python will not until the Python generator learns to emit vendor variants. Worth an issue.
  • Chromium installs unpacked directories only (#16541); passing an archive surfaces the browser's error rather than being rejected client-side, which keeps the message accurate as chromium-bidi catches up.
  • The deprecated install_addon still carries its own copy of the directory-zipping logic. Ruby's PR folded that into the shared helper; I left it alone to keep this diff reversible, but happy to do it here if preferred.

Decisions worth a reviewer's opinion:

  • Firefox-only options on another browser raise ValueError. Ruby raises ArgumentError structurally, because its Chromium bridge method does not accept the keywords at all. Python cannot do that with a shared signature, so this is a deliberate choice of exception type.
  • uninstall_web_extension rejects a raw id with TypeError; Ruby duck-types it into a NoMethodError.

🔄 Types of changes

  • New feature (non-breaking change which adds functionality and tests!)

Add install_web_extension and uninstall_web_extension to the driver,
implementing the Python binding for ADR 17817. Install accepts an
unpacked directory, a packed archive, or base64 bytes, and returns a
WebExtension wrapping the id the browser assigned. Uninstall takes
that object back rather than a raw id.

A directory only resolves on the machine running the browser, so a
remote session uploads it first and installs from the path the remote
end hands back. The upload keeps the directory as the archive's single
top-level entry, which is what the Grid resolves the returned path
from.

Firefox falls back to the classic moz/addon endpoints when BiDi is not
enabled. Those endpoints are now registered on demand, because only a
webdriver.Firefox session has them in its command registry; a Grid
session driven through webdriver.Remote previously failed with a bare
"AssertionError: Unrecognised command INSTALL_ADDON".

Chromium without BiDi raises instead of silently doing less, and the
Firefox-only install_addon and uninstall_addon are deprecated in
favour of the new methods.
@selenium-ci selenium-ci added C-py Python Bindings B-devtools Includes everything BiDi or Chrome DevTools related labels Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

B-devtools Includes everything BiDi or Chrome DevTools related C-py Python Bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants