browsingContext.sys.mjs (19013B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 accessibility: 11 "chrome://remote/content/shared/webdriver/Accessibility.sys.mjs", 12 AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs", 13 assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", 14 ClipRectangleType: 15 "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs", 16 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", 17 EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", 18 LoadListener: "chrome://remote/content/shared/listeners/LoadListener.sys.mjs", 19 LocatorType: 20 "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs", 21 OriginType: 22 "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs", 23 OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", 24 pprint: "chrome://remote/content/shared/Format.sys.mjs", 25 }); 26 27 const DOCUMENT_FRAGMENT_NODE = 11; 28 const DOCUMENT_NODE = 9; 29 const ELEMENT_NODE = 1; 30 31 const ORDERED_NODE_SNAPSHOT_TYPE = 7; 32 33 class BrowsingContextModule extends WindowGlobalBiDiModule { 34 #contextCreatedHandled; 35 #loadListener; 36 #subscribedEvents; 37 38 constructor(messageHandler) { 39 super(messageHandler); 40 41 // Setup the LoadListener as early as possible. 42 this.#loadListener = new lazy.LoadListener(this.messageHandler.window); 43 this.#loadListener.on("DOMContentLoaded", this.#onDOMContentLoaded); 44 this.#loadListener.on("load", this.#onLoad); 45 46 // Set of event names which have active subscriptions. 47 this.#subscribedEvents = new Set(); 48 this.contextCreatedHandled = false; 49 } 50 51 destroy() { 52 this.#loadListener.destroy(); 53 this.#subscribedEvents = null; 54 } 55 56 /** 57 * Collect nodes using accessibility attributes. 58 * 59 * @see https://w3c.github.io/webdriver-bidi/#collect-nodes-using-accessibility-attributes 60 */ 61 async #collectNodesUsingAccessibilityAttributes( 62 contextNodes, 63 selector, 64 maxReturnedNodeCount, 65 returnedNodes 66 ) { 67 if (returnedNodes === null) { 68 returnedNodes = []; 69 } 70 71 for (const contextNode of contextNodes) { 72 let match = true; 73 74 if (contextNode.nodeType === ELEMENT_NODE) { 75 if ("role" in selector) { 76 const role = await lazy.accessibility.getComputedRole(contextNode); 77 78 if (selector.role !== role) { 79 match = false; 80 } 81 } 82 83 if ("name" in selector) { 84 const name = await lazy.accessibility.getAccessibleName(contextNode); 85 if (selector.name !== name) { 86 match = false; 87 } 88 } 89 } else { 90 match = false; 91 } 92 93 if (match) { 94 if ( 95 maxReturnedNodeCount !== null && 96 returnedNodes.length === maxReturnedNodeCount 97 ) { 98 break; 99 } 100 returnedNodes.push(contextNode); 101 } 102 103 const childNodes = [...contextNode.children]; 104 105 await this.#collectNodesUsingAccessibilityAttributes( 106 childNodes, 107 selector, 108 maxReturnedNodeCount, 109 returnedNodes 110 ); 111 } 112 113 return returnedNodes; 114 } 115 116 #getNavigationInfo(data) { 117 // Note: the navigation id is collected in the parent-process and will be 118 // added via event interception by the windowglobal-in-root module. 119 return { 120 context: this.messageHandler.context, 121 timestamp: Date.now(), 122 url: data.target.URL, 123 }; 124 } 125 126 #getOriginRectangle(origin) { 127 const win = this.messageHandler.window; 128 129 if (origin === lazy.OriginType.viewport) { 130 const viewport = win.visualViewport; 131 // Until it's clarified in the scope of the issue: 132 // https://github.com/w3c/webdriver-bidi/issues/592 133 // if we should take into account scrollbar dimensions, when calculating 134 // the viewport size, we match the behavior of WebDriver Classic, 135 // meaning we include scrollbar dimensions. 136 return new DOMRect( 137 viewport.pageLeft, 138 viewport.pageTop, 139 win.innerWidth, 140 win.innerHeight 141 ); 142 } 143 144 const documentElement = win.document.documentElement; 145 return new DOMRect( 146 0, 147 0, 148 documentElement.scrollWidth, 149 documentElement.scrollHeight 150 ); 151 } 152 153 /** 154 * Locate the container element of a provided context id. 155 * 156 * @see https://w3c.github.io/webdriver-bidi/#locate-the-container-element 157 */ 158 159 #locateContainer(contextId) { 160 const returnedNodes = []; 161 const context = BrowsingContext.get(contextId); 162 const container = context.embedderElement; 163 if (container) { 164 returnedNodes.push(container); 165 } 166 167 return returnedNodes; 168 } 169 170 /** 171 * Locate nodes using accessibility attributes. 172 * 173 * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-accessibility-attributes 174 */ 175 async #locateNodesUsingAccessibilityAttributes( 176 contextNodes, 177 selector, 178 maxReturnedNodeCount 179 ) { 180 if (!("role" in selector) && !("name" in selector)) { 181 throw new lazy.error.InvalidSelectorError( 182 "Locating nodes by accessibility attributes requires `role` or `name` arguments" 183 ); 184 } 185 186 return this.#collectNodesUsingAccessibilityAttributes( 187 contextNodes, 188 selector, 189 maxReturnedNodeCount, 190 null 191 ); 192 } 193 194 /** 195 * Locate nodes using css selector. 196 * 197 * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-css 198 */ 199 #locateNodesUsingCss(contextNodes, selector, maxReturnedNodeCount) { 200 const returnedNodes = []; 201 202 for (const contextNode of contextNodes) { 203 let elements; 204 try { 205 elements = contextNode.querySelectorAll(selector); 206 } catch (e) { 207 throw new lazy.error.InvalidSelectorError( 208 `${e.message}: "${selector}"` 209 ); 210 } 211 212 if (maxReturnedNodeCount === null) { 213 returnedNodes.push(...elements); 214 } else { 215 for (const element of elements) { 216 returnedNodes.push(element); 217 218 if (returnedNodes.length === maxReturnedNodeCount) { 219 return returnedNodes; 220 } 221 } 222 } 223 } 224 225 return returnedNodes; 226 } 227 228 /** 229 * Locate nodes using XPath. 230 * 231 * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-xpath 232 */ 233 #locateNodesUsingXPath(contextNodes, selector, maxReturnedNodeCount) { 234 const returnedNodes = []; 235 236 for (const contextNode of contextNodes) { 237 let evaluationResult; 238 try { 239 evaluationResult = this.messageHandler.window.document.evaluate( 240 selector, 241 contextNode, 242 null, 243 ORDERED_NODE_SNAPSHOT_TYPE, 244 null 245 ); 246 } catch (e) { 247 const errorMessage = `${e.message}: "${selector}"`; 248 if (DOMException.isInstance(e) && e.name === "SyntaxError") { 249 throw new lazy.error.InvalidSelectorError(errorMessage); 250 } 251 252 throw new lazy.error.UnknownError(errorMessage); 253 } 254 255 for (let index = 0; index < evaluationResult.snapshotLength; index++) { 256 const node = evaluationResult.snapshotItem(index); 257 returnedNodes.push(node); 258 259 if ( 260 maxReturnedNodeCount !== null && 261 returnedNodes.length === maxReturnedNodeCount 262 ) { 263 return returnedNodes; 264 } 265 } 266 } 267 268 return returnedNodes; 269 } 270 271 /** 272 * Normalize rectangle. This ensures that the resulting rect has 273 * positive width and height dimensions. 274 * 275 * @see https://w3c.github.io/webdriver-bidi/#normalise-rect 276 * 277 * @param {DOMRect} rect 278 * An object which describes the size and position of a rectangle. 279 * 280 * @returns {DOMRect} Normalized rectangle. 281 */ 282 #normalizeRect(rect) { 283 let { x, y, width, height } = rect; 284 285 if (width < 0) { 286 x += width; 287 width = -width; 288 } 289 290 if (height < 0) { 291 y += height; 292 height = -height; 293 } 294 295 return new DOMRect(x, y, width, height); 296 } 297 298 #onDOMContentLoaded = (eventName, data) => { 299 if (this.#subscribedEvents.has("browsingContext._documentInteractive")) { 300 this.messageHandler.emitEvent("browsingContext._documentInteractive", { 301 baseURL: data.target.baseURI, 302 contextId: this.messageHandler.contextId, 303 documentURL: data.target.URL, 304 innerWindowId: this.messageHandler.innerWindowId, 305 readyState: data.target.readyState, 306 }); 307 } 308 309 if (this.#subscribedEvents.has("browsingContext.domContentLoaded")) { 310 this.emitEvent( 311 "browsingContext.domContentLoaded", 312 this.#getNavigationInfo(data) 313 ); 314 } 315 }; 316 317 #onLoad = (eventName, data) => { 318 if (this.#subscribedEvents.has("browsingContext.load")) { 319 this.emitEvent("browsingContext.load", this.#getNavigationInfo(data)); 320 } 321 }; 322 323 /** 324 * Create a new rectangle which will be an intersection of 325 * rectangles specified as arguments. 326 * 327 * @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection 328 * 329 * @param {DOMRect} rect1 330 * An object which describes the size and position of a rectangle. 331 * @param {DOMRect} rect2 332 * An object which describes the size and position of a rectangle. 333 * 334 * @returns {DOMRect} Rectangle, representing an intersection of <var>rect1</var> and <var>rect2</var>. 335 */ 336 #rectangleIntersection(rect1, rect2) { 337 rect1 = this.#normalizeRect(rect1); 338 rect2 = this.#normalizeRect(rect2); 339 340 const x_min = Math.max(rect1.x, rect2.x); 341 const x_max = Math.min(rect1.x + rect1.width, rect2.x + rect2.width); 342 343 const y_min = Math.max(rect1.y, rect2.y); 344 const y_max = Math.min(rect1.y + rect1.height, rect2.y + rect2.height); 345 346 const width = Math.max(x_max - x_min, 0); 347 const height = Math.max(y_max - y_min, 0); 348 349 return new DOMRect(x_min, y_min, width, height); 350 } 351 352 #startListening() { 353 if (this.#subscribedEvents.size == 0) { 354 this.#loadListener.startListening(); 355 } 356 } 357 358 #stopListening() { 359 if (this.#subscribedEvents.size == 0) { 360 this.#loadListener.stopListening(); 361 } 362 } 363 364 #subscribeEvent(event) { 365 switch (event) { 366 case "browsingContext._documentInteractive": 367 this.#startListening(); 368 this.#subscribedEvents.add("browsingContext._documentInteractive"); 369 break; 370 case "browsingContext.domContentLoaded": 371 this.#startListening(); 372 this.#subscribedEvents.add("browsingContext.domContentLoaded"); 373 break; 374 case "browsingContext.load": 375 this.#startListening(); 376 this.#subscribedEvents.add("browsingContext.load"); 377 break; 378 } 379 } 380 381 #unsubscribeEvent(event) { 382 switch (event) { 383 case "browsingContext._documentInteractive": 384 this.#subscribedEvents.delete("browsingContext._documentInteractive"); 385 break; 386 case "browsingContext.domContentLoaded": 387 this.#subscribedEvents.delete("browsingContext.domContentLoaded"); 388 break; 389 case "browsingContext.load": 390 this.#subscribedEvents.delete("browsingContext.load"); 391 break; 392 } 393 394 this.#stopListening(); 395 } 396 397 /** 398 * Internal commands 399 */ 400 401 _applySessionData(params) { 402 // TODO: Bug 1775231. Move this logic to a shared module or an abstract 403 // class. 404 const { category } = params; 405 if (category === "event") { 406 const filteredSessionData = params.sessionData.filter(item => 407 this.messageHandler.matchesContext(item.contextDescriptor) 408 ); 409 for (const event of this.#subscribedEvents.values()) { 410 const hasSessionItem = filteredSessionData.some( 411 item => item.value === event 412 ); 413 // If there are no session items for this context, we should unsubscribe from the event. 414 if (!hasSessionItem) { 415 this.#unsubscribeEvent(event); 416 } 417 } 418 419 // Subscribe to all events, which have an item in SessionData. 420 for (const { value } of filteredSessionData) { 421 /** 422 * We only want to emit backfill events when subscribing to the contextCreated 423 * event. We also do not want to emit a backfill when creating the context 424 * initially as this is already emitted elsewhere, so backfilling it would 425 * cause a duplicate event to be emitted. 426 */ 427 if (value === "browsingContext.contextCreated") { 428 /** 429 * We check for contextCreatedHandled so we do not replay any contextCreated events we 430 * have seen before. This can happen when navigating within a browser session as 431 * navigation will cause _applySessionData to be called with params.initial = false. 432 */ 433 if (!params.initial && !this.#contextCreatedHandled) { 434 this.emitEvent("browsingContext.contextCreated", { 435 context: this.messageHandler.context, 436 }); 437 } 438 439 this.#contextCreatedHandled = true; 440 } 441 442 this.#subscribeEvent(value); 443 } 444 } 445 } 446 447 /** 448 * Waits until the viewport has reached the new dimensions. 449 * 450 * @param {object} options 451 * @param {number} options.height 452 * Expected height the viewport will resize to. 453 * @param {number} options.width 454 * Expected width the viewport will resize to. 455 * 456 * @returns {Promise} 457 * Promise that resolves when the viewport has been resized. 458 */ 459 async _awaitViewportDimensions(options) { 460 const { height, width } = options; 461 462 const win = this.messageHandler.window; 463 let resized; 464 465 // Updates for background tabs are throttled, and we also have to make 466 // sure that the new browser dimensions have been received by the content 467 // process. As such wait for the next animation frame. 468 await lazy.AnimationFramePromise(win); 469 470 const checkBrowserSize = () => { 471 if (win.innerWidth === width && win.innerHeight === height) { 472 resized(); 473 } 474 }; 475 476 return new Promise(resolve => { 477 resized = resolve; 478 479 win.addEventListener("resize", checkBrowserSize); 480 481 // Trigger a layout flush in case none happened yet. 482 checkBrowserSize(); 483 }).finally(() => { 484 win.removeEventListener("resize", checkBrowserSize); 485 }); 486 } 487 488 /** 489 * Waits until the visibility state of the document has the expected value. 490 * 491 * @param {object} options 492 * @param {number=} options.timeout 493 * Timeout in ms. Optional, if not provided, the command will only resolve 494 * when the expected state is met. 495 * @param {number} options.value 496 * Expected value of the visibility state. 497 * 498 * @returns {Promise} 499 * Promise that resolves when the visibility state has the expected value, 500 * or the timeout has been reached. 501 */ 502 async _awaitVisibilityState(options) { 503 const { timeout = null, value } = options; 504 const win = this.messageHandler.window; 505 506 if (win.document.visibilityState === value) { 507 // If the document visibilityState already has the expected value, resolve 508 // immediately. 509 return; 510 } 511 512 try { 513 // Otherwise, wait for the next visibilitychange event. 514 await new lazy.EventPromise(win, "visibilitychange", { timeout }); 515 } catch (e) { 516 if (e instanceof lazy.error.TimeoutError) { 517 // Swallow the exception thrown by the EventPromise if we simply 518 // reached the timeout. Here the timeout is meant as an escape hatch, 519 // but we should still resolve 520 return; 521 } 522 throw e; 523 } 524 } 525 526 _getBaseURL() { 527 return this.messageHandler.window.document.baseURI; 528 } 529 530 _getScreenshotRect(params = {}) { 531 const { clip, origin } = params; 532 533 const originRect = this.#getOriginRectangle(origin); 534 let clipRect = originRect; 535 536 if (clip !== null) { 537 switch (clip.type) { 538 case lazy.ClipRectangleType.Box: { 539 clipRect = new DOMRect( 540 clip.x + originRect.x, 541 clip.y + originRect.y, 542 clip.width, 543 clip.height 544 ); 545 break; 546 } 547 548 case lazy.ClipRectangleType.Element: { 549 const realm = this.messageHandler.getRealm(); 550 const element = this.deserialize(clip.element, realm); 551 const viewportRect = this.#getOriginRectangle( 552 lazy.OriginType.viewport 553 ); 554 const elementRect = element.getBoundingClientRect(); 555 556 clipRect = new DOMRect( 557 elementRect.x + viewportRect.x, 558 elementRect.y + viewportRect.y, 559 elementRect.width, 560 elementRect.height 561 ); 562 break; 563 } 564 } 565 } 566 567 return this.#rectangleIntersection(originRect, clipRect); 568 } 569 570 async _locateNodes(params = {}) { 571 const { locator, maxNodeCount, serializationOptions, startNodes } = params; 572 573 const realm = this.messageHandler.getRealm(); 574 575 const contextNodes = []; 576 if (startNodes === null) { 577 contextNodes.push(this.messageHandler.window.document.documentElement); 578 } else { 579 for (const serializedStartNode of startNodes) { 580 const startNode = this.deserialize(serializedStartNode, realm); 581 lazy.assert.that( 582 startNode => 583 Node.isInstance(startNode) && 584 [DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, ELEMENT_NODE].includes( 585 startNode.nodeType 586 ), 587 lazy.pprint`Expected an item of "startNodes" to be an Element, got ${startNode}` 588 )(startNode); 589 590 contextNodes.push(startNode); 591 } 592 } 593 594 let returnedNodes; 595 switch (locator.type) { 596 case lazy.LocatorType.accessibility: { 597 returnedNodes = await this.#locateNodesUsingAccessibilityAttributes( 598 contextNodes, 599 locator.value, 600 maxNodeCount 601 ); 602 break; 603 } 604 case lazy.LocatorType.context: { 605 returnedNodes = this.#locateContainer(locator.value.context); 606 break; 607 } 608 case lazy.LocatorType.css: { 609 returnedNodes = this.#locateNodesUsingCss( 610 contextNodes, 611 locator.value, 612 maxNodeCount 613 ); 614 break; 615 } 616 case lazy.LocatorType.xpath: { 617 returnedNodes = this.#locateNodesUsingXPath( 618 contextNodes, 619 locator.value, 620 maxNodeCount 621 ); 622 break; 623 } 624 } 625 626 const serializedNodes = []; 627 const seenNodeIds = new Map(); 628 for (const returnedNode of returnedNodes) { 629 serializedNodes.push( 630 this.serialize( 631 returnedNode, 632 serializationOptions, 633 lazy.OwnershipModel.None, 634 realm, 635 { seenNodeIds } 636 ) 637 ); 638 } 639 640 return { 641 serializedNodes, 642 _extraData: { seenNodeIds }, 643 }; 644 } 645 } 646 647 export const browsingContext = BrowsingContextModule;