commit 1653d9f1dafeae4fc654aaf9fcf63e0f14f98ba9 parent 6791c71836b166ba377aa5a8a9c2e8a2c9f397e3 Author: Elijah Sawyers <esawyers@apple.com> Date: Tue, 14 Oct 2025 22:24:32 +0000 Bug 1947644 [wpt PR 50648] - Add the ability to load and test web extensions., a=testonly Automatic update from web-platform-tests Add the ability to load and test web extensions. Validate this change by writing a few simple tests that verify that some APIs on browser.runtime behave as expected. -- wpt-commits: 77bf2efd14aab125b4e1305bf18d08645e9bb933 wpt-pr: 50648 Diffstat:
18 files changed, 312 insertions(+), 13 deletions(-)
diff --git a/testing/web-platform/tests/docs/writing-tests/testharness.md b/testing/web-platform/tests/docs/writing-tests/testharness.md @@ -199,7 +199,35 @@ dedicated worker tests and shared worker tests](testharness-api.html#determining-when-all-tests-are-complete), it is automatically invoked for tests defined using the "multi-global" pattern. -## Other features of `.window.js`, `.worker.js` and `.any.js` +## Extension tests (`.extension.js`) + +Create a JavaScript file whose name ends in `.extension.js` to have the necessary HTML boilerplate +generated for you at `.extension.html`. + +Extension tests leverage the `browser.test` API rather than interacting with the `testharness.js` +framework directly. + +For example, one could write a test for `browser.runtime.getURL()` by creating a +`web-extensions/browser.runtime.extension.js` file as follows: + +```js +runTestsWithWebExtension("/resources/runtime/") +// ==> this method assumes that the extension resources (manifest, scripts, etc.) exist at the path +``` + +And by creating a `web-extensions/resources/runtime/background.js` file as follows: + +```js +browser.test.runTests([ + function getURLWithNoParameter() { + browser.test.assertThrows(() => browser.runtime.getURL()) + } +]) +``` + +This test could then be run from `web-extensions/browser.runtime.extension.html`. + +## Other features of `.window.js`, `.worker.js`, `.any.js` and `.extension.js` ### Specifying a test title diff --git a/testing/web-platform/tests/resources/testdriver.js b/testing/web-platform/tests/resources/testdriver.js @@ -2195,6 +2195,43 @@ */ set_global_privacy_control: function(newValue) { return window.test_driver_internal.set_global_privacy_control(newValue); + }, + + /** + * Installs a WebExtension. + * + * Matches the `Install WebExtension + * <https://github.com/w3c/webextensions/blob/main/specification/webdriver-classic.bs>`_ + * WebDriver command. + * + * @param {Object} params - Parameters for loading the extension. + * @param {String} params.type - A type such as "path", "archivePath", or "base64". + * + * @param {String} params.path - The path to the extension's resources if type "path" or "archivePath" is specified. + * + * @param {String} params.value - The base64 encoded value of the extension's resources if type "base64" is specified. + * + * @returns {Promise} Returns the extension identifier as defined in the spec. + * Rejected if the extension fails to load. + */ + install_web_extension: function(params) { + return window.test_driver_internal.install_web_extension(params); + }, + + /** + * Uninstalls a WebExtension. + * + * Matches the `Uninstall WebExtension + * <https://github.com/w3c/webextensions/blob/main/specification/webdriver-classic.bs>`_ + * WebDriver command. + * + * @param {String} extension_id - The extension identifier. + * + * @returns {Promise} Fulfilled after the extension has been removed. + * Rejected in case the WebDriver command errors out. + */ + uninstall_web_extension: function(extension_id) { + return window.test_driver_internal.uninstall_web_extension(extension_id); } }; diff --git a/testing/web-platform/tests/resources/web-extensions-helper.js b/testing/web-platform/tests/resources/web-extensions-helper.js @@ -0,0 +1,40 @@ +// testharness file with WebExtensions utilities + +/** + * Loads the WebExtension at the path specified and runs the tests defined in the extension's resources. + * Listens to messages sent from the user agent and converts the `browser.test` assertions + * into testharness.js assertions. + * + * @param {string} extensionPath - a path to the extension's resources. + */ + +setup({ explicit_done: true }) +globalThis.runTestsWithWebExtension = function(extensionPath) { + test_driver.install_web_extension({ + type: "path", + path: extensionPath + }) + .then((result) => { + let test; + browser.test.onTestStarted.addListener((data) => { + test = async_test(data.testName) + }) + + browser.test.onTestFinished.addListener((data) => { + test.step(() => { + let description = data.message ? `${data.assertionDescription}. ${data.message}` : data.assertionDescription + assert_true(data.result, description) + }) + + test.done() + + if (!data.result) { + test.set_status(test.FAIL) + } + + if (!data.remainingTests) { + test_driver.uninstall_web_extension(result.extension).then(() => { done() }) + } + }) + }) +} diff --git a/testing/web-platform/tests/tools/lint/lint.py b/testing/web-platform/tests/tools/lint/lint.py @@ -600,7 +600,7 @@ def check_parsed(repo_root: Text, path: Text, f: IO[bytes]) -> List[rules.Error] if not is_path_correct("testdriver.js", src): errors.append(rules.TestdriverPath.error(path)) if not is_query_string_correct("testdriver.js", src, - {'feature': ['bidi']}): + {'feature': ['bidi', 'extensions']}): errors.append(rules.TestdriverUnsupportedQueryParameter.error(path)) if (not is_path_correct("testdriver-vendor.js", src) or diff --git a/testing/web-platform/tests/tools/manifest/sourcefile.py b/testing/web-platform/tests/tools/manifest/sourcefile.py @@ -379,6 +379,12 @@ class SourceFile: return "window" in self.meta_flags and self.ext == ".js" @property + def name_is_extension(self) -> bool: + """Check if the file name matches the conditions for the file to + be a extension js test file""" + return "extension" in self.meta_flags and self.ext == ".js" + + @property def name_is_webdriver(self) -> bool: """Check if the file name matches the conditions for the file to be a webdriver spec test file""" @@ -466,7 +472,7 @@ class SourceFile: @cached_property def script_metadata(self) -> Optional[List[Tuple[Text, Text]]]: - if self.name_is_worker or self.name_is_multi_global or self.name_is_window: + if self.name_is_worker or self.name_is_multi_global or self.name_is_window or self.name_is_extension: regexp = js_meta_re elif self.name_is_webdriver: regexp = python_meta_re @@ -911,6 +917,9 @@ class SourceFile: if self.name_is_window: return {TestharnessTest.item_type} + if self.name_is_extension: + return {TestharnessTest.item_type} + if self.markup_type is None: return {SupportFile.item_type} @@ -1075,6 +1084,22 @@ class SourceFile: ] rv = TestharnessTest.item_type, tests + elif self.name_is_extension: + test_url = replace_end(self.rel_url, ".extension.js", ".extension.html") + tests = [ + TestharnessTest( + self.tests_root, + self.rel_path, + self.url_base, + test_url + variant, + timeout=self.timeout, + pac=self.pac, + script_metadata=self.script_metadata + ) + for variant in self.test_variants + ] + rv = TestharnessTest.item_type, tests + elif self.content_is_css_manual and not self.name_is_reference: rv = ManualTest.item_type, [ ManualTest( diff --git a/testing/web-platform/tests/tools/serve/serve.py b/testing/web-platform/tests/tools/serve/serve.py @@ -311,6 +311,21 @@ class WindowHandler(HtmlWrapperHandler): <script src="%(path)s"></script> """ +class ExtensionHandler(HtmlWrapperHandler): + path_replace = [(".extension.html", ".extension.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js?feature=extensions"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/resources/web-extensions-helper.js"></script> +%(script)s +<div id=log></div> +<script src="%(path)s"></script> +""" + class WindowModulesHandler(HtmlWrapperHandler): global_type = "window-module" @@ -772,6 +787,7 @@ class RoutesBuilder: ("GET", "*.worker.html", WorkersHandler), ("GET", "*.worker-module.html", WorkerModulesHandler), ("GET", "*.window.html", WindowHandler), + ("GET", "*.extension.html", ExtensionHandler), ("GET", "*.any.html", AnyHtmlHandler), ("GET", "*.any.sharedworker.html", SharedWorkersHandler), ("GET", "*.any.sharedworker-module.html", SharedWorkerModulesHandler), diff --git a/testing/web-platform/tests/tools/webdriver/webdriver/client.py b/testing/web-platform/tests/tools/webdriver/webdriver/client.py @@ -407,6 +407,7 @@ class Session: self.find = Find(self) self.alert = UserPrompt(self) self.actions = Actions(self) + self.web_extensions = WebExtensions(self) def __repr__(self): return "<%s %s>" % (self.__class__.__name__, self.session_id or "(disconnected)") @@ -864,6 +865,21 @@ class WebElement: return self.send_element_command("GET", "property/%s" % name) +class WebExtensions: + def __init__(self, session): + self.session = session + + def install(self, type, path=None, value=None): + body = {"type": type} + if path is not None: + body["path"] = path + elif value is not None: + body["value"] = value + return self.session.send_session_command("POST", "webextension", body) + + def uninstall(self, extension_id): + return self.session.send_session_command("DELETE", "webextension/%s" % extension_id) + class WebFrame: identifier = "frame-075b-4da1-b6ba-e579c2d3230a" diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome.py @@ -303,7 +303,12 @@ class ChromeBrowser(WebDriverBrowser): def settings(self, test: Test) -> BrowserSettings: """ Required to store `require_webdriver_bidi` in browser settings.""" settings = super().settings(test) - self._require_webdriver_bidi = test.testdriver_features is not None and 'bidi' in test.testdriver_features + self._require_webdriver_bidi = ( + test.testdriver_features is not None and ( + 'bidi' in test.testdriver_features or + 'extensions' in test.testdriver_features + ) + ) return { **settings, diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/actions.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/actions.py @@ -568,6 +568,32 @@ class ClearDisplayFeaturesAction: def __call__(self, payload): return self.protocol.display_features.clear_display_features() +class WebExtensionInstallAction: + name = "install_web_extension" + + def __init__(self, logger, protocol): + self.logger = logger + self.protocol = protocol + + def __call__(self, payload): + self.logger.debug("installing web extension") + type = payload["type"] + path = payload.get("path") + value = payload.get("value") + return self.protocol.web_extensions.install_web_extension(type, path, value) + +class WebExtensionUninstallAction: + name = "uninstall_web_extension" + + def __init__(self, logger, protocol): + self.logger = logger + self.protocol = protocol + + def __call__(self, payload): + self.logger.debug("uninstalling web extension") + extension_id = payload["extension_id"] + return self.protocol.web_extensions.uninstall_web_extension(extension_id) + actions = [ClickAction, DeleteAllCookiesAction, GetAllCookiesAction, @@ -612,4 +638,6 @@ actions = [ClickAction, SetDisplayFeaturesAction, ClearDisplayFeaturesAction, GetGlobalPrivacyControlAction, - SetGlobalPrivacyControlAction] + SetGlobalPrivacyControlAction, + WebExtensionInstallAction, + WebExtensionUninstallAction] diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/asyncactions.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/asyncactions.py @@ -314,4 +314,6 @@ async_actions = [ BidiEmulationSetScreenOrientationOverrideAction, BidiPermissionsSetPermissionAction, BidiSessionSubscribeAction, - BidiSessionUnsubscribeAction] + BidiSessionUnsubscribeAction, + BidiPermissionsSetPermissionAction, + BidiSessionSubscribeAction] diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py @@ -789,12 +789,12 @@ class MarionetteWebExtensionsProtocolPart(WebExtensionsProtocolPart): def setup(self): self.addons = Addons(self.parent.marionette) - def install_web_extension(self, extension): - if extension["type"] == "base64": - extension_id = self.addons.install(data=extension["value"], temp=True) + def install_web_extension(self, type, path, value): + if type == "base64": + extension_id = self.addons.install(data=value, temp=True) else: - path = self.parent.test_dir + extension["path"] - extension_id = self.addons.install(path, temp=True) + extension_path = self.parent.test_dir + path + extension_id = self.addons.install(extension_path, temp=True) return {'extension': extension_id} diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py @@ -49,6 +49,7 @@ from .protocol import (BaseProtocolPart, ProtectedAudienceProtocolPart, DisplayFeaturesProtocolPart, GlobalPrivacyControlProtocolPart, + WebExtensionsProtocolPart, merge_dicts) from typing import Any, List, Dict, Optional @@ -401,6 +402,30 @@ class WebDriverBidiPermissionsProtocolPart(BidiPermissionsProtocolPart): return await self.webdriver.bidi_session.permissions.set_permission( descriptor=descriptor, state=state, origin=origin) +class WebDriverBidiWebExtensionsProtocolPart(WebExtensionsProtocolPart): + def __init__(self, parent): + super().__init__(parent) + self.webdriver = None + + def setup(self): + self.webdriver = self.parent.webdriver + + def install_web_extension(self, type, path, value): + params = {"type": type} + if path is not None: + params["path"] = self._resolve_path(path) + else: + params["value"] = value + + return self.webdriver.loop.run_until_complete(self.webdriver.bidi_session.web_extension.install(params)) + + def uninstall_web_extension(self, extension_id): + return self.webdriver.loop.run_until_complete(self.webdriver.bidi_session.web_extension.uninstall(extension_id)) + + def _resolve_path(self, path): + if self.parent.test_path is not None: + return self.parent.test_path.rsplit("/", 1)[0] + path + return path class WebDriverTestharnessProtocolPart(TestharnessProtocolPart): def setup(self): @@ -949,6 +974,24 @@ class WebDriverGlobalPrivacyControlProtocolPart(GlobalPrivacyControlProtocolPart def get_global_privacy_control(self): return self.webdriver.get_global_privacy_control() +class WebDriverWebExtensionsProtocolPart(WebExtensionsProtocolPart): + def setup(self): + self.webdriver = self.parent.webdriver + + def install_web_extension(self, type, path, value): + if path is not None: + path = self._resolve_path(path) + + return self.webdriver.web_extensions.install(type, path, value) + + def uninstall_web_extension(self, extension_id): + return self.webdriver.web_extensions.uninstall(extension_id) + + def _resolve_path(self, path): + if self.parent.test_path is not None: + return self.parent.test_path.rsplit("/", 1)[0] + path + return path + class WebDriverProtocol(Protocol): enable_bidi = False @@ -976,7 +1019,8 @@ class WebDriverProtocol(Protocol): WebDriverVirtualPressureSourceProtocolPart, WebDriverProtectedAudienceProtocolPart, WebDriverDisplayFeaturesProtocolPart, - WebDriverGlobalPrivacyControlProtocolPart] + WebDriverGlobalPrivacyControlProtocolPart, + WebDriverWebExtensionsProtocolPart] def __init__(self, executor, browser, capabilities, **kwargs): super().__init__(executor, browser) @@ -1053,6 +1097,7 @@ class WebDriverBidiProtocol(WebDriverProtocol): WebDriverBidiEventsProtocolPart, WebDriverBidiPermissionsProtocolPart, WebDriverBidiScriptProtocolPart, + WebDriverBidiWebExtensionsProtocolPart, *(part for part in WebDriverProtocol.implements) ] @@ -1155,6 +1200,7 @@ class WebDriverTestharnessExecutor(TestharnessExecutor): def do_test(self, test): url = self.test_url(test) + self.protocol.test_path = test.path timeout = (test.timeout * self.timeout_multiplier if self.debug_info is None else None) diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py @@ -110,6 +110,7 @@ class ProtocolPart: def __init__(self, parent): self.parent = parent + self.test_path = None @property def logger(self): @@ -342,7 +343,7 @@ class WebExtensionsProtocolPart(ProtocolPart): name = "web_extensions" @abstractmethod - def install_web_extension(self, extension): + def install_web_extension(self, type, path, value): pass @abstractmethod diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js @@ -475,6 +475,14 @@ return create_context_action("get_all_cookies", context, {}); }; + window.test_driver_internal.install_web_extension = function (params, context=null) { + return create_context_action("install_web_extension", context, {...params}); + } + + window.test_driver_internal.uninstall_web_extension = function (extension_id, context=null) { + return create_context_action("uninstall_web_extension", context, {extension_id}); + } + window.test_driver_internal.get_computed_label = function(element) { const selector = get_selector(element); const context = get_context(element); diff --git a/testing/web-platform/tests/web-extensions/META.yml b/testing/web-platform/tests/web-extensions/META.yml @@ -0,0 +1,5 @@ +spec: https://w3c.github.io/webextensions +suggested_reviewers: + - elijahsawyers + - kiaraarose + - xeenon diff --git a/testing/web-platform/tests/web-extensions/browser.runtime.extension.js b/testing/web-platform/tests/web-extensions/browser.runtime.extension.js @@ -0,0 +1 @@ +runTestsWithWebExtension("/resources/runtime/") diff --git a/testing/web-platform/tests/web-extensions/resources/runtime/background.js b/testing/web-platform/tests/web-extensions/resources/runtime/background.js @@ -0,0 +1,30 @@ +browser.test.runTests([ + function browserRuntimeGetURLErrorCases() { + browser.test.assertThrows(() => browser.runtime.getURL()) + browser.test.assertThrows(() => browser.runtime.getURL(null)) + browser.test.assertThrows(() => browser.runtime.getURL(undefined)) + browser.test.assertThrows(() => browser.runtime.getURL(42)) + browser.test.assertThrows(() => browser.runtime.getURL(/test/)) + }, + function browserRuntimeGetURLNormalCases() { + browser.test.assertEq(typeof browser.runtime.getURL(""), "string") + browser.test.assertEq(new URL(browser.runtime.getURL("")).pathname, "/") + browser.test.assertEq(new URL(browser.runtime.getURL("test.js")).pathname, "/test.js") + browser.test.assertEq(new URL(browser.runtime.getURL("/test.js")).pathname, "/test.js") + browser.test.assertEq(new URL(browser.runtime.getURL("../../test.js")).pathname, "/test.js") + browser.test.assertEq(new URL(browser.runtime.getURL("./test.js")).pathname, "/test.js") + browser.test.assertEq(new URL(browser.runtime.getURL("././/example")).pathname, "//example") + browser.test.assertEq(new URL(browser.runtime.getURL("../../example/..//test/")).pathname, "//test/") + browser.test.assertEq(new URL(browser.runtime.getURL(".")).pathname, "/") + browser.test.assertEq(new URL(browser.runtime.getURL("..//../")).pathname, "/") + browser.test.assertEq(new URL(browser.runtime.getURL(".././..")).pathname, "/") + browser.test.assertEq(new URL(browser.runtime.getURL("/.././.")).pathname, "/") + }, + async function browserRuntimeGetPlatformInfo() { + const platformInfo = await browser.runtime.getPlatformInfo() + + browser.test.assertEq(typeof platformInfo, "object") + browser.test.assertEq(typeof platformInfo.os, "string") + browser.test.assertEq(typeof platformInfo.arch, "string") + } +]) diff --git a/testing/web-platform/tests/web-extensions/resources/runtime/manifest.json b/testing/web-platform/tests/web-extensions/resources/runtime/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 3, + "name": "BrowserRuntimeTestExtension", + "description": "browser.runtime test extension", + "version": "1.0", + "background": { + "scripts": [ "background.js" ], + "service_worker": "background.js", + "type": "module" + } +}