[py] Install web extensions from the driver - #17970
AutomatedTester wants to merge 5 commits into
Conversation
titusfortner
left a comment
There was a problem hiding this comment.
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.
4f82231 to
5f77bf9
Compare
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.
a9347c4 to
0bc8026
Compare
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.
|
@titusfortner thanks — both points are done, and the PR description has been rewritten to match. Moved onto 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 — but 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 Named A follow-on commit (e87a372) moves Added to One thing I found along the way that you may care about for #17879: CI is green on |
🔗 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_extensionanduninstall_web_extensionto the driver itself, so installing an extension no longer depends on the browser you happen to be driving.Against each decision in the ADR:
remote.webdriver.WebDriver, so every driver inherits themExtensionPath, file →ExtensionBase64Encoded, anything else passed through as base64permanent/allow_private_browsing→moz:permanent/moz:allowPrivateBrowsingWebExtensionwrapping the idselenium.webdriver.common.web_extensionTypeErrormoz/addon/install, mappingpermanentonto the classictemporaryflaginstall_addon/uninstall_addonnow emit aDeprecationWarningWebDriverException; Firefox-only options elsewhere raiseValueError🔧 Implementation Notes
Built on
common/_bidi, and it teaches the generator about vendor overlays. Per review, this uses the generated protocol layer rather thancommon/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.jsondoes declare them, under avendorsection the projector keeps separate so the neutral schema stays neutral, butgenerate_bidi_protocol.pyhad novendorhandling, so the generatedInstallParameterscarried only the untypedextensionsbag andinstall()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:The wire key stays fully qualified; only the Python name is namespaced, because
moz:permanentis 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 onlywebExtension.InstallParametershas 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/addonendpoint 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_ADDONandUNINSTALL_ADDONlive only inFirefoxRemoteConnection, which awebdriver.Remotesession against a Firefox node never constructs, so the classic fallback died onassert command_info is not Nonewith a bareAssertionError: Unrecognised command INSTALL_ADDON. It was also intermittent, becauseRemoteConnection.__init__assigns the shared module-levelremote_commandsdict 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 mixingFirefox::Featuresinto remote bridges; Python has no equivalent seam.Base64 rather than
archivePathfor 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.mjsthrowsInvalidWebExtensionErrorformoz:permanentwithextensionData.type == "path", and a permanent install additionally goes throughAddonManager.getInstallForFile, so it is signature-checked. The tests use the signed.xpiforpermanent=Trueand the directory forpermanent=False, and both constraints are documented on the method.🤖 AI assistance
💡 Additional Considerations
Tests. Unit tests in
py/test/unit/selenium/webdriver/common/web_extension_tests.pycovering 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 inbidi_protocol_command_tests.py. Integration tests for the BiDi path on Firefox and Chromium, for the Grid path viaREMOTE_BIDI_TESTS(now that #18023 has landed), and for the Firefox classic fallback inff_installs_addons_tests.py.//py:unit,//py:ruff-check,//py:ruff-formatand//py:mypyare green locally, as aredriver_web_extension_tests-firefox-bidi,-firefox-remote-bidiand-chrome-remote-bidiagainst real browsers.Follow-up work:
allow_private_browsingis currently a no-op over BiDi: Firefox'swebExtension.installdestructures onlyextensionDataandmoz:permanent, then callsAddon.installWithPath(path, !permanent, false), somoz:allowPrivateBrowsingis never read. The classic endpoint does honour it. The integration test therefore only asserts the option is accepted; worth raising upstream.install_addonstill 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.driver.webextensionproperty still points atcommon/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:
ValueError. Ruby raisesArgumentErrorstructurally, 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_extensionrejects a raw id withTypeError; Ruby duck-types it into aNoMethodError.moz_permanenton a sharedInstallParameters, rather than Ruby's separateMozsubclass. It keeps the generatedinstall()a single signature; the alternative would need a params variant.🔄 Types of changes