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