diff options
Diffstat (limited to 'remote/shared')
-rw-r--r-- | remote/shared/Capture.sys.mjs | 2 | ||||
-rw-r--r-- | remote/shared/DOM.sys.mjs | 34 | ||||
-rw-r--r-- | remote/shared/Navigate.sys.mjs | 7 | ||||
-rw-r--r-- | remote/shared/NetworkRequest.sys.mjs | 254 | ||||
-rw-r--r-- | remote/shared/NetworkResponse.sys.mjs | 131 | ||||
-rw-r--r-- | remote/shared/Permissions.sys.mjs | 90 | ||||
-rw-r--r-- | remote/shared/listeners/NetworkEventRecord.sys.mjs | 290 | ||||
-rw-r--r-- | remote/shared/listeners/NetworkListener.sys.mjs | 11 | ||||
-rw-r--r-- | remote/shared/listeners/test/browser/browser_NetworkListener.js | 31 | ||||
-rw-r--r-- | remote/shared/test/xpcshell/test_DOM.js | 114 | ||||
-rw-r--r-- | remote/shared/webdriver/Accessibility.sys.mjs | 519 | ||||
-rw-r--r-- | remote/shared/webdriver/Actions.sys.mjs | 27 | ||||
-rw-r--r-- | remote/shared/webdriver/Assert.sys.mjs | 30 | ||||
-rw-r--r-- | remote/shared/webdriver/Session.sys.mjs | 3 | ||||
-rw-r--r-- | remote/shared/webdriver/test/xpcshell/test_Assert.js | 14 |
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]) { |