summaryrefslogtreecommitdiffstats
path: root/remote/shared
diff options
context:
space:
mode:
Diffstat (limited to 'remote/shared')
-rw-r--r--remote/shared/Capture.sys.mjs2
-rw-r--r--remote/shared/DOM.sys.mjs34
-rw-r--r--remote/shared/Navigate.sys.mjs7
-rw-r--r--remote/shared/NetworkRequest.sys.mjs254
-rw-r--r--remote/shared/NetworkResponse.sys.mjs131
-rw-r--r--remote/shared/Permissions.sys.mjs90
-rw-r--r--remote/shared/listeners/NetworkEventRecord.sys.mjs290
-rw-r--r--remote/shared/listeners/NetworkListener.sys.mjs11
-rw-r--r--remote/shared/listeners/test/browser/browser_NetworkListener.js31
-rw-r--r--remote/shared/test/xpcshell/test_DOM.js114
-rw-r--r--remote/shared/webdriver/Accessibility.sys.mjs519
-rw-r--r--remote/shared/webdriver/Actions.sys.mjs27
-rw-r--r--remote/shared/webdriver/Assert.sys.mjs30
-rw-r--r--remote/shared/webdriver/Session.sys.mjs3
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Assert.js14
15 files changed, 1240 insertions, 317 deletions
diff --git a/remote/shared/Capture.sys.mjs b/remote/shared/Capture.sys.mjs
index ec34d09aba..a9a20c0b81 100644
--- a/remote/shared/Capture.sys.mjs
+++ b/remote/shared/Capture.sys.mjs
@@ -74,7 +74,7 @@ capture.canvas = async function (
) {
// FIXME(bug 1761032): This looks a bit sketchy, overrideDPPX doesn't
// influence rendering...
- const scale = win.browsingContext.overrideDPPX || win.devicePixelRatio;
+ const scale = browsingContext.overrideDPPX || win.devicePixelRatio;
let canvasHeight = height * scale;
let canvasWidth = width * scale;
diff --git a/remote/shared/DOM.sys.mjs b/remote/shared/DOM.sys.mjs
index 664f02328c..51c9298183 100644
--- a/remote/shared/DOM.sys.mjs
+++ b/remote/shared/DOM.sys.mjs
@@ -622,24 +622,14 @@ dom.isDisabled = function (el) {
return false;
}
- switch (el.localName) {
- case "option":
- case "optgroup":
- if (el.disabled) {
- return true;
- }
- let parent = dom.findClosest(el, "optgroup,select");
- return dom.isDisabled(parent);
-
- case "button":
- case "input":
- case "select":
- case "textarea":
- return el.disabled;
-
- default:
- return false;
+ // Selenium expects that even an enabled "option" element that is a child
+ // of a disabled "optgroup" or "select" element to be disabled.
+ if (["optgroup", "option"].includes(el.localName) && !el.disabled) {
+ const parent = dom.findClosest(el, "optgroup,select");
+ return dom.isDisabled(parent);
}
+
+ return el.matches(":disabled");
};
/**
@@ -1064,6 +1054,16 @@ dom.isElement = function (obj) {
return dom.isDOMElement(obj) || dom.isXULElement(obj);
};
+dom.isEnabled = function (el) {
+ let enabled = false;
+
+ if (el.ownerDocument.contentType !== "text/xml") {
+ enabled = !dom.isDisabled(el);
+ }
+
+ return enabled;
+};
+
/**
* Returns the shadow root of an element.
*
diff --git a/remote/shared/Navigate.sys.mjs b/remote/shared/Navigate.sys.mjs
index 9b72c0dfbf..cdb23b54c7 100644
--- a/remote/shared/Navigate.sys.mjs
+++ b/remote/shared/Navigate.sys.mjs
@@ -91,9 +91,14 @@ export async function waitForInitialNavigationCompleted(
isInitial = browsingContext.currentWindowGlobal.isInitialDocument;
}
+ const isLoadingDocument = listener.isLoadingDocument;
+ lazy.logger.trace(
+ lazy.truncate`[${browsingContext.id}] Wait for initial navigation: isInitial=${isInitial}, isLoadingDocument=${isLoadingDocument}`
+ );
+
// If the current document is not the initial "about:blank" and is also
// no longer loading, assume the navigation is done and return.
- if (!isInitial && !listener.isLoadingDocument) {
+ if (!isInitial && !isLoadingDocument) {
lazy.logger.trace(
lazy.truncate`[${browsingContext.id}] Document already finished loading: ${browsingContext.currentURI?.spec}`
);
diff --git a/remote/shared/NetworkRequest.sys.mjs b/remote/shared/NetworkRequest.sys.mjs
new file mode 100644
index 0000000000..6524132752
--- /dev/null
+++ b/remote/shared/NetworkRequest.sys.mjs
@@ -0,0 +1,254 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+
+ notifyNavigationStarted:
+ "chrome://remote/content/shared/NavigationManager.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+/**
+ * The NetworkRequest class is a wrapper around the internal channel which
+ * provides getters and methods closer to fetch's response concept
+ * (https://fetch.spec.whatwg.org/#concept-response).
+ */
+export class NetworkRequest {
+ #channel;
+ #contextId;
+ #navigationId;
+ #navigationManager;
+ #postData;
+ #rawHeaders;
+ #redirectCount;
+ #requestId;
+ #timedChannel;
+ #wrappedChannel;
+
+ /**
+ *
+ * @param {nsIChannel} channel
+ * The channel for the request.
+ * @param {object} params
+ * @param {NavigationManager} params.navigationManager
+ * The NavigationManager where navigations for the current session are
+ * monitored.
+ * @param {string=} params.rawHeaders
+ * The request's raw (ie potentially compressed) headers
+ */
+ constructor(channel, params) {
+ const { navigationManager, rawHeaders = "" } = params;
+
+ this.#channel = channel;
+ this.#navigationManager = navigationManager;
+ this.#rawHeaders = rawHeaders;
+
+ this.#timedChannel = this.#channel.QueryInterface(Ci.nsITimedChannel);
+ this.#wrappedChannel = ChannelWrapper.get(channel);
+
+ this.#redirectCount = this.#timedChannel.redirectCount;
+ // The wrappedChannel id remains identical across redirects, whereas
+ // nsIChannel.channelId is different for each and every request.
+ this.#requestId = this.#wrappedChannel.id.toString();
+
+ this.#contextId = this.#getContextId();
+ this.#navigationId = this.#getNavigationId();
+ }
+
+ get contextId() {
+ return this.#contextId;
+ }
+
+ get errorText() {
+ // TODO: Update with a proper error text. Bug 1873037.
+ return ChromeUtils.getXPCOMErrorName(this.#channel.status);
+ }
+
+ get headersSize() {
+ // TODO: rawHeaders will not be updated after modifying the headers via
+ // request interception. Need to find another way to retrieve the
+ // information dynamically.
+ return this.#rawHeaders.length;
+ }
+
+ get method() {
+ return this.#channel.requestMethod;
+ }
+
+ get navigationId() {
+ return this.#navigationId;
+ }
+
+ get postDataSize() {
+ return this.#postData ? this.#postData.size : 0;
+ }
+
+ get redirectCount() {
+ return this.#redirectCount;
+ }
+
+ get requestId() {
+ return this.#requestId;
+ }
+
+ get serializedURL() {
+ return this.#channel.URI.spec;
+ }
+
+ get wrappedChannel() {
+ return this.#wrappedChannel;
+ }
+
+ /**
+ * Retrieve the Fetch timings for the NetworkRequest.
+ *
+ * @returns {object}
+ * Object with keys corresponding to fetch timing names, and their
+ * corresponding values.
+ */
+ getFetchTimings() {
+ const {
+ channelCreationTime,
+ redirectStartTime,
+ redirectEndTime,
+ dispatchFetchEventStartTime,
+ cacheReadStartTime,
+ domainLookupStartTime,
+ domainLookupEndTime,
+ connectStartTime,
+ connectEndTime,
+ secureConnectionStartTime,
+ requestStartTime,
+ responseStartTime,
+ responseEndTime,
+ } = this.#timedChannel;
+
+ // fetchStart should be the post-redirect start time, which should be the
+ // first non-zero timing from: dispatchFetchEventStart, cacheReadStart and
+ // domainLookupStart. See https://www.w3.org/TR/navigation-timing-2/#processing-model
+ const fetchStartTime =
+ dispatchFetchEventStartTime ||
+ cacheReadStartTime ||
+ domainLookupStartTime;
+
+ // Bug 1805478: Per spec, the origin time should match Performance API's
+ // timeOrigin for the global which initiated the request. This is not
+ // available in the parent process, so for now we will use 0.
+ const timeOrigin = 0;
+
+ return {
+ timeOrigin,
+ requestTime: this.#convertTimestamp(channelCreationTime, timeOrigin),
+ redirectStart: this.#convertTimestamp(redirectStartTime, timeOrigin),
+ redirectEnd: this.#convertTimestamp(redirectEndTime, timeOrigin),
+ fetchStart: this.#convertTimestamp(fetchStartTime, timeOrigin),
+ dnsStart: this.#convertTimestamp(domainLookupStartTime, timeOrigin),
+ dnsEnd: this.#convertTimestamp(domainLookupEndTime, timeOrigin),
+ connectStart: this.#convertTimestamp(connectStartTime, timeOrigin),
+ connectEnd: this.#convertTimestamp(connectEndTime, timeOrigin),
+ tlsStart: this.#convertTimestamp(secureConnectionStartTime, timeOrigin),
+ tlsEnd: this.#convertTimestamp(connectEndTime, timeOrigin),
+ requestStart: this.#convertTimestamp(requestStartTime, timeOrigin),
+ responseStart: this.#convertTimestamp(responseStartTime, timeOrigin),
+ responseEnd: this.#convertTimestamp(responseEndTime, timeOrigin),
+ };
+ }
+
+ /**
+ * Retrieve the list of headers for the NetworkRequest.
+ *
+ * @returns {Array.Array}
+ * Array of (name, value) tuples.
+ */
+ getHeadersList() {
+ const headers = [];
+
+ this.#channel.visitRequestHeaders({
+ visitHeader(name, value) {
+ // The `Proxy-Authorization` header even though it appears on the channel is not
+ // actually sent to the server for non CONNECT requests after the HTTP/HTTPS tunnel
+ // is setup by the proxy.
+ if (name == "Proxy-Authorization") {
+ return;
+ }
+ headers.push([name, value]);
+ },
+ });
+
+ return headers;
+ }
+
+ /**
+ * Update the postData for this NetworkRequest. This is currently forwarded
+ * by the DevTools' NetworkObserver.
+ *
+ * TODO: We should read this information dynamically from the channel so that
+ * we can get updated information in case it was modified via network
+ * interception.
+ *
+ * @param {object} postData
+ * The request POST data.
+ */
+ setPostData(postData) {
+ this.#postData = postData;
+ }
+
+ /**
+ * Convert the provided request timing to a timing relative to the beginning
+ * of the request. All timings are numbers representing high definition
+ * timestamps.
+ *
+ * @param {number} timing
+ * High definition timestamp for a request timing relative from the time
+ * origin.
+ * @param {number} requestTime
+ * High definition timestamp for the request start time relative from the
+ * time origin.
+ *
+ * @returns {number}
+ * High definition timestamp for the request timing relative to the start
+ * time of the request, or 0 if the provided timing was 0.
+ */
+ #convertTimestamp(timing, requestTime) {
+ if (timing == 0) {
+ return 0;
+ }
+
+ return timing - requestTime;
+ }
+
+ #getContextId() {
+ const id = lazy.NetworkUtils.getChannelBrowsingContextID(this.#channel);
+ const browsingContext = BrowsingContext.get(id);
+ return lazy.TabManager.getIdForBrowsingContext(browsingContext);
+ }
+
+ #getNavigationId() {
+ if (!this.#channel.isMainDocumentChannel) {
+ return null;
+ }
+
+ const browsingContext = lazy.TabManager.getBrowsingContextById(
+ this.#contextId
+ );
+
+ let navigation =
+ this.#navigationManager.getNavigationForBrowsingContext(browsingContext);
+
+ // `onBeforeRequestSent` might be too early for the NavigationManager.
+ // If there is no ongoing navigation, create one ourselves.
+ // TODO: Bug 1835704 to detect navigations earlier and avoid this.
+ if (!navigation || navigation.finished) {
+ navigation = lazy.notifyNavigationStarted({
+ contextDetails: { context: browsingContext },
+ url: this.serializedURL,
+ });
+ }
+
+ return navigation ? navigation.navigationId : null;
+ }
+}
diff --git a/remote/shared/NetworkResponse.sys.mjs b/remote/shared/NetworkResponse.sys.mjs
new file mode 100644
index 0000000000..45a03fb445
--- /dev/null
+++ b/remote/shared/NetworkResponse.sys.mjs
@@ -0,0 +1,131 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+/**
+ * The NetworkResponse class is a wrapper around the internal channel which
+ * provides getters and methods closer to fetch's response concept
+ * (https://fetch.spec.whatwg.org/#concept-response).
+ */
+export class NetworkResponse {
+ #channel;
+ #decodedBodySize;
+ #encodedBodySize;
+ #fromCache;
+ #headersTransmittedSize;
+ #status;
+ #statusMessage;
+ #totalTransmittedSize;
+ #wrappedChannel;
+
+ /**
+ *
+ * @param {nsIChannel} channel
+ * The channel for the response.
+ * @param {object} params
+ * @param {boolean} params.fromCache
+ * Whether the response was read from the cache or not.
+ * @param {string=} params.rawHeaders
+ * The response's raw (ie potentially compressed) headers
+ */
+ constructor(channel, params) {
+ this.#channel = channel;
+ const { fromCache, rawHeaders = "" } = params;
+ this.#fromCache = fromCache;
+ this.#wrappedChannel = ChannelWrapper.get(channel);
+
+ this.#decodedBodySize = 0;
+ this.#encodedBodySize = 0;
+ this.#headersTransmittedSize = rawHeaders.length;
+ this.#totalTransmittedSize = rawHeaders.length;
+
+ // TODO: responseStatus and responseStatusText are sometimes inconsistent.
+ // For instance, they might be (304, Not Modified) when retrieved during the
+ // responseStarted event, and then (200, OK) during the responseCompleted
+ // event.
+ // For now consider them as immutable and store them on startup.
+ this.#status = this.#channel.responseStatus;
+ this.#statusMessage = this.#channel.responseStatusText;
+ }
+
+ get decodedBodySize() {
+ return this.#decodedBodySize;
+ }
+
+ get encodedBodySize() {
+ return this.#encodedBodySize;
+ }
+
+ get headersTransmittedSize() {
+ return this.#headersTransmittedSize;
+ }
+
+ get fromCache() {
+ return this.#fromCache;
+ }
+
+ get protocol() {
+ return lazy.NetworkUtils.getProtocol(this.#channel);
+ }
+
+ get serializedURL() {
+ return this.#channel.URI.spec;
+ }
+
+ get status() {
+ return this.#status;
+ }
+
+ get statusMessage() {
+ return this.#statusMessage;
+ }
+
+ get totalTransmittedSize() {
+ return this.#totalTransmittedSize;
+ }
+
+ addResponseContent(responseContent) {
+ this.#decodedBodySize = responseContent.decodedBodySize;
+ this.#encodedBodySize = responseContent.bodySize;
+ this.#totalTransmittedSize = responseContent.transferredSize;
+ }
+
+ getComputedMimeType() {
+ // TODO: DevTools NetworkObserver is computing a similar value in
+ // addResponseContent, but uses an inconsistent implementation in
+ // addResponseStart. This approach can only be used as early as in
+ // addResponseHeaders. We should move this logic to the NetworkObserver and
+ // expose mimeType in addResponseStart. Bug 1809670.
+ let mimeType = "";
+
+ try {
+ mimeType = this.#wrappedChannel.contentType;
+ const contentCharset = this.#channel.contentCharset;
+ if (contentCharset) {
+ mimeType += `;charset=${contentCharset}`;
+ }
+ } catch (e) {
+ // Ignore exceptions when reading contentType/contentCharset
+ }
+
+ return mimeType;
+ }
+
+ getHeadersList() {
+ const headers = [];
+
+ this.#channel.visitOriginalResponseHeaders({
+ visitHeader(name, value) {
+ headers.push([name, value]);
+ },
+ });
+
+ return headers;
+ }
+}
diff --git a/remote/shared/Permissions.sys.mjs b/remote/shared/Permissions.sys.mjs
new file mode 100644
index 0000000000..50996bf701
--- /dev/null
+++ b/remote/shared/Permissions.sys.mjs
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+});
+
+/** @namespace */
+export const permissions = {};
+
+/**
+ * Get a permission type for the "storage-access" permission.
+ *
+ * @param {nsIURI} uri
+ * The URI to use for building the permission type.
+ *
+ * @returns {string} permissionType
+ * The permission type for the "storage-access" permission.
+ */
+permissions.getStorageAccessPermissionsType = function (uri) {
+ const thirdPartyPrincipalSite = Services.eTLD.getSite(uri);
+ return "3rdPartyFrameStorage^" + thirdPartyPrincipalSite;
+};
+
+/**
+ * Set a permission given a permission descriptor, a permission state,
+ * an origin.
+ *
+ * @param {PermissionDescriptor} descriptor
+ * The descriptor of the permission which will be updated.
+ * @param {string} state
+ * State of the permission. It can be `granted`, `denied` or `prompt`.
+ * @param {string} origin
+ * The origin which is used as a target for permission update.
+ *
+ * @throws {UnsupportedOperationError}
+ * If <var>state</var> has unsupported value.
+ */
+permissions.set = function (descriptor, state, origin) {
+ const principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+
+ switch (state) {
+ case "granted": {
+ Services.perms.addFromPrincipal(
+ principal,
+ descriptor.type,
+ Services.perms.ALLOW_ACTION
+ );
+ return;
+ }
+ case "denied": {
+ Services.perms.addFromPrincipal(
+ principal,
+ descriptor.type,
+ Services.perms.DENY_ACTION
+ );
+ return;
+ }
+ case "prompt": {
+ Services.perms.removeFromPrincipal(principal, descriptor.type);
+ return;
+ }
+ default:
+ throw new lazy.error.UnsupportedOperationError(
+ "Unrecognized permission keyword for 'Set Permission' operation"
+ );
+ }
+};
+
+/**
+ * Validate the permission.
+ *
+ * @param {string} permissionName
+ * The name of the permission which will be validated.
+ *
+ * @throws {UnsupportedOperationError}
+ * If <var>permissionName</var> is not supported.
+ */
+permissions.validatePermission = function (permissionName) {
+ // Bug 1609427: PermissionDescriptor for "camera" and "microphone" are not yet implemented.
+ if (["camera", "microphone"].includes(permissionName)) {
+ throw new lazy.error.UnsupportedOperationError(
+ `"descriptor.name" "${permissionName}" is currently unsupported`
+ );
+ }
+};
diff --git a/remote/shared/listeners/NetworkEventRecord.sys.mjs b/remote/shared/listeners/NetworkEventRecord.sys.mjs
index 72b43e3de1..0f592d62b0 100644
--- a/remote/shared/listeners/NetworkEventRecord.sys.mjs
+++ b/remote/shared/listeners/NetworkEventRecord.sys.mjs
@@ -4,10 +4,8 @@
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
- NetworkUtils:
- "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
-
- TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ NetworkRequest: "chrome://remote/content/shared/NetworkRequest.sys.mjs",
+ NetworkResponse: "chrome://remote/content/shared/NetworkResponse.sys.mjs",
});
/**
@@ -18,17 +16,10 @@ ChromeUtils.defineESModuleGetters(lazy, {
* NetworkListener instance which created it.
*/
export class NetworkEventRecord {
- #contextId;
#fromCache;
- #isMainDocumentChannel;
#networkListener;
- #redirectCount;
- #requestChannel;
- #requestData;
- #requestId;
- #responseChannel;
- #responseData;
- #wrappedChannel;
+ #request;
+ #response;
/**
*
@@ -39,56 +30,21 @@ export class NetworkEventRecord {
* The nsIChannel behind this network event.
* @param {NetworkListener} networkListener
* The NetworkListener which created this NetworkEventRecord.
+ * @param {NavigationManager} navigationManager
+ * The NavigationManager which belongs to the same session as this
+ * NetworkEventRecord.
*/
- constructor(networkEvent, channel, networkListener) {
- this.#requestChannel = channel;
- this.#responseChannel = null;
+ constructor(networkEvent, channel, networkListener, navigationManager) {
+ this.#request = new lazy.NetworkRequest(channel, {
+ navigationManager,
+ rawHeaders: networkEvent.rawHeaders,
+ });
+ this.#response = null;
this.#fromCache = networkEvent.fromCache;
- this.#isMainDocumentChannel = channel.isMainDocumentChannel;
-
- this.#wrappedChannel = ChannelWrapper.get(channel);
this.#networkListener = networkListener;
- // The context ids computed by TabManager have the lifecycle of a navigable
- // and can be reused for all the events emitted from this record.
- this.#contextId = this.#getContextId();
-
- // The wrappedChannel id remains identical across redirects, whereas
- // nsIChannel.channelId is different for each and every request.
- this.#requestId = this.#wrappedChannel.id.toString();
-
- const { cookies, headers } =
- lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel);
-
- // See the RequestData type definition for the full list of properties that
- // should be set on this object.
- this.#requestData = {
- bodySize: null,
- cookies,
- headers,
- headersSize: networkEvent.rawHeaders ? networkEvent.rawHeaders.length : 0,
- method: channel.requestMethod,
- request: this.#requestId,
- timings: {},
- url: channel.URI.spec,
- };
-
- // See the ResponseData type definition for the full list of properties that
- // should be set on this object.
- this.#responseData = {
- // encoded size (body)
- bodySize: null,
- content: {
- // decoded size
- size: null,
- },
- // encoded size (headers)
- headersSize: null,
- url: channel.URI.spec,
- };
-
// NetworkObserver creates a network event when request headers have been
// parsed.
// According to the BiDi spec, we should emit beforeRequestSent when adding
@@ -113,8 +69,7 @@ export class NetworkEventRecord {
* The request POST data.
*/
addRequestPostData(postData) {
- // Only the postData size is needed for RemoteAgent consumers.
- this.#requestData.bodySize = postData.size;
+ this.#request.setPostData(postData);
}
/**
@@ -130,25 +85,11 @@ export class NetworkEventRecord {
* @param {string} options.rawHeaders
*/
addResponseStart(options) {
- const { channel, fromCache, rawHeaders = "" } = options;
- this.#responseChannel = channel;
-
- const { headers } =
- lazy.NetworkUtils.fetchResponseHeadersAndCookies(channel);
-
- const headersSize = rawHeaders.length;
- this.#responseData = {
- ...this.#responseData,
- bodySize: 0,
- bytesReceived: headersSize,
+ const { channel, fromCache, rawHeaders } = options;
+ this.#response = new lazy.NetworkResponse(channel, {
+ rawHeaders,
fromCache: this.#fromCache || !!fromCache,
- headers,
- headersSize,
- mimeType: this.#getMimeType(),
- protocol: lazy.NetworkUtils.getProtocol(channel),
- status: channel.responseStatus,
- statusText: channel.responseStatusText,
- };
+ });
// This should be triggered when all headers have been received, matching
// the WebDriverBiDi response started trigger in `4.6. HTTP-network fetch`
@@ -189,25 +130,16 @@ export class NetworkEventRecord {
*
* Required API for a NetworkObserver event owner.
*
- * @param {object} response
+ * @param {object} responseContent
* An object which represents the response content.
* @param {object} responseInfo
* Additional meta data about the response.
*/
- addResponseContent(response, responseInfo) {
- // Update content-related sizes with the latest data from addResponseContent.
- this.#responseData = {
- ...this.#responseData,
- bodySize: response.bodySize,
- bytesReceived: response.transferredSize,
- content: {
- size: response.decodedBodySize,
- },
- };
-
+ addResponseContent(responseContent, responseInfo) {
if (responseInfo.blockedReason) {
this.#emitFetchError();
} else {
+ this.#response.addResponseContent(responseContent);
this.#emitResponseCompleted();
}
}
@@ -234,201 +166,37 @@ export class NetworkEventRecord {
this.#emitAuthRequired(authCallbacks);
}
- /**
- * Convert the provided request timing to a timing relative to the beginning
- * of the request. All timings are numbers representing high definition
- * timestamps.
- *
- * @param {number} timing
- * High definition timestamp for a request timing relative from the time
- * origin.
- * @param {number} requestTime
- * High definition timestamp for the request start time relative from the
- * time origin.
- * @returns {number}
- * High definition timestamp for the request timing relative to the start
- * time of the request, or 0 if the provided timing was 0.
- */
- #convertTimestamp(timing, requestTime) {
- if (timing == 0) {
- return 0;
- }
-
- return timing - requestTime;
- }
-
#emitAuthRequired(authCallbacks) {
- this.#updateDataFromTimedChannel();
-
this.#networkListener.emit("auth-required", {
authCallbacks,
- contextId: this.#contextId,
- isNavigationRequest: this.#isMainDocumentChannel,
- redirectCount: this.#redirectCount,
- requestChannel: this.#requestChannel,
- requestData: this.#requestData,
- responseChannel: this.#responseChannel,
- responseData: this.#responseData,
- timestamp: Date.now(),
+ request: this.#request,
+ response: this.#response,
});
}
#emitBeforeRequestSent() {
- this.#updateDataFromTimedChannel();
-
this.#networkListener.emit("before-request-sent", {
- contextId: this.#contextId,
- isNavigationRequest: this.#isMainDocumentChannel,
- redirectCount: this.#redirectCount,
- requestChannel: this.#requestChannel,
- requestData: this.#requestData,
- timestamp: Date.now(),
+ request: this.#request,
});
}
#emitFetchError() {
- this.#updateDataFromTimedChannel();
-
this.#networkListener.emit("fetch-error", {
- contextId: this.#contextId,
- // TODO: Update with a proper error text. Bug 1873037.
- errorText: ChromeUtils.getXPCOMErrorName(this.#requestChannel.status),
- isNavigationRequest: this.#isMainDocumentChannel,
- redirectCount: this.#redirectCount,
- requestChannel: this.#requestChannel,
- requestData: this.#requestData,
- timestamp: Date.now(),
+ request: this.#request,
});
}
#emitResponseCompleted() {
- this.#updateDataFromTimedChannel();
-
this.#networkListener.emit("response-completed", {
- contextId: this.#contextId,
- isNavigationRequest: this.#isMainDocumentChannel,
- redirectCount: this.#redirectCount,
- requestChannel: this.#requestChannel,
- requestData: this.#requestData,
- responseChannel: this.#responseChannel,
- responseData: this.#responseData,
- timestamp: Date.now(),
+ request: this.#request,
+ response: this.#response,
});
}
#emitResponseStarted() {
- this.#updateDataFromTimedChannel();
-
this.#networkListener.emit("response-started", {
- contextId: this.#contextId,
- isNavigationRequest: this.#isMainDocumentChannel,
- redirectCount: this.#redirectCount,
- requestChannel: this.#requestChannel,
- requestData: this.#requestData,
- responseChannel: this.#responseChannel,
- responseData: this.#responseData,
- timestamp: Date.now(),
+ request: this.#request,
+ response: this.#response,
});
}
-
- #getBrowsingContext() {
- const id = lazy.NetworkUtils.getChannelBrowsingContextID(
- this.#requestChannel
- );
- return BrowsingContext.get(id);
- }
-
- /**
- * Retrieve the navigable id for the current browsing context associated to
- * the requests' channel. Network events are recorded in the parent process
- * so we always expect to be able to use TabManager.getIdForBrowsingContext.
- *
- * @returns {string}
- * The navigable id corresponding to the given browsing context.
- */
- #getContextId() {
- return lazy.TabManager.getIdForBrowsingContext(this.#getBrowsingContext());
- }
-
- #getMimeType() {
- // TODO: DevTools NetworkObserver is computing a similar value in
- // addResponseContent, but uses an inconsistent implementation in
- // addResponseStart. This approach can only be used as early as in
- // addResponseHeaders. We should move this logic to the NetworkObserver and
- // expose mimeType in addResponseStart. Bug 1809670.
- let mimeType = "";
-
- try {
- mimeType = this.#wrappedChannel.contentType;
- const contentCharset = this.#requestChannel.contentCharset;
- if (contentCharset) {
- mimeType += `;charset=${contentCharset}`;
- }
- } catch (e) {
- // Ignore exceptions when reading contentType/contentCharset
- }
-
- return mimeType;
- }
-
- #getTimingsFromTimedChannel(timedChannel) {
- const {
- channelCreationTime,
- redirectStartTime,
- redirectEndTime,
- dispatchFetchEventStartTime,
- cacheReadStartTime,
- domainLookupStartTime,
- domainLookupEndTime,
- connectStartTime,
- connectEndTime,
- secureConnectionStartTime,
- requestStartTime,
- responseStartTime,
- responseEndTime,
- } = timedChannel;
-
- // fetchStart should be the post-redirect start time, which should be the
- // first non-zero timing from: dispatchFetchEventStart, cacheReadStart and
- // domainLookupStart. See https://www.w3.org/TR/navigation-timing-2/#processing-model
- const fetchStartTime =
- dispatchFetchEventStartTime ||
- cacheReadStartTime ||
- domainLookupStartTime;
-
- // Bug 1805478: Per spec, the origin time should match Performance API's
- // timeOrigin for the global which initiated the request. This is not
- // available in the parent process, so for now we will use 0.
- const timeOrigin = 0;
-
- return {
- timeOrigin,
- requestTime: this.#convertTimestamp(channelCreationTime, timeOrigin),
- redirectStart: this.#convertTimestamp(redirectStartTime, timeOrigin),
- redirectEnd: this.#convertTimestamp(redirectEndTime, timeOrigin),
- fetchStart: this.#convertTimestamp(fetchStartTime, timeOrigin),
- dnsStart: this.#convertTimestamp(domainLookupStartTime, timeOrigin),
- dnsEnd: this.#convertTimestamp(domainLookupEndTime, timeOrigin),
- connectStart: this.#convertTimestamp(connectStartTime, timeOrigin),
- connectEnd: this.#convertTimestamp(connectEndTime, timeOrigin),
- tlsStart: this.#convertTimestamp(secureConnectionStartTime, timeOrigin),
- tlsEnd: this.#convertTimestamp(connectEndTime, timeOrigin),
- requestStart: this.#convertTimestamp(requestStartTime, timeOrigin),
- responseStart: this.#convertTimestamp(responseStartTime, timeOrigin),
- responseEnd: this.#convertTimestamp(responseEndTime, timeOrigin),
- };
- }
-
- /**
- * Update the timings and the redirect count from the nsITimedChannel
- * corresponding to the current channel. This should be called before emitting
- * any event from this class.
- */
- #updateDataFromTimedChannel() {
- const timedChannel = this.#requestChannel.QueryInterface(
- Ci.nsITimedChannel
- );
- this.#redirectCount = timedChannel.redirectCount;
- this.#requestData.timings = this.#getTimingsFromTimedChannel(timedChannel);
- }
}
diff --git a/remote/shared/listeners/NetworkListener.sys.mjs b/remote/shared/listeners/NetworkListener.sys.mjs
index 500d2005dc..d0d6d0e44f 100644
--- a/remote/shared/listeners/NetworkListener.sys.mjs
+++ b/remote/shared/listeners/NetworkListener.sys.mjs
@@ -44,11 +44,13 @@ ChromeUtils.defineESModuleGetters(lazy, {
export class NetworkListener {
#devtoolsNetworkObserver;
#listening;
+ #navigationManager;
- constructor() {
+ constructor(navigationManager) {
lazy.EventEmitter.decorate(this);
this.#listening = false;
+ this.#navigationManager = navigationManager;
}
destroy() {
@@ -104,6 +106,11 @@ export class NetworkListener {
};
#onNetworkEvent = (networkEvent, channel) => {
- return new lazy.NetworkEventRecord(networkEvent, channel, this);
+ return new lazy.NetworkEventRecord(
+ networkEvent,
+ channel,
+ this,
+ this.#navigationManager
+ );
};
}
diff --git a/remote/shared/listeners/test/browser/browser_NetworkListener.js b/remote/shared/listeners/test/browser/browser_NetworkListener.js
index cc1b42f2fc..211ccef49c 100644
--- a/remote/shared/listeners/test/browser/browser_NetworkListener.js
+++ b/remote/shared/listeners/test/browser/browser_NetworkListener.js
@@ -2,6 +2,9 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
+const { NavigationManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/NavigationManager.sys.mjs"
+);
const { NetworkListener } = ChromeUtils.importESModule(
"chrome://remote/content/shared/listeners/NetworkListener.sys.mjs"
);
@@ -10,7 +13,10 @@ const { TabManager } = ChromeUtils.importESModule(
);
add_task(async function test_beforeRequestSent() {
- const listener = new NetworkListener();
+ const navigationManager = new NavigationManager();
+ navigationManager.startMonitoring();
+
+ const listener = new NetworkListener(navigationManager);
const events = [];
const onEvent = (name, data) => events.push(data);
listener.on("before-request-sent", onEvent);
@@ -54,10 +60,14 @@ add_task(async function test_beforeRequestSent() {
gBrowser.removeTab(tab2);
listener.off("before-request-sent", onEvent);
listener.destroy();
+ navigationManager.destroy();
});
add_task(async function test_beforeRequestSent_newTab() {
- const listener = new NetworkListener();
+ const navigationManager = new NavigationManager();
+ navigationManager.startMonitoring();
+
+ const listener = new NetworkListener(navigationManager);
const onBeforeRequestSent = listener.once("before-request-sent");
listener.startListening();
@@ -76,10 +86,14 @@ add_task(async function test_beforeRequestSent_newTab() {
"https://example.com/document-builder.sjs?html=tab"
);
gBrowser.removeTab(tab);
+ navigationManager.destroy();
});
add_task(async function test_fetchError() {
- const listener = new NetworkListener();
+ const navigationManager = new NavigationManager();
+ navigationManager.startMonitoring();
+
+ const listener = new NetworkListener(navigationManager);
const onFetchError = listener.once("fetch-error");
listener.startListening();
@@ -90,11 +104,16 @@ add_task(async function test_fetchError() {
const event = await onFetchError;
assertNetworkEvent(event, contextId, "https://not_a_valid_url/");
- is(event.errorText, "NS_ERROR_UNKNOWN_HOST");
+ is(event.request.errorText, "NS_ERROR_UNKNOWN_HOST");
gBrowser.removeTab(tab);
+ navigationManager.destroy();
});
function assertNetworkEvent(event, expectedContextId, expectedUrl) {
- is(event.contextId, expectedContextId, "Event has the expected context id");
- is(event.requestData.url, expectedUrl, "Event has the expected url");
+ is(
+ event.request.contextId,
+ expectedContextId,
+ "Event has the expected context id"
+ );
+ is(event.request.serializedURL, expectedUrl, "Event has the expected url");
}
diff --git a/remote/shared/test/xpcshell/test_DOM.js b/remote/shared/test/xpcshell/test_DOM.js
index 19844659b9..03ac27ed45 100644
--- a/remote/shared/test/xpcshell/test_DOM.js
+++ b/remote/shared/test/xpcshell/test_DOM.js
@@ -75,27 +75,55 @@ function setupTest() {
<iframe></iframe>
<video></video>
<svg xmlns="http://www.w3.org/2000/svg"></svg>
- <textarea></textarea>
+ <form>
+ <button/>
+ <input/>
+ <fieldset>
+ <legend><input id="first"/></legend>
+ <legend><input id="second"/></legend>
+ </fieldset>
+ <select>
+ <optgroup>
+ <option id="in-group">foo</options>
+ </optgroup>
+ <option id="no-group">bar</option>
+ </select>
+ <textarea></textarea>
+ </form>
</div>
`;
const divEl = browser.document.querySelector("div");
const svgEl = browser.document.querySelector("svg");
- const textareaEl = browser.document.querySelector("textarea");
const videoEl = browser.document.querySelector("video");
+ const shadowRoot = videoEl.openOrClosedShadowRoot;
+
+ const buttonEl = browser.document.querySelector("button");
+ const fieldsetEl = browser.document.querySelector("fieldset");
+ const inputEl = browser.document.querySelector("input");
+ const optgroupEl = browser.document.querySelector("optgroup");
+ const optionInGroupEl = browser.document.querySelector("option#in-group");
+ const optionNoGroupEl = browser.document.querySelector("option#no-group");
+ const selectEl = browser.document.querySelector("select");
+ const textareaEl = browser.document.querySelector("textarea");
const iframeEl = browser.document.querySelector("iframe");
const childEl = iframeEl.contentDocument.createElement("div");
iframeEl.contentDocument.body.appendChild(childEl);
- const shadowRoot = videoEl.openOrClosedShadowRoot;
-
return {
browser,
- nodeCache: new NodeCache(),
+ buttonEl,
childEl,
divEl,
+ inputEl,
+ fieldsetEl,
iframeEl,
+ nodeCache: new NodeCache(),
+ optgroupEl,
+ optionInGroupEl,
+ optionNoGroupEl,
+ selectEl,
shadowRoot,
svgEl,
textareaEl,
@@ -252,38 +280,70 @@ add_task(function test_isReadOnly() {
ok(!dom.isReadOnly(null));
});
-add_task(function test_isDisabled() {
- const { browser, divEl, svgEl } = setupTest();
+add_task(function test_isDisabledSelect() {
+ const { optgroupEl, optionInGroupEl, optionNoGroupEl, selectEl } =
+ setupTest();
+
+ optionNoGroupEl.disabled = true;
+ ok(dom.isDisabled(optionNoGroupEl));
+ optionNoGroupEl.disabled = false;
+ ok(!dom.isDisabled(optionNoGroupEl));
+
+ optgroupEl.disabled = true;
+ ok(dom.isDisabled(optgroupEl));
+ ok(dom.isDisabled(optionInGroupEl));
+ optgroupEl.disabled = false;
+ ok(!dom.isDisabled(optgroupEl));
+ ok(!dom.isDisabled(optionInGroupEl));
+
+ selectEl.disabled = true;
+ ok(dom.isDisabled(selectEl));
+ ok(dom.isDisabled(optgroupEl));
+ ok(dom.isDisabled(optionNoGroupEl));
+ selectEl.disabled = false;
+ ok(!dom.isDisabled(selectEl));
+ ok(!dom.isDisabled(optgroupEl));
+ ok(!dom.isDisabled(optionNoGroupEl));
+});
- const select = browser.document.createElement("select");
- const option = browser.document.createElement("option");
- select.appendChild(option);
- select.disabled = true;
- ok(dom.isDisabled(option));
+add_task(function test_isDisabledFormControl() {
+ const { buttonEl, fieldsetEl, inputEl, selectEl, textareaEl } = setupTest();
- const optgroup = browser.document.createElement("optgroup");
- option.parentNode = optgroup;
- ok(dom.isDisabled(option));
+ for (const elem of [buttonEl, inputEl, selectEl, textareaEl]) {
+ elem.disabled = true;
+ ok(dom.isDisabled(elem));
+ elem.disabled = false;
+ ok(!dom.isDisabled(elem));
+ }
- optgroup.parentNode = select;
- ok(dom.isDisabled(option));
+ const inputs = fieldsetEl.querySelectorAll("input");
+ fieldsetEl.disabled = true;
+ ok(dom.isDisabled(fieldsetEl));
+ ok(!dom.isDisabled(inputs[0]));
+ ok(dom.isDisabled(inputs[1]));
+ fieldsetEl.disabled = false;
+ ok(!dom.isDisabled(fieldsetEl));
+ ok(!dom.isDisabled(inputs[0]));
+ ok(!dom.isDisabled(inputs[1]));
+});
- select.disabled = false;
- ok(!dom.isDisabled(option));
+add_task(function test_isDisabledElement() {
+ const { divEl, svgEl } = setupTest();
+ const mockXulEl = new MockXULElement("browser", { disabled: true });
- for (const type of ["button", "input", "select", "textarea"]) {
- const elem = browser.document.createElement(type);
+ for (const elem of [divEl, svgEl, mockXulEl]) {
ok(!dom.isDisabled(elem));
elem.disabled = true;
- ok(dom.isDisabled(elem));
+ ok(!dom.isDisabled(elem));
}
+});
- ok(!dom.isDisabled(divEl));
-
- svgEl.disabled = true;
- ok(!dom.isDisabled(svgEl));
+add_task(function test_isDisabledNoDOMElement() {
+ ok(!dom.isDisabled());
- ok(!dom.isDisabled(new MockXULElement("browser", { disabled: true })));
+ for (const obj of [null, undefined, 42, "", {}, []]) {
+ ok(!dom.isDisabled(obj));
+ }
});
add_task(function test_isEditingHost() {
diff --git a/remote/shared/webdriver/Accessibility.sys.mjs b/remote/shared/webdriver/Accessibility.sys.mjs
new file mode 100644
index 0000000000..4c7b2a6c69
--- /dev/null
+++ b/remote/shared/webdriver/Accessibility.sys.mjs
@@ -0,0 +1,519 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+ChromeUtils.defineLazyGetter(lazy, "service", () => {
+ try {
+ return Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ } catch (e) {
+ lazy.logger.warn("Accessibility module is not present");
+ return undefined;
+ }
+});
+
+/** @namespace */
+export const accessibility = {
+ get service() {
+ return lazy.service;
+ },
+};
+
+/**
+ * Accessible states used to check element"s state from the accessiblity API
+ * perspective.
+ *
+ * Note: if gecko is built with --disable-accessibility, the interfaces
+ * are not defined. This is why we use getters instead to be able to use
+ * these statically.
+ */
+accessibility.State = {
+ get Unavailable() {
+ return Ci.nsIAccessibleStates.STATE_UNAVAILABLE;
+ },
+ get Focusable() {
+ return Ci.nsIAccessibleStates.STATE_FOCUSABLE;
+ },
+ get Selectable() {
+ return Ci.nsIAccessibleStates.STATE_SELECTABLE;
+ },
+ get Selected() {
+ return Ci.nsIAccessibleStates.STATE_SELECTED;
+ },
+};
+
+/**
+ * Accessible object roles that support some action.
+ */
+accessibility.ActionableRoles = new Set([
+ "checkbutton",
+ "check menu item",
+ "check rich option",
+ "combobox",
+ "combobox option",
+ "entry",
+ "key",
+ "link",
+ "listbox option",
+ "listbox rich option",
+ "menuitem",
+ "option",
+ "outlineitem",
+ "pagetab",
+ "pushbutton",
+ "radiobutton",
+ "radio menu item",
+ "rowheader",
+ "slider",
+ "spinbutton",
+ "switch",
+]);
+
+/**
+ * Factory function that constructs a new {@code accessibility.Checks}
+ * object with enforced strictness or not.
+ */
+accessibility.get = function (strict = false) {
+ return new accessibility.Checks(!!strict);
+};
+
+/**
+ * Wait for the document accessibility state to be different from STATE_BUSY.
+ *
+ * @param {Document} doc
+ * The document to wait for.
+ * @returns {Promise}
+ * A promise which resolves when the document's accessibility state is no
+ * longer busy.
+ */
+function waitForDocumentAccessibility(doc) {
+ const documentAccessible = accessibility.service.getAccessibleFor(doc);
+ const state = {};
+ documentAccessible.getState(state, {});
+ if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
+ return Promise.resolve();
+ }
+
+ // Accessibility for the doc is busy, so wait for the state to change.
+ return lazy.waitForObserverTopic("accessible-event", {
+ checkFn: subject => {
+ // If event type does not match expected type, skip the event.
+ // If event's accessible does not match expected accessible,
+ // skip the event.
+ const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
+ return (
+ event.eventType === Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE &&
+ event.accessible === documentAccessible
+ );
+ },
+ });
+}
+
+/**
+ * Retrieve the Accessible for the provided element.
+ *
+ * @param {Element} element
+ * The element for which we need to retrieve the accessible.
+ *
+ * @returns {nsIAccessible|null}
+ * The Accessible object corresponding to the provided element or null if
+ * the accessibility service is not available.
+ */
+accessibility.getAccessible = async function (element) {
+ if (!accessibility.service) {
+ return null;
+ }
+
+ // First, wait for accessibility to be ready for the element's document.
+ await waitForDocumentAccessibility(element.ownerDocument);
+
+ const acc = accessibility.service.getAccessibleFor(element);
+ if (acc) {
+ return acc;
+ }
+
+ // The Accessible doesn't exist yet. This can happen because a11y tree
+ // mutations happen during refresh driver ticks. Stop the refresh driver from
+ // doing its regular ticks and force two refresh driver ticks: the first to
+ // let layout update and notify a11y, and the second to let a11y process
+ // updates.
+ const windowUtils = element.ownerGlobal.windowUtils;
+ windowUtils.advanceTimeAndRefresh(0);
+ windowUtils.advanceTimeAndRefresh(0);
+ // Go back to normal refresh driver ticks.
+ windowUtils.restoreNormalRefresh();
+ return accessibility.service.getAccessibleFor(element);
+};
+
+/**
+ * Retrieve the accessible name for the provided element.
+ *
+ * @param {Element} element
+ * The element for which we need to retrieve the accessible name.
+ *
+ * @returns {string}
+ * The accessible name.
+ */
+accessibility.getAccessibleName = async function (element) {
+ const accessible = await accessibility.getAccessible(element);
+ if (!accessible) {
+ return "";
+ }
+
+ // If name is null (absent), expose the empty string.
+ if (accessible.name === null) {
+ return "";
+ }
+
+ return accessible.name;
+};
+
+/**
+ * Compute the role for the provided element.
+ *
+ * @param {Element} element
+ * The element for which we need to compute the role.
+ *
+ * @returns {string}
+ * The computed role.
+ */
+accessibility.getComputedRole = async function (element) {
+ const accessible = await accessibility.getAccessible(element);
+ if (!accessible) {
+ // If it's not in the a11y tree, it's probably presentational.
+ return "none";
+ }
+
+ return accessible.computedARIARole;
+};
+
+/**
+ * Component responsible for interacting with platform accessibility
+ * API.
+ *
+ * Its methods serve as wrappers for testing content and chrome
+ * accessibility as well as accessibility of user interactions.
+ */
+accessibility.Checks = class {
+ /**
+ * @param {boolean} strict
+ * Flag indicating whether the accessibility issue should be logged
+ * or cause an error to be thrown. Default is to log to stdout.
+ */
+ constructor(strict) {
+ this.strict = strict;
+ }
+
+ /**
+ * Assert that the element has a corresponding accessible object, and retrieve
+ * this accessible. Note that if the accessibility.Checks component was
+ * created in non-strict mode, this helper will not attempt to resolve the
+ * accessible at all and will simply return null.
+ *
+ * @param {DOMElement|XULElement} element
+ * Element to get the accessible object for.
+ * @param {boolean=} mustHaveAccessible
+ * Flag indicating that the element must have an accessible object.
+ * Defaults to not require this.
+ *
+ * @returns {Promise.<nsIAccessible>}
+ * Promise with an accessibility object for the given element.
+ */
+ async assertAccessible(element, mustHaveAccessible = false) {
+ if (!this.strict) {
+ return null;
+ }
+
+ const accessible = await accessibility.getAccessible(element);
+ if (!accessible && mustHaveAccessible) {
+ this.error("Element does not have an accessible object", element);
+ }
+
+ return accessible;
+ }
+
+ /**
+ * Test if the accessible has a role that supports some arbitrary
+ * action.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if an actionable role is found on the accessible, false
+ * otherwise.
+ */
+ isActionableRole(accessible) {
+ return accessibility.ActionableRoles.has(
+ accessibility.service.getStringRole(accessible.role)
+ );
+ }
+
+ /**
+ * Test if an accessible has at least one action that it supports.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if the accessible has at least one supported action,
+ * false otherwise.
+ */
+ hasActionCount(accessible) {
+ return accessible.actionCount > 0;
+ }
+
+ /**
+ * Test if an accessible has a valid name.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if the accessible has a non-empty valid name, or false if
+ * this is not the case.
+ */
+ hasValidName(accessible) {
+ return accessible.name && accessible.name.trim();
+ }
+
+ /**
+ * Test if an accessible has a {@code hidden} attribute.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if the accessible object has a {@code hidden} attribute,
+ * false otherwise.
+ */
+ hasHiddenAttribute(accessible) {
+ let hidden = false;
+ try {
+ hidden = accessible.attributes.getStringProperty("hidden");
+ } catch (e) {}
+ // if the property is missing, error will be thrown
+ return hidden && hidden === "true";
+ }
+
+ /**
+ * Verify if an accessible has a given state.
+ * Test if an accessible has a given state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object to test.
+ * @param {number} stateToMatch
+ * State to match.
+ *
+ * @returns {boolean}
+ * True if |accessible| has |stateToMatch|, false otherwise.
+ */
+ matchState(accessible, stateToMatch) {
+ let state = {};
+ accessible.getState(state, {});
+ return !!(state.value & stateToMatch);
+ }
+
+ /**
+ * Test if an accessible is hidden from the user.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if element is hidden from user, false otherwise.
+ */
+ isHidden(accessible) {
+ if (!accessible) {
+ return true;
+ }
+
+ while (accessible) {
+ if (this.hasHiddenAttribute(accessible)) {
+ return true;
+ }
+ accessible = accessible.parent;
+ }
+ return false;
+ }
+
+ /**
+ * Test if the element's visible state corresponds to its accessibility
+ * API visibility.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} visible
+ * Visibility state of |element|.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s visibility state does not correspond to
+ * |accessible|'s.
+ */
+ assertVisible(accessible, element, visible) {
+ let hiddenAccessibility = this.isHidden(accessible);
+
+ let message;
+ if (visible && hiddenAccessibility) {
+ message =
+ "Element is not currently visible via the accessibility API " +
+ "and may not be manipulated by it";
+ } else if (!visible && !hiddenAccessibility) {
+ message =
+ "Element is currently only visible via the accessibility API " +
+ "and can be manipulated by it";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Test if the element's unavailable accessibility state matches the
+ * enabled state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} enabled
+ * Enabled state of |element|.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s enabled state does not match |accessible|'s.
+ */
+ assertEnabled(accessible, element, enabled) {
+ if (!accessible) {
+ return;
+ }
+
+ let win = element.ownerGlobal;
+ let disabledAccessibility = this.matchState(
+ accessible,
+ accessibility.State.Unavailable
+ );
+ let explorable =
+ win.getComputedStyle(element).getPropertyValue("pointer-events") !==
+ "none";
+
+ let message;
+ if (!explorable && !disabledAccessibility) {
+ message =
+ "Element is enabled but is not explorable via the " +
+ "accessibility API";
+ } else if (enabled && disabledAccessibility) {
+ message = "Element is enabled but disabled via the accessibility API";
+ } else if (!enabled && !disabledAccessibility) {
+ message = "Element is disabled but enabled via the accessibility API";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Test if it is possible to activate an element with the accessibility
+ * API.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ *
+ * @throws ElementNotAccessibleError
+ * If it is impossible to activate |element| with |accessible|.
+ */
+ assertActionable(accessible, element) {
+ if (!accessible) {
+ return;
+ }
+
+ let message;
+ if (!this.hasActionCount(accessible)) {
+ message = "Element does not support any accessible actions";
+ } else if (!this.isActionableRole(accessible)) {
+ message =
+ "Element does not have a correct accessibility role " +
+ "and may not be manipulated via the accessibility API";
+ } else if (!this.hasValidName(accessible)) {
+ message = "Element is missing an accessible name";
+ } else if (!this.matchState(accessible, accessibility.State.Focusable)) {
+ message = "Element is not focusable via the accessibility API";
+ }
+
+ this.error(message, element);
+ }
+
+ /**
+ * Test that an element's selected state corresponds to its
+ * accessibility API selected state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} selected
+ * The |element|s selected state.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s selected state does not correspond to
+ * |accessible|'s.
+ */
+ assertSelected(accessible, element, selected) {
+ if (!accessible) {
+ return;
+ }
+
+ // element is not selectable via the accessibility API
+ if (!this.matchState(accessible, accessibility.State.Selectable)) {
+ return;
+ }
+
+ let selectedAccessibility = this.matchState(
+ accessible,
+ accessibility.State.Selected
+ );
+
+ let message;
+ if (selected && !selectedAccessibility) {
+ message =
+ "Element is selected but not selected via the accessibility API";
+ } else if (!selected && selectedAccessibility) {
+ message =
+ "Element is not selected but selected via the accessibility API";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Throw an error if strict accessibility checks are enforced and log
+ * the error to the log.
+ *
+ * @param {string} message
+ * @param {DOMElement|XULElement} element
+ * Element that caused an error.
+ *
+ * @throws ElementNotAccessibleError
+ * If |strict| is true.
+ */
+ error(message, element) {
+ if (!message || !this.strict) {
+ return;
+ }
+ if (element) {
+ let { id, tagName, className } = element;
+ message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
+ }
+
+ throw new lazy.error.ElementNotAccessibleError(message);
+ }
+};
diff --git a/remote/shared/webdriver/Actions.sys.mjs b/remote/shared/webdriver/Actions.sys.mjs
index 2639c4dc9f..2c21e989dd 100644
--- a/remote/shared/webdriver/Actions.sys.mjs
+++ b/remote/shared/webdriver/Actions.sys.mjs
@@ -1318,6 +1318,7 @@ class WheelScrollAction extends WheelAction {
this.duration ?? tickDuration,
deltaTarget =>
this.performOneWheelScroll(
+ state,
scrollCoordinates,
deltaPosition,
deltaTarget,
@@ -1329,12 +1330,19 @@ class WheelScrollAction extends WheelAction {
/**
* Perform one part of a wheel scroll corresponding to a specific emitted event.
*
+ * @param {State} state - Actions state.
* @param {Array<number>} scrollCoordinates - [x, y] viewport coordinates of the scroll.
* @param {Array<number>} deltaPosition - [deltaX, deltaY] coordinates of the scroll before this event.
* @param {Array<Array<number>>} deltaTargets - Array of [deltaX, deltaY] coordinates to scroll to.
* @param {WindowProxy} win - Current window global.
*/
- performOneWheelScroll(scrollCoordinates, deltaPosition, deltaTargets, win) {
+ performOneWheelScroll(
+ state,
+ scrollCoordinates,
+ deltaPosition,
+ deltaTargets,
+ win
+ ) {
if (deltaTargets.length !== 1) {
throw new Error("Can only scroll one wheel at a time");
}
@@ -1350,6 +1358,7 @@ class WheelScrollAction extends WheelAction {
deltaY,
deltaZ: 0,
});
+ eventData.update(state);
lazy.event.synthesizeWheelAtPoint(
scrollCoordinates[0],
@@ -2237,6 +2246,22 @@ class WheelEventData extends InputEventData {
this.deltaY = deltaY;
this.deltaZ = deltaZ;
this.deltaMode = deltaMode;
+
+ this.altKey = false;
+ this.ctrlKey = false;
+ this.metaKey = false;
+ this.shiftKey = false;
+ }
+
+ update(state) {
+ // set modifier properties based on whether any corresponding keys are
+ // pressed on any key input source
+ for (const [, otherInputSource] of state.inputSourcesByType("key")) {
+ this.altKey = otherInputSource.alt || this.altKey;
+ this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
+ this.metaKey = otherInputSource.meta || this.metaKey;
+ this.shiftKey = otherInputSource.shift || this.shiftKey;
+ }
}
}
diff --git a/remote/shared/webdriver/Assert.sys.mjs b/remote/shared/webdriver/Assert.sys.mjs
index 6c254173aa..fe83bc9181 100644
--- a/remote/shared/webdriver/Assert.sys.mjs
+++ b/remote/shared/webdriver/Assert.sys.mjs
@@ -410,6 +410,36 @@ assert.object = function (obj, msg = "") {
};
/**
+ * Asserts that <var>obj</var> is an instance of a specified class.
+ * <var>constructor</var> should have a static isInstance method implemented.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {?} constructor
+ * Class constructor.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {object}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an instance of a specified class.
+ */
+assert.isInstance = function (obj, constructor, msg = "") {
+ assert.object(obj, msg);
+ assert.object(constructor.prototype, msg);
+
+ msg =
+ msg ||
+ lazy.pprint`Expected ${obj} to be an instance of ${constructor.name}`;
+ return assert.that(
+ o => Object.hasOwn(constructor, "isInstance") && constructor.isInstance(o),
+ msg
+ )(obj);
+};
+
+/**
* Asserts that <var>prop</var> is in <var>obj</var>.
*
* @param {?} prop
diff --git a/remote/shared/webdriver/Session.sys.mjs b/remote/shared/webdriver/Session.sys.mjs
index edffeea7b6..3d7b074ac9 100644
--- a/remote/shared/webdriver/Session.sys.mjs
+++ b/remote/shared/webdriver/Session.sys.mjs
@@ -5,7 +5,8 @@
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
- accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs",
+ accessibility:
+ "chrome://remote/content/shared/webdriver/Accessibility.sys.mjs",
allowAllCerts: "chrome://remote/content/marionette/cert.sys.mjs",
Capabilities: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
diff --git a/remote/shared/webdriver/test/xpcshell/test_Assert.js b/remote/shared/webdriver/test/xpcshell/test_Assert.js
index cf474868b6..aabd8656dd 100644
--- a/remote/shared/webdriver/test/xpcshell/test_Assert.js
+++ b/remote/shared/webdriver/test/xpcshell/test_Assert.js
@@ -150,6 +150,20 @@ add_task(function test_object() {
Assert.throws(() => assert.object(null, "custom"), /custom/);
});
+add_task(function test_isInstance() {
+ class Foo {
+ static isInstance(obj) {
+ return obj instanceof Foo;
+ }
+ }
+ assert.isInstance(new Foo(), Foo);
+ for (let typ of [{}, 42, "foo", true, null, undefined]) {
+ Assert.throws(() => assert.isInstance(typ, Foo), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.isInstance(null, null, "custom"), /custom/);
+});
+
add_task(function test_in() {
assert.in("foo", { foo: 42 });
for (let typ of [{}, 42, true, null, undefined]) {