From 4c8dc601d25329780b1d00ab11f7a007efcba221 Mon Sep 17 00:00:00 2001 From: Elijah Sawyers Date: Mon, 10 Feb 2025 13:44:51 -0800 Subject: [PATCH] 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. --- docs/writing-tests/testharness.md | 30 ++++++++++- resources/testdriver.js | 37 ++++++++++++++ resources/web-extensions-helper.js | 40 +++++++++++++++ tools/lint/lint.py | 2 +- tools/manifest/sourcefile.py | 27 +++++++++- tools/serve/serve.py | 16 ++++++ tools/webdriver/webdriver/client.py | 16 ++++++ tools/wptrunner/wptrunner/browsers/chrome.py | 7 ++- .../wptrunner/wptrunner/executors/actions.py | 30 ++++++++++- .../wptrunner/executors/asyncactions.py | 4 +- .../wptrunner/executors/executormarionette.py | 10 ++-- .../wptrunner/executors/executorwebdriver.py | 50 ++++++++++++++++++- .../wptrunner/wptrunner/executors/protocol.py | 3 +- tools/wptrunner/wptrunner/testdriver-extra.js | 8 +++ web-extensions/META.yml | 5 ++ web-extensions/browser.runtime.extension.js | 1 + .../resources/runtime/background.js | 30 +++++++++++ .../resources/runtime/manifest.json | 11 ++++ 18 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 resources/web-extensions-helper.js create mode 100644 web-extensions/META.yml create mode 100644 web-extensions/browser.runtime.extension.js create mode 100644 web-extensions/resources/runtime/background.js create mode 100644 web-extensions/resources/runtime/manifest.json diff --git a/docs/writing-tests/testharness.md b/docs/writing-tests/testharness.md index 02864f8853bbb4..6ec83dce7c020b 100644 --- a/docs/writing-tests/testharness.md +++ b/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/resources/testdriver.js b/resources/testdriver.js index 5b390dedeb72bb..f40ba2d3602767 100644 --- a/resources/testdriver.js +++ b/resources/testdriver.js @@ -2169,6 +2169,43 @@ */ clear_display_features: function(context=null) { return window.test_driver_internal.clear_display_features(context); + }, + + /** + * Installs a WebExtension. + * + * Matches the `Install WebExtension + * `_ + * 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 + * `_ + * 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/resources/web-extensions-helper.js b/resources/web-extensions-helper.js new file mode 100644 index 00000000000000..57a40fe84dae0a --- /dev/null +++ b/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/tools/lint/lint.py b/tools/lint/lint.py index f6b0f1adf6f4e3..74f3a04d105c9f 100644 --- a/tools/lint/lint.py +++ b/tools/lint/lint.py @@ -600,7 +600,7 @@ def is_query_string_correct(script: Text, src: Text, 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/tools/manifest/sourcefile.py b/tools/manifest/sourcefile.py index 8682c233e8898a..3628105006c972 100644 --- a/tools/manifest/sourcefile.py +++ b/tools/manifest/sourcefile.py @@ -378,6 +378,12 @@ def name_is_window(self) -> bool: be a window js test file""" 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 @@ -466,7 +472,7 @@ def pac_nodes(self) -> List[ElementTree.Element]: @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 @@ def possible_types(self) -> Set[Text]: 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 @@ def manifest_items(self) -> Tuple[Text, List[ManifestItem]]: ] 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/tools/serve/serve.py b/tools/serve/serve.py index 41469447fdcbc9..b94527de50beee 100644 --- a/tools/serve/serve.py +++ b/tools/serve/serve.py @@ -311,6 +311,21 @@ class WindowHandler(HtmlWrapperHandler): """ +class ExtensionHandler(HtmlWrapperHandler): + path_replace = [(".extension.html", ".extension.js")] + wrapper = """ + +%(meta)s + + + + + +%(script)s +
+ +""" + class WindowModulesHandler(HtmlWrapperHandler): global_type = "window-module" @@ -772,6 +787,7 @@ def add_mount_point(self, url_base, path): ("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/tools/webdriver/webdriver/client.py b/tools/webdriver/webdriver/client.py index f4e6259d547a66..c8ceb27ffc16fd 100644 --- a/tools/webdriver/webdriver/client.py +++ b/tools/webdriver/webdriver/client.py @@ -407,6 +407,7 @@ def __init__(self, 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)") @@ -850,6 +851,21 @@ def property(self, name): 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/tools/wptrunner/wptrunner/browsers/chrome.py b/tools/wptrunner/wptrunner/browsers/chrome.py index 7d702878a7e478..c264a1ab2c563d 100644 --- a/tools/wptrunner/wptrunner/browsers/chrome.py +++ b/tools/wptrunner/wptrunner/browsers/chrome.py @@ -294,7 +294,12 @@ def require_webdriver_bidi(self) -> Optional[bool]: 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/tools/wptrunner/wptrunner/executors/actions.py b/tools/wptrunner/wptrunner/executors/actions.py index b093b0355d357f..53f5a8aafe3b7e 100644 --- a/tools/wptrunner/wptrunner/executors/actions.py +++ b/tools/wptrunner/wptrunner/executors/actions.py @@ -544,6 +544,32 @@ def __init__(self, logger, protocol): 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, @@ -586,4 +612,6 @@ def __call__(self, payload): RemoveVirtualPressureSourceAction, SetProtectedAudienceKAnonymityAction, SetDisplayFeaturesAction, - ClearDisplayFeaturesAction] + ClearDisplayFeaturesAction, + WebExtensionInstallAction, + WebExtensionUninstallAction] diff --git a/tools/wptrunner/wptrunner/executors/asyncactions.py b/tools/wptrunner/wptrunner/executors/asyncactions.py index b5192dcbda7eb7..88e877b581aefa 100644 --- a/tools/wptrunner/wptrunner/executors/asyncactions.py +++ b/tools/wptrunner/wptrunner/executors/asyncactions.py @@ -314,4 +314,6 @@ async def __call__(self, payload): BidiEmulationSetScreenOrientationOverrideAction, BidiPermissionsSetPermissionAction, BidiSessionSubscribeAction, - BidiSessionUnsubscribeAction] + BidiSessionUnsubscribeAction, + BidiPermissionsSetPermissionAction, + BidiSessionSubscribeAction] diff --git a/tools/wptrunner/wptrunner/executors/executormarionette.py b/tools/wptrunner/wptrunner/executors/executormarionette.py index 3607960b41e513..c486138c9ccffd 100644 --- a/tools/wptrunner/wptrunner/executors/executormarionette.py +++ b/tools/wptrunner/wptrunner/executors/executormarionette.py @@ -765,12 +765,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/tools/wptrunner/wptrunner/executors/executorwebdriver.py b/tools/wptrunner/wptrunner/executors/executorwebdriver.py index e4127f130ba32d..e4afd36b381546 100644 --- a/tools/wptrunner/wptrunner/executors/executorwebdriver.py +++ b/tools/wptrunner/wptrunner/executors/executorwebdriver.py @@ -48,6 +48,7 @@ VirtualPressureSourceProtocolPart, ProtectedAudienceProtocolPart, DisplayFeaturesProtocolPart, + WebExtensionsProtocolPart, merge_dicts) from typing import Any, List, Dict, Optional, Tuple @@ -405,6 +406,31 @@ async def set_permission(self, descriptor, state, origin): 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): @@ -943,6 +969,25 @@ def set_display_features(self, features): def clear_display_features(self): return self.webdriver.send_session_command("DELETE", "displayfeatures") +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 implements = [WebDriverBaseProtocolPart, @@ -968,7 +1013,8 @@ class WebDriverProtocol(Protocol): WebDriverStorageProtocolPart, WebDriverVirtualPressureSourceProtocolPart, WebDriverProtectedAudienceProtocolPart, - WebDriverDisplayFeaturesProtocolPart] + WebDriverDisplayFeaturesProtocolPart, + WebDriverWebExtensionsProtocolPart] def __init__(self, executor, browser, capabilities, **kwargs): super().__init__(executor, browser) @@ -1045,6 +1091,7 @@ class WebDriverBidiProtocol(WebDriverProtocol): WebDriverBidiEventsProtocolPart, WebDriverBidiPermissionsProtocolPart, WebDriverBidiScriptProtocolPart, + WebDriverBidiWebExtensionsProtocolPart, *(part for part in WebDriverProtocol.implements) ] @@ -1147,6 +1194,7 @@ def on_environment_change(self, new_environment): 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/tools/wptrunner/wptrunner/executors/protocol.py b/tools/wptrunner/wptrunner/executors/protocol.py index 8af313d27d6d21..c4372d3bcd871e 100644 --- a/tools/wptrunner/wptrunner/executors/protocol.py +++ b/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/tools/wptrunner/wptrunner/testdriver-extra.js b/tools/wptrunner/wptrunner/testdriver-extra.js index 4d26a14097f530..a239762f423f18 100644 --- a/tools/wptrunner/wptrunner/testdriver-extra.js +++ b/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/web-extensions/META.yml b/web-extensions/META.yml new file mode 100644 index 00000000000000..0656dff2962a3c --- /dev/null +++ b/web-extensions/META.yml @@ -0,0 +1,5 @@ +spec: https://w3c.github.io/webextensions +suggested_reviewers: + - elijahsawyers + - kiaraarose + - xeenon diff --git a/web-extensions/browser.runtime.extension.js b/web-extensions/browser.runtime.extension.js new file mode 100644 index 00000000000000..c41a1b795ea829 --- /dev/null +++ b/web-extensions/browser.runtime.extension.js @@ -0,0 +1 @@ +runTestsWithWebExtension("/resources/runtime/") diff --git a/web-extensions/resources/runtime/background.js b/web-extensions/resources/runtime/background.js new file mode 100644 index 00000000000000..0426ed8f3de27d --- /dev/null +++ b/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/web-extensions/resources/runtime/manifest.json b/web-extensions/resources/runtime/manifest.json new file mode 100644 index 00000000000000..1bd0f0fcbb3d01 --- /dev/null +++ b/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" + } +}