/* * Copyright 2024 Google LLC. * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { ChromiumBidi, } from '../../../protocol/protocol.js'; import { Deferred } from '../../../utils/Deferred.js'; import { LogType } from '../../../utils/log.js'; import { getTimestamp } from '../../../utils/time.js'; import { urlMatchesAboutBlank } from '../../../utils/UrlHelpers.js'; import { uuidv4 } from '../../../utils/uuid.js'; export class NavigationResult { eventName; message; constructor(eventName, message) { this.eventName = eventName; this.message = message; } } class NavigationState { navigationId = uuidv4(); #browsingContextId; #started = false; #finished = new Deferred(); url; loaderId; #isInitial; #eventManager; #navigated = false; get finished() { return this.#finished; } constructor(url, browsingContextId, isInitial, eventManager) { this.#browsingContextId = browsingContextId; this.url = url; this.#isInitial = isInitial; this.#eventManager = eventManager; } navigationInfo() { return { context: this.#browsingContextId, navigation: this.navigationId, timestamp: getTimestamp(), url: this.url, }; } start() { if (!this.#isInitial && !this.#started) { this.#eventManager.registerEvent({ type: 'event', method: ChromiumBidi.BrowsingContext.EventNames.NavigationStarted, params: this.navigationInfo(), }, this.#browsingContextId); } this.#started = true; } #finish(navigationResult) { this.#started = true; if (!this.#isInitial && !this.#finished.isFinished && navigationResult.eventName !== "browsingContext.load" /* NavigationEventName.Load */) { this.#eventManager.registerEvent({ type: 'event', method: navigationResult.eventName, params: this.navigationInfo(), }, this.#browsingContextId); } this.#finished.resolve(navigationResult); } frameNavigated() { this.#navigated = true; } fragmentNavigated() { this.#navigated = true; this.#finish(new NavigationResult("browsingContext.fragmentNavigated" /* NavigationEventName.FragmentNavigated */)); } load() { this.#finish(new NavigationResult("browsingContext.load" /* NavigationEventName.Load */)); } fail(message) { this.#finish(new NavigationResult(this.#navigated ? "browsingContext.navigationAborted" /* NavigationEventName.NavigationAborted */ : "browsingContext.navigationFailed" /* NavigationEventName.NavigationFailed */, message)); } } /** * Keeps track of navigations. Details: http://go/webdriver:bidi-navigation */ export class NavigationTracker { #eventManager; #logger; #loaderIdToNavigationsMap = new Map(); #browsingContextId; #currentNavigation; // When a new navigation is started via `BrowsingContext.navigate` with `wait` set to // `None`, the command result should have `navigation` value, but mapper does not have // it yet. This value will be set to `navigationId` after next . #pendingNavigation; // Flags if the initial navigation to `about:blank` is in progress. #isInitialNavigation = true; navigation = { withinDocument: new Deferred(), }; constructor(url, browsingContextId, eventManager, logger) { this.#browsingContextId = browsingContextId; this.#eventManager = eventManager; this.#logger = logger; this.#isInitialNavigation = true; this.#currentNavigation = new NavigationState(url, browsingContextId, urlMatchesAboutBlank(url), this.#eventManager); } /** * Returns current started ongoing navigation. It can be either a started pending * navigation, or one is already navigated. */ get currentNavigationId() { if (this.#pendingNavigation?.loaderId !== undefined) { return this.#pendingNavigation.navigationId; } return this.#currentNavigation.navigationId; } /** * Flags if the current navigation relates to the initial to `about:blank` navigation. */ get isInitialNavigation() { return this.#isInitialNavigation; } /** * Url of the last navigated navigation. */ get url() { return this.#currentNavigation.url; } /** * Creates a pending navigation e.g. when navigation command is called. Required to * provide navigation id before the actual navigation is started. It will be used when * navigation started. Can be aborted, failed, fragment navigated, or became a current * navigation. */ createPendingNavigation(url, canBeInitialNavigation = false) { this.#logger?.(LogType.debug, 'createCommandNavigation'); this.#isInitialNavigation = canBeInitialNavigation && this.#isInitialNavigation && urlMatchesAboutBlank(url); this.#pendingNavigation?.fail('navigation canceled by concurrent navigation'); const navigation = new NavigationState(url, this.#browsingContextId, this.#isInitialNavigation, this.#eventManager); this.#pendingNavigation = navigation; return navigation; } dispose() { this.#pendingNavigation?.fail('navigation canceled by context disposal'); this.#currentNavigation.fail('navigation canceled by context disposal'); } // Update the current url. onTargetInfoChanged(url) { this.#logger?.(LogType.debug, `onTargetInfoChanged ${url}`); this.#currentNavigation.url = url; } #getNavigationForFrameNavigated(url, loaderId) { if (this.#loaderIdToNavigationsMap.has(loaderId)) { return this.#loaderIdToNavigationsMap.get(loaderId); } if (this.#pendingNavigation !== undefined && this.#pendingNavigation?.loaderId === undefined) { // This can be a pending navigation to `about:blank` created by a command. Use the // pending navigation in this case. return this.#pendingNavigation; } // Create a new pending navigation. return this.createPendingNavigation(url, true); } /** * @param {string} unreachableUrl indicated the navigation is actually failed. */ frameNavigated(url, loaderId, unreachableUrl) { this.#logger?.(LogType.debug, `frameNavigated ${url}`); if (unreachableUrl !== undefined && !this.#loaderIdToNavigationsMap.has(loaderId)) { // The navigation failed before started. Get or create pending navigation and fail // it. const navigation = this.#pendingNavigation ?? this.createPendingNavigation(unreachableUrl, true); navigation.url = unreachableUrl; navigation.start(); navigation.fail('the requested url is unreachable'); return; } const navigation = this.#getNavigationForFrameNavigated(url, loaderId); navigation.frameNavigated(); if (navigation !== this.#currentNavigation) { this.#currentNavigation.fail('navigation canceled by concurrent navigation'); } navigation.url = url; navigation.loaderId = loaderId; this.#loaderIdToNavigationsMap.set(loaderId, navigation); navigation.start(); this.#currentNavigation = navigation; if (this.#pendingNavigation === navigation) { this.#pendingNavigation = undefined; } } navigatedWithinDocument(url, navigationType) { this.#logger?.(LogType.debug, `navigatedWithinDocument ${url}, ${navigationType}`); // Current navigation URL should be updated. this.#currentNavigation.url = url; if (navigationType !== 'fragment') { // TODO: check for other navigation types, like `javascript`. return; } // There is no way to guaranteed match pending navigation with finished fragment // navigations. So assume any pending navigation without loader id is the fragment // one. const fragmentNavigation = this.#pendingNavigation !== undefined && this.#pendingNavigation.loaderId === undefined ? this.#pendingNavigation : new NavigationState(url, this.#browsingContextId, false, this.#eventManager); // Finish ongoing navigation. fragmentNavigation.fragmentNavigated(); if (fragmentNavigation === this.#pendingNavigation) { this.#pendingNavigation = undefined; } } frameRequestedNavigation(url) { this.#logger?.(LogType.debug, `Page.frameRequestedNavigation ${url}`); // The page is about to navigate to the url. this.createPendingNavigation(url, true); } /** * Required to mark navigation as fully complete. * TODO: navigation should be complete when it became the current one on * `Page.frameNavigated` or on navigating command finished with a new loader Id. */ loadPageEvent(loaderId) { this.#logger?.(LogType.debug, 'loadPageEvent'); // Even if it was an initial navigation, it is finished. this.#isInitialNavigation = false; this.#loaderIdToNavigationsMap.get(loaderId)?.load(); } /** * Fail navigation due to navigation command failed. */ failNavigation(navigation, errorText) { this.#logger?.(LogType.debug, 'failCommandNavigation'); navigation.fail(errorText); } /** * Updates the navigation's `loaderId` and sets it as current one, if it is a * cross-document navigation. */ navigationCommandFinished(navigation, loaderId) { this.#logger?.(LogType.debug, `finishCommandNavigation ${navigation.navigationId}, ${loaderId}`); if (loaderId !== undefined) { navigation.loaderId = loaderId; this.#loaderIdToNavigationsMap.set(loaderId, navigation); } if (loaderId === undefined || this.#currentNavigation === navigation) { // If the command's navigation is same-document or is already the current one, // nothing to do. return; } this.#currentNavigation.fail('navigation canceled by concurrent navigation'); navigation.start(); this.#currentNavigation = navigation; if (this.#pendingNavigation === navigation) { this.#pendingNavigation = undefined; } } /** * Emulated event, tight to `Network.requestWillBeSent`. */ frameStartedNavigating(url, loaderId) { this.#logger?.(LogType.debug, `frameStartedNavigating ${url}, ${loaderId}`); if (this.#loaderIdToNavigationsMap.has(loaderId)) { // The `frameStartedNavigating` is tight to the `Network.requestWillBeSent` event // which can be emitted several times, e.g. in case of redirection. Nothing to do in // such a case. return; } const pendingNavigation = this.#pendingNavigation ?? this.createPendingNavigation(url, true); pendingNavigation.url = url; pendingNavigation.start(); pendingNavigation.loaderId = loaderId; this.#loaderIdToNavigationsMap.set(loaderId, pendingNavigation); } /** * In case of `beforeunload` handler, the pending navigation should be marked as started * for consistency, as the `browsingContext.navigationStarted` should be emitted before * user prompt. */ beforeunload() { this.#logger?.(LogType.debug, `beforeunload`); if (this.#pendingNavigation === undefined) { this.#logger?.(LogType.debugError, `Unexpectedly no pending navigation on beforeunload`); return; } this.#pendingNavigation.start(); } /** * If there is a navigation with the loaderId equals to the network request id, it means * that the navigation failed. */ networkLoadingFailed(loaderId, errorText) { this.#loaderIdToNavigationsMap.get(loaderId)?.fail(errorText); } } //# sourceMappingURL=NavigationTracker.js.map