Skip to content

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

Open
AutomatedTester wants to merge 5 commits into
trunkfrom
py-install-web-extension
Open

AutomatedTester wants to merge 5 commits 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 → ExtensionPath, file → ExtensionBase64Encoded, 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

Built on common/_bidi, and it teaches the generator about vendor overlays. Per review, this uses the generated protocol layer rather than common/bidi, which is being retired (#17670, #17786) — as far as I can tell this is its first production caller.

The vendor fields the Firefox options map onto were not reachable through it yet. common/bidi/schema.json does declare them, under a vendor section the projector keeps separate so the neutral schema stays neutral, but generate_bidi_protocol.py had no vendor handling, so the generated InstallParameters carried only the untyped extensions bag and install() had no way to accept them. So the generator now folds a vendor overlay back into the type it extends. A vendor field is then an ordinary optional field — it lands in its record, in its command's signature, and in the serializer's type checks with no special casing:

def install(
    self,
    extension_data: ExtensionDataValue,
    moz_allow_private_browsing: bool | UnsetType = UNSET,
    moz_permanent: bool | UnsetType = UNSET,
) -> InstallResult:

The wire key stays fully qualified; only the Python name is namespaced, because moz:permanent is not an identifier. This tracks the schema instead of a hand-maintained list, so it picks up whatever the overlay gains next. An overlay that tried to redeclare an existing field raises rather than quietly winning, since that would change the neutral protocol. Today only webExtension.InstallParameters has an overlay, so nothing else in the generated output moves.

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.

Firefox rejects a permanent install of an unpacked directory. webExtension.sys.mjs throws InvalidWebExtensionError for moz:permanent with extensionData.type == "path", and a permanent install additionally goes through AddonManager.getInstallForFile, so it is signature-checked. The tests use the signed .xpi for permanent=True and the directory for permanent=False, and both constraints are documented on the method.

🤖 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. 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; they record the websocket frames rather than standing in for the webExtension module, so they assert the wire payload itself. The generator's vendor handling is covered in bidi_protocol_command_tests.py. Integration tests for the BiDi path on Firefox and Chromium, for the Grid path via REMOTE_BIDI_TESTS (now that #18023 has landed), and for the Firefox classic fallback in ff_installs_addons_tests.py.

//py:unit, //py:ruff-check, //py:ruff-format and //py:mypy are green locally, as are driver_web_extension_tests-firefox-bidi, -firefox-remote-bidi and -chrome-remote-bidi against real browsers.

Follow-up work:

  • The other four bindings still need this — #17933 tracks them.
  • 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.
  • allow_private_browsing is currently a no-op over BiDi: Firefox's webExtension.install destructures only extensionData and moz:permanent, then calls Addon.installWithPath(path, !permanent, false), so moz:allowPrivateBrowsing is never read. The classic endpoint does honour it. The integration test therefore only asserts the option is accepted; worth raising upstream.
  • 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.
  • The driver.webextension property still points at common/bidi. Moving it is a breaking signature change for existing callers, so it felt like it belongs with the rest of the retirement rather than here.

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.
  • The generated vendor field is named moz_permanent on a shared InstallParameters, rather than Ruby's separate Moz subclass. It keeps the generated install() a single signature; the alternative would need a params variant.

🔄 Types of changes

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

@selenium-ci selenium-ci added C-py Python Bindings B-devtools Includes everything BiDi or Chrome DevTools related labels Aug 29, 2026

@titusfortner titusfortner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be implemented with common/_bidi, not common/bidi. We're retiring common/bidi per #17670 and #17786. The new implementation has the vendor support already baked in without needing to wait on anything form Mozilla.

Once #18023 lands, we can add driver_web_extension_tests.py to REMOTE_BIDI_TESTS so we can test the remote Chrome implementation.

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.
Firefox rejects moz:permanent for an unpacked extension directory with
"Permanent installation of unpacked extensions is not supported", so
combining permanent=True with a directory could never pass. A permanent
install is also signature-checked, because it goes through
AddonManager.getInstallForFile rather than installTemporaryAddon.

Split the combined test into the three cases Firefox supports:
permanent=True against the signed .xpi, permanent=False against the
unpacked directory, and allow_private_browsing on its own.

Firefox's BiDi webExtension.install does not read
moz:allowPrivateBrowsing yet -- it passes allowPrivateBrowsing=false to
Addon.installWithPath unconditionally -- so that test asserts only that
the option is accepted. The classic endpoint does honour it. Document
both constraints on install_web_extension and in the BiDi manifest.
install_web_extension uploads an unpacked extension to the remote end
and installs it from the path handed back, so the Grid path was the one
part of the ADR that only unit tests covered.

Opt the file into REMOTE_BIDI_TESTS so test-chrome-remote-bidi and
test-firefox-remote-bidi pick it up.

The Chromium tests build their own driver because they need
enable_webextensions, so that fixture has to honour the server fixture
as well; without it the tests quietly start a local browser and the
remote target proves nothing.
common/bidi is being retired, so install_web_extension and
uninstall_web_extension now go through common/_bidi instead. This is the
first production caller of the generated protocol layer.

The vendor fields the Firefox options map onto were being written into
the old generator's enhancement manifest by hand. The shared schema
already declares them, under a `vendor` section the projector keeps
separate so the neutral schema stays neutral, so teach the Python
generator to fold a vendor overlay back into the type it extends. A
vendor field is then an ordinary optional field: it lands in its record,
in its command's signature, and in the serializer's type checks with no
special casing, and it tracks the schema rather than a hand-written
list. The wire key stays fully qualified; only the Python name is
namespaced, because `moz:permanent` is not an identifier.

The unit tests now record the websocket frames rather than standing in
for the webExtension module, so they assert the wire payload itself.
webdriver.py now imports selenium.webdriver.common._bidi, but only the
targets that explicitly test BiDi listed :bidi_protocol, so everything
else failed to import the driver at all.

:remote cannot carry the dependency, because the protocol generator
depends on :remote to read errorhandler's error-code tables, and that
would cycle. :common already supplies the old bidi package to the same
consumers, so the new one rides along beside it.
@AutomatedTester

Copy link
Copy Markdown
Member Author

@titusfortner thanks — both points are done, and the PR description has been rewritten to match.

Moved onto common/_bidi (0bc8026). install_web_extension / uninstall_web_extension now go through the generated protocol layer; as far as I can tell this is its first production caller.

One wrinkle worth flagging, since it's the reason this took a generator change rather than an import swap: the vendor fields weren't reachable through _bidi yet. common/bidi/schema.json does declare them —

/vendor/moz/extends/webExtension.InstallParameters/fields -> "moz:allowPrivateBrowsing", "moz:permanent"

— but generate_bidi_protocol.py had no vendor/extends handling, so the generated InstallParameters carried only the untyped extensions bag and install() had no parameter to accept them. So the generator now folds a vendor overlay back into the type it extends, which makes a vendor field an ordinary optional field in its record, its command signature and the serializer's type checks with no special casing:

def install(
    self,
    extension_data: ExtensionDataValue,
    moz_allow_private_browsing: bool | UnsetType = UNSET,
    moz_permanent: bool | UnsetType = UNSET,
) -> InstallResult:

The wire key stays fully qualified; only the Python name is namespaced, since moz:permanent isn't an identifier. This tracks the schema rather than a hand-maintained list, so it picks up whatever the overlay gains next — and it let me drop the entries I'd previously added to bidi_enhancements_manifest.py. An overlay that redeclares an existing field raises rather than quietly winning. Today only webExtension.InstallParameters has an overlay, so nothing else in the generated output moves.

Named moz_permanent on the shared InstallParameters rather than Ruby's separate Moz subclass, to keep install() a single signature — happy to change it if you'd rather the two bindings' generators matched shapes.

A follow-on commit (e87a372) moves :bidi_protocol onto :common. remote/webdriver.py importing _bidi broke every non-BiDi target, because only the -bidi targets listed it. :remote would have been the obvious home but it's a cycle — generate-bidi-protocol-tool depends on :remote for errorhandler's error-code tables — so :common it is, beside where :bidi already enters the graph.

Added to REMOTE_BIDI_TESTS (b64ebd7), now that #18023 has landed. driver_web_extension_tests-firefox-remote-bidi and -chrome-remote-bidi both pass locally against a real Grid, so the upload-and-install-from-returned-path route is finally covered outside unit tests. The Chromium class needed its fixture taught to build a webdriver.Remote when --remote is set; otherwise it quietly span up a local Chrome and proved nothing.

One thing I found along the way that you may care about for #17879: moz:allowPrivateBrowsing is currently a no-op over BiDi. Firefox's webExtension.sys.mjs destructures only { extensionData, "moz:permanent": permanent } and then calls Addon.installWithPath(path, !permanent, false), so the flag is never read. The classic endpoint does honour it. My integration test therefore only asserts the option is accepted — I'd expect Ruby's 'runs in a private window when allowed' spec to fail for the same reason, with its companion passing vacuously.

CI is green on e87a37288b.

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.

3 participants