navigate.sys.mjs (14664B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", 9 EventDispatcher: 10 "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs", 11 getTimeoutMultiplier: "chrome://remote/content/shared/AppInfo.sys.mjs", 12 Log: "chrome://remote/content/shared/Log.sys.mjs", 13 MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs", 14 PageLoadStrategy: 15 "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", 16 ProgressListener: "chrome://remote/content/shared/Navigate.sys.mjs", 17 TimedPromise: "chrome://remote/content/shared/Sync.sys.mjs", 18 truncate: "chrome://remote/content/shared/Format.sys.mjs", 19 }); 20 21 ChromeUtils.defineLazyGetter(lazy, "logger", () => 22 lazy.Log.get(lazy.Log.TYPES.MARIONETTE) 23 ); 24 25 // Timeout used to wait for the page to be unloaded. 26 const TIMEOUT_UNLOAD_EVENT = 5000; 27 28 /** @namespace */ 29 export const navigate = {}; 30 31 /** 32 * Checks the value of readyState for the current page 33 * load activity, and resolves the command if the load 34 * has been finished. It also takes care of the selected 35 * page load strategy. 36 * 37 * @param {PageLoadStrategy} pageLoadStrategy 38 * Strategy when navigation is considered as finished. 39 * @param {object} eventData 40 * @param {string} eventData.documentURI 41 * Current document URI of the document. 42 * @param {string} eventData.readyState 43 * Current ready state of the document. 44 * 45 * @returns {boolean} 46 * True if the page load has been finished. 47 */ 48 function checkReadyState(pageLoadStrategy, eventData = {}) { 49 const { documentURI, readyState, isUncommittedInitialDocument } = eventData; 50 51 const result = { error: null, finished: false }; 52 53 switch (readyState) { 54 case "interactive": 55 if (documentURI.startsWith("about:certerror")) { 56 result.error = new lazy.error.InsecureCertificateError(); 57 result.finished = true; 58 } else if (/about:.*(error)\?/.exec(documentURI)) { 59 result.error = new lazy.error.UnknownError( 60 `Reached error page: ${documentURI}` 61 ); 62 result.finished = true; 63 64 // Return early with a page load strategy of eager, and also 65 // special-case about:blocked pages which should be treated as 66 // non-error pages but do not raise a pageshow event. about:blank 67 // is also treaded specifically here, because it gets temporary 68 // loaded for new content processes, and we only want to rely on 69 // complete loads for it. 70 } else if ( 71 (pageLoadStrategy === lazy.PageLoadStrategy.Eager && 72 documentURI != "about:blank") || 73 /about:blocked\?/.exec(documentURI) 74 ) { 75 result.finished = true; 76 } 77 break; 78 79 case "complete": 80 if (!isUncommittedInitialDocument) { 81 result.finished = true; 82 } 83 break; 84 } 85 86 return result; 87 } 88 89 /** 90 * Determines if we expect to get a DOM load event (DOMContentLoaded) 91 * on navigating to the <code>future</code> URL. 92 * 93 * @param {URL} current 94 * URL the browser is currently visiting. 95 * @param {object} options 96 * @param {BrowsingContext=} options.browsingContext 97 * The current browsing context. Needed for targets of _parent and _top. 98 * @param {URL=} options.future 99 * Destination URL, if known. 100 * @param {target=} options.target 101 * Link target, if known. 102 * 103 * @returns {boolean} 104 * Full page load would be expected if future is followed. 105 * 106 * @throws TypeError 107 * If <code>current</code> is not defined, or any of 108 * <code>current</code> or <code>future</code> are invalid URLs. 109 */ 110 navigate.isLoadEventExpected = function (current, options = {}) { 111 const { browsingContext, future, target } = options; 112 113 if (typeof current == "undefined") { 114 throw new TypeError("Expected at least one URL"); 115 } 116 117 if (["_parent", "_top"].includes(target) && !browsingContext) { 118 throw new TypeError( 119 "Expected browsingContext when target is _parent or _top" 120 ); 121 } 122 123 // Don't wait if the navigation happens in a different browsing context 124 if ( 125 target === "_blank" || 126 (target === "_parent" && browsingContext.parent) || 127 (target === "_top" && browsingContext.top != browsingContext) 128 ) { 129 return false; 130 } 131 132 // Assume we will go somewhere exciting 133 if (typeof future == "undefined") { 134 return true; 135 } 136 137 // Assume javascript:<whatever> will modify the current document 138 // but this is not an entirely safe assumption to make, 139 // considering it could be used to set window.location 140 if (future.protocol == "javascript:") { 141 return false; 142 } 143 144 // If hashes are present and identical 145 if ( 146 current.href.includes("#") && 147 future.href.includes("#") && 148 current.hash === future.hash 149 ) { 150 return false; 151 } 152 153 return true; 154 }; 155 156 /** 157 * Load the given URL in the specified browsing context. 158 * 159 * @param {CanonicalBrowsingContext} browsingContext 160 * Browsing context to load the URL into. 161 * @param {string} url 162 * URL to navigate to. 163 */ 164 navigate.navigateTo = async function (browsingContext, url) { 165 const opts = { 166 loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK, 167 // Fake user activation. 168 hasValidUserGestureActivation: true, 169 // Prevent HTTPS-First upgrades. 170 schemelessInput: Ci.nsILoadInfo.SchemelessInputTypeSchemeful, 171 triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), 172 }; 173 browsingContext.fixupAndLoadURIString(url, opts); 174 }; 175 176 /** 177 * Reload the page. 178 * 179 * @param {CanonicalBrowsingContext} browsingContext 180 * Browsing context to refresh. 181 */ 182 navigate.refresh = async function (browsingContext) { 183 const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; 184 browsingContext.reload(flags); 185 }; 186 187 /** 188 * Execute a callback and wait for a possible navigation to complete 189 * 190 * @param {GeckoDriver} driver 191 * Reference to driver instance. 192 * @param {Function} callback 193 * Callback to execute that might trigger a navigation. 194 * @param {object} options 195 * @param {BrowsingContext=} options.browsingContext 196 * Browsing context to observe. Defaults to the current browsing context. 197 * @param {boolean=} options.loadEventExpected 198 * If false, return immediately and don't wait for 199 * the navigation to be completed. Defaults to true. 200 * @param {boolean=} options.requireBeforeUnload 201 * If false and no beforeunload event is fired, abort waiting 202 * for the navigation. Defaults to true. 203 */ 204 navigate.waitForNavigationCompleted = async function waitForNavigationCompleted( 205 driver, 206 callback, 207 options = {} 208 ) { 209 const { 210 browsingContextFn = driver.getBrowsingContext.bind(driver), 211 loadEventExpected = true, 212 requireBeforeUnload = true, 213 } = options; 214 215 const browsingContext = browsingContextFn(); 216 const chromeWindow = browsingContext.topChromeWindow; 217 const pageLoadStrategy = driver.currentSession.pageLoadStrategy; 218 219 // Return immediately if no load event is expected 220 if (!loadEventExpected) { 221 await callback(); 222 return Promise.resolve(); 223 } 224 225 // When not waiting for page load events, do not return until the navigation has actually started. 226 if (pageLoadStrategy === lazy.PageLoadStrategy.None) { 227 const listener = new lazy.ProgressListener(browsingContext.webProgress, { 228 resolveWhenStarted: true, 229 waitForExplicitStart: true, 230 }); 231 const navigated = listener.start(); 232 navigated.finally(() => { 233 if (listener.isStarted) { 234 listener.stop(); 235 } 236 listener.destroy(); 237 }); 238 239 await callback(); 240 await navigated; 241 242 return Promise.resolve(); 243 } 244 245 let rejectNavigation; 246 let resolveNavigation; 247 248 let browsingContextChanged = false; 249 let seenBeforeUnload = false; 250 let seenUnload = false; 251 252 let unloadTimer; 253 254 const checkDone = ({ finished, error }) => { 255 if (finished) { 256 if (error) { 257 rejectNavigation(error); 258 } else { 259 resolveNavigation(); 260 } 261 } 262 }; 263 264 const onPromptClosed = (_, data) => { 265 if (data.detail.promptType === "beforeunload" && !data.detail.accepted) { 266 // If a beforeunload prompt is dismissed there will be no navigation. 267 lazy.logger.trace( 268 `Canceled page load listener because a beforeunload prompt was dismissed` 269 ); 270 checkDone({ finished: true }); 271 } 272 }; 273 274 const onPromptOpened = (_, data) => { 275 if (data.prompt.promptType === "beforeunload") { 276 // WebDriver HTTP basically doesn't know anything about beforeunload 277 // prompts. As such we always ignore the prompt opened event. 278 return; 279 } 280 281 lazy.logger.trace( 282 `Canceled page load listener because a ${data.prompt.promptType} prompt opened` 283 ); 284 checkDone({ finished: true }); 285 }; 286 287 const onTimer = () => { 288 // For the command "Element Click" we want to detect a potential navigation 289 // as early as possible. The `beforeunload` event is an indication for that 290 // but could still cause the navigation to get aborted by the user. As such 291 // wait a bit longer for the `unload` event to happen (only when the page 292 // load strategy is `none`), which usually will occur pretty soon after 293 // `beforeunload`. 294 // 295 // Note that with WebDriver BiDi enabled the `beforeunload` prompts might 296 // not get implicitly accepted, so lets keep the timer around until we know 297 // that it is really not required. 298 if (seenBeforeUnload) { 299 seenBeforeUnload = false; 300 unloadTimer.initWithCallback( 301 onTimer, 302 TIMEOUT_UNLOAD_EVENT, 303 Ci.nsITimer.TYPE_ONE_SHOT 304 ); 305 306 // If no page unload has been detected, ensure to properly stop 307 // the load listener, and return from the currently active command. 308 } else if (!seenUnload) { 309 lazy.logger.trace( 310 "Canceled page load listener because no navigation " + 311 "has been detected" 312 ); 313 checkDone({ finished: true }); 314 } 315 }; 316 317 const onNavigation = (eventName, data) => { 318 const browsingContext = browsingContextFn(); 319 320 // Ignore events from other browsing contexts than the selected one. 321 if (data.browsingContext != browsingContext) { 322 return; 323 } 324 325 lazy.logger.trace( 326 lazy.truncate`[${data.browsingContext.id}] Received event ${data.type} for ${data.documentURI}` 327 ); 328 329 switch (data.type) { 330 case "beforeunload": 331 seenBeforeUnload = true; 332 break; 333 334 case "pagehide": 335 seenUnload = true; 336 break; 337 338 case "hashchange": 339 case "popstate": 340 checkDone({ finished: true }); 341 break; 342 343 case "DOMContentLoaded": 344 case "pageshow": { 345 // Don't require an unload event when a top-level browsing context 346 // change occurred. 347 // The initial about:blank load has no previous page to unload. 348 if (!seenUnload && !browsingContextChanged && !data.isInitialDocument) { 349 return; 350 } 351 const result = checkReadyState(pageLoadStrategy, data); 352 checkDone(result); 353 break; 354 } 355 } 356 }; 357 358 // In the case when the currently selected frame is closed, 359 // there will be no further load events. Stop listening immediately. 360 const onBrowsingContextDiscarded = (subject, topic, why) => { 361 // If the BrowsingContext is being discarded to be replaced by another 362 // context, we don't want to stop waiting for the pageload to complete, as 363 // we will continue listening to the newly created context. 364 if (subject == browsingContextFn() && why != "replace") { 365 lazy.logger.trace( 366 "Canceled page load listener " + 367 `because browsing context with id ${subject.id} has been removed` 368 ); 369 checkDone({ finished: true }); 370 } 371 }; 372 373 // Detect changes to the top-level browsing context to not 374 // necessarily require an unload event. 375 const onBrowsingContextChanged = event => { 376 if (event.target === driver.curBrowser.contentBrowser) { 377 browsingContextChanged = true; 378 } 379 }; 380 381 const onUnload = () => { 382 lazy.logger.trace( 383 "Canceled page load listener " + 384 "because the top-browsing context has been closed" 385 ); 386 checkDone({ finished: true }); 387 }; 388 389 chromeWindow.addEventListener("TabClose", onUnload); 390 chromeWindow.addEventListener("unload", onUnload); 391 driver.curBrowser.tabBrowser?.addEventListener( 392 "XULFrameLoaderCreated", 393 onBrowsingContextChanged 394 ); 395 driver.promptListener.on("closed", onPromptClosed); 396 driver.promptListener.on("opened", onPromptOpened); 397 Services.obs.addObserver( 398 onBrowsingContextDiscarded, 399 "browsing-context-discarded" 400 ); 401 402 lazy.EventDispatcher.on("page-load", onNavigation); 403 404 return new lazy.TimedPromise( 405 async (resolve, reject) => { 406 rejectNavigation = reject; 407 resolveNavigation = resolve; 408 409 try { 410 await callback(); 411 412 // Certain commands like clickElement can cause a navigation. Setup a timer 413 // to check if a "beforeunload" event has been emitted within the given 414 // time frame. If not resolve the Promise. 415 if ( 416 !requireBeforeUnload && 417 lazy.MarionettePrefs.navigateAfterClickEnabled 418 ) { 419 unloadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 420 unloadTimer.initWithCallback( 421 onTimer, 422 lazy.MarionettePrefs.navigateAfterClickTimeout * 423 lazy.getTimeoutMultiplier(), 424 Ci.nsITimer.TYPE_ONE_SHOT 425 ); 426 } 427 } catch (e) { 428 // Executing the callback above could destroy the actor pair before the 429 // command returns. Such an error has to be ignored. 430 if (e.name !== "AbortError") { 431 checkDone({ finished: true, error: e }); 432 } 433 } 434 }, 435 { 436 errorMessage: "Navigation timed out", 437 timeout: driver.currentSession.timeouts.pageLoad, 438 } 439 ).finally(() => { 440 // Clean-up all registered listeners and timers 441 Services.obs.removeObserver( 442 onBrowsingContextDiscarded, 443 "browsing-context-discarded" 444 ); 445 chromeWindow.removeEventListener("TabClose", onUnload); 446 chromeWindow.removeEventListener("unload", onUnload); 447 driver.curBrowser.tabBrowser?.removeEventListener( 448 "XULFrameLoaderCreated", 449 onBrowsingContextChanged 450 ); 451 driver.promptListener?.off("closed", onPromptClosed); 452 driver.promptListener?.off("opened", onPromptOpened); 453 unloadTimer?.cancel(); 454 455 lazy.EventDispatcher.off("page-load", onNavigation); 456 }); 457 };