diff --git a/java/src/org/openqa/selenium/bidi/browsingcontext/BrowsingContext.java b/java/src/org/openqa/selenium/bidi/browsingcontext/BrowsingContext.java index 4b7002d7f1c2d..758346e6e93e5 100644 --- a/java/src/org/openqa/selenium/bidi/browsingcontext/BrowsingContext.java +++ b/java/src/org/openqa/selenium/bidi/browsingcontext/BrowsingContext.java @@ -200,6 +200,13 @@ public NavigationResult reload(ReadinessState readinessState) { RELOAD, Map.of(CONTEXT, id, "wait", readinessState.toString()), navigationInfoMapper)); } + public NavigationResult reload(ReadinessState readinessState, Duration timeout) { + return this.bidi.send( + new Command<>( + RELOAD, Map.of(CONTEXT, id, "wait", readinessState.toString()), navigationInfoMapper), + timeout); + } + // Yet to be implemented by browser vendors private NavigationResult reload(boolean ignoreCache, ReadinessState readinessState) { return this.bidi.send( diff --git a/java/src/org/openqa/selenium/remote/BUILD.bazel b/java/src/org/openqa/selenium/remote/BUILD.bazel index 71bfde2e5d0b5..4fc796ddcb3d7 100644 --- a/java/src/org/openqa/selenium/remote/BUILD.bazel +++ b/java/src/org/openqa/selenium/remote/BUILD.bazel @@ -63,6 +63,7 @@ java_library( deps = [ "//java/src/org/openqa/selenium:core", "//java/src/org/openqa/selenium/bidi", + "//java/src/org/openqa/selenium/bidi/browsingcontext", "//java/src/org/openqa/selenium/bidi/log", "//java/src/org/openqa/selenium/bidi/module", "//java/src/org/openqa/selenium/bidi/network", diff --git a/java/src/org/openqa/selenium/remote/RemoteWebDriver.java b/java/src/org/openqa/selenium/remote/RemoteWebDriver.java index 30d8f000adcee..fcae15ab20021 100644 --- a/java/src/org/openqa/selenium/remote/RemoteWebDriver.java +++ b/java/src/org/openqa/selenium/remote/RemoteWebDriver.java @@ -26,6 +26,7 @@ import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.StringReader; import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Files; @@ -41,6 +42,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiFunction; import java.util.logging.Level; import java.util.logging.Logger; @@ -65,6 +67,7 @@ import org.openqa.selenium.NoSuchFrameException; import org.openqa.selenium.NoSuchWindowException; import org.openqa.selenium.OutputType; +import org.openqa.selenium.PageLoadStrategy; import org.openqa.selenium.Pdf; import org.openqa.selenium.Platform; import org.openqa.selenium.Point; @@ -72,12 +75,19 @@ import org.openqa.selenium.SearchContext; import org.openqa.selenium.SessionNotCreatedException; import org.openqa.selenium.TakesScreenshot; +import org.openqa.selenium.UnexpectedAlertBehaviour; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebDriverException; import org.openqa.selenium.WebElement; import org.openqa.selenium.WindowType; import org.openqa.selenium.bidi.BiDi; +import org.openqa.selenium.bidi.Event; import org.openqa.selenium.bidi.HasBiDi; +import org.openqa.selenium.bidi.browsingcontext.BrowsingContext; +import org.openqa.selenium.bidi.browsingcontext.ReadinessState; +import org.openqa.selenium.bidi.browsingcontext.UserPromptOpened; +import org.openqa.selenium.json.Json; +import org.openqa.selenium.json.JsonInput; import org.openqa.selenium.devtools.DevTools; import org.openqa.selenium.devtools.HasDevTools; import org.openqa.selenium.federatedcredentialmanagement.FederatedCredentialManagementDialog; @@ -141,6 +151,18 @@ public class RemoteWebDriver private Logs remoteLogs; + // Cached page-load timeout used by BiDi navigation. Null until set by the user via + // pageLoadTimeout() or lazily populated from the session's GET_TIMEOUTS response. + private volatile Duration biDiPageLoadTimeout = null; + + // Set to true once the one-time browsingContext.userPromptOpened listener is installed. + // Sending session.subscribe once is sufficient; subsequent navigations reuse it. + private final AtomicBoolean biDiPromptListenerInstalled = new AtomicBoolean(false); + + // Non-null only while a BiDi navigation is in progress. The prompt handler uses this to + // ignore events that arrive outside of a navigation (e.g., user-triggered alerts). + private volatile String biDiNavigatingContextId = null; + @SuppressWarnings("deprecation") private LocalLogs localLogs; @@ -371,7 +393,125 @@ public Capabilities getCapabilities() { @Override public void get(String url) { - execute(DriverCommand.GET(url)); + if (isBiDiEnabled()) { + String contextId = getWindowHandle(); + ReadinessState readiness = getReadinessState(); + Duration timeout = getPageLoadDuration(); + navigateViaBiDi( + contextId, + () -> new BrowsingContext(this, contextId).navigate(url, readiness, timeout)); + } else { + execute(DriverCommand.GET(url)); + } + } + + // BiDi is active when the driver implements HasBiDi and the session returned a WebSocket URL + // (a String), not just the boolean request capability that was sent at session creation. + private boolean isBiDiEnabled() { + return this instanceof HasBiDi + && getCapabilities().getCapability("webSocketUrl") instanceof String; + } + + private ReadinessState getReadinessState() { + Object raw = getCapabilities().getCapability(CapabilityType.PAGE_LOAD_STRATEGY); + // The capability may be a PageLoadStrategy enum (set locally) or a String (deserialized from + // JSON), so normalise to the enum via toString() before comparing. + PageLoadStrategy strategy = + raw instanceof PageLoadStrategy + ? (PageLoadStrategy) raw + : PageLoadStrategy.fromString(raw == null ? null : raw.toString()); + if (PageLoadStrategy.EAGER.equals(strategy)) { + return ReadinessState.INTERACTIVE; + } else if (PageLoadStrategy.NONE.equals(strategy)) { + return ReadinessState.NONE; + } + return ReadinessState.COMPLETE; + } + + // Returns the effective page load timeout for BiDi navigation commands. The value is cached so + // repeated navigations don't incur an extra HTTP round-trip to GET_TIMEOUTS. + private Duration getPageLoadDuration() { + if (biDiPageLoadTimeout == null) { + synchronized (this) { + if (biDiPageLoadTimeout == null) { + biDiPageLoadTimeout = manage().timeouts().getPageLoadTimeout(); + } + } + } + return biDiPageLoadTimeout; + } + + private static final Json BIDI_JSON = new Json(); + + // Shared event definition for browsingContext.userPromptOpened used during navigation. + private static final Event USER_PROMPT_OPENED_EVENT = + new Event<>( + "browsingContext.userPromptOpened", + params -> { + try (StringReader reader = new StringReader(BIDI_JSON.toJson(params)); + JsonInput input = BIDI_JSON.newInput(reader)) { + return input.readNonNull(UserPromptOpened.class); + } + }); + + private UnexpectedAlertBehaviour getUnhandledPromptBehaviour() { + Object raw = getCapabilities().getCapability(CapabilityType.UNHANDLED_PROMPT_BEHAVIOUR); + if (raw instanceof UnexpectedAlertBehaviour) { + return (UnexpectedAlertBehaviour) raw; + } + if (raw instanceof String) { + return UnexpectedAlertBehaviour.fromString((String) raw); + } + // W3C WebDriver spec default is "dismiss and notify" + return UnexpectedAlertBehaviour.DISMISS_AND_NOTIFY; + } + + // Installs a single session-scoped browsingContext.userPromptOpened listener the first time + // BiDi navigation is used. The listener only acts while biDiNavigatingContextId is set, + // so it has no effect on user-triggered alerts outside of navigation. + private void ensureBiDiPromptListener() { + if (biDiPromptListenerInstalled.compareAndSet(false, true)) { + ((HasBiDi) this) + .getBiDi() + .addListener( + USER_PROMPT_OPENED_EVENT, + prompt -> { + String contextId = biDiNavigatingContextId; + if (contextId == null || !contextId.equals(prompt.getBrowsingContextId())) { + return; + } + UnexpectedAlertBehaviour behaviour = getUnhandledPromptBehaviour(); + if (behaviour == UnexpectedAlertBehaviour.IGNORE) { + return; + } + boolean accept = + behaviour == UnexpectedAlertBehaviour.ACCEPT + || behaviour == UnexpectedAlertBehaviour.ACCEPT_AND_NOTIFY; + LOG.fine( + () -> + String.format( + "Handling %s user prompt during BiDi navigation (%s)", + prompt.getType(), accept ? "accept" : "dismiss")); + new BrowsingContext(this, contextId).handleUserPrompt(accept); + }); + } + } + + // Wraps a BiDi navigation call with prompt handling that replicates, for BiDi, the automatic + // unhandledPromptBehavior that classic WebDriver delegates to the browser. + private void navigateViaBiDi(String contextId, Runnable navigation) { + ensureBiDiPromptListener(); + UnexpectedAlertBehaviour behaviour = getUnhandledPromptBehaviour(); + if (behaviour == UnexpectedAlertBehaviour.IGNORE) { + navigation.run(); + return; + } + biDiNavigatingContextId = contextId; + try { + navigation.run(); + } finally { + biDiNavigatingContextId = null; + } } @Override @@ -1145,6 +1285,7 @@ public Duration getScriptTimeout() { @Override public Timeouts pageLoadTimeout(Duration duration) { execute(DriverCommand.SET_PAGE_LOAD_TIMEOUT(duration)); + biDiPageLoadTimeout = duration; return this; } @@ -1218,12 +1359,24 @@ private class RemoteNavigation implements Navigation { @Override public void back() { - execute(DriverCommand.GO_BACK); + if (isBiDiEnabled()) { + String contextId = getWindowHandle(); + navigateViaBiDi( + contextId, () -> new BrowsingContext(RemoteWebDriver.this, contextId).back()); + } else { + execute(DriverCommand.GO_BACK); + } } @Override public void forward() { - execute(DriverCommand.GO_FORWARD); + if (isBiDiEnabled()) { + String contextId = getWindowHandle(); + navigateViaBiDi( + contextId, () -> new BrowsingContext(RemoteWebDriver.this, contextId).forward()); + } else { + execute(DriverCommand.GO_FORWARD); + } } @Override @@ -1238,7 +1391,18 @@ public void to(URL url) { @Override public void refresh() { - execute(DriverCommand.REFRESH); + if (isBiDiEnabled()) { + String contextId = getWindowHandle(); + ReadinessState readiness = getReadinessState(); + Duration timeout = getPageLoadDuration(); + navigateViaBiDi( + contextId, + () -> + new BrowsingContext(RemoteWebDriver.this, contextId) + .reload(readiness, timeout)); + } else { + execute(DriverCommand.REFRESH); + } } } diff --git a/java/test/org/openqa/selenium/bidi/browsingcontext/BiDiNavigationTest.java b/java/test/org/openqa/selenium/bidi/browsingcontext/BiDiNavigationTest.java new file mode 100644 index 0000000000000..fc1febe132db1 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/browsingcontext/BiDiNavigationTest.java @@ -0,0 +1,113 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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. + +package org.openqa.selenium.bidi.browsingcontext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openqa.selenium.support.ui.ExpectedConditions.titleIs; +import static org.openqa.selenium.support.ui.ExpectedConditions.visibilityOfElementLocated; +import static org.openqa.selenium.testing.drivers.Browser.EDGE; + +import java.net.MalformedURLException; +import java.net.URL; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.testing.JupiterTestBase; +import org.openqa.selenium.testing.NeedsFreshDriver; +import org.openqa.selenium.testing.NotYetImplemented; + +class BiDiNavigationTest extends JupiterTestBase { + + @Test + @NeedsFreshDriver + void driverGetNavigatesToUrlViaBiDi() { + String url = appServer.whereIs("formPage.html"); + driver.get(url); + assertThat(driver.getCurrentUrl()).contains("formPage.html"); + assertThat(driver.getTitle()).isEqualTo("We Leave From Here"); + } + + @Test + @NeedsFreshDriver + void driverGetNavigatesToSecondUrlViaBiDi() { + driver.get(pages.formPage); + String url = appServer.whereIs("simpleTest.html"); + driver.get(url); + assertThat(driver.getCurrentUrl()).contains("simpleTest.html"); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(EDGE) + void navigateToStringUrlViaNavigationTo() { + String url = appServer.whereIs("formPage.html"); + driver.navigate().to(url); + assertThat(driver.getCurrentUrl()).contains("formPage.html"); + assertThat(driver.getTitle()).isEqualTo("We Leave From Here"); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(EDGE) + void navigateToUrlObjectViaNavigationTo() throws MalformedURLException { + URL url = new URL(appServer.whereIs("formPage.html")); + driver.navigate().to(url); + assertThat(driver.getCurrentUrl()).contains("formPage.html"); + assertThat(driver.getTitle()).isEqualTo("We Leave From Here"); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(EDGE) + void navigateBackTraversesHistory() { + driver.get(pages.formPage); + wait.until(visibilityOfElementLocated(By.id("imageButton"))).submit(); + wait.until(titleIs("We Arrive Here")); + + driver.navigate().back(); + wait.until(titleIs("We Leave From Here")); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(EDGE) + void navigateForwardTraversesHistory() { + driver.get(pages.formPage); + wait.until(visibilityOfElementLocated(By.id("imageButton"))).submit(); + wait.until(titleIs("We Arrive Here")); + + driver.navigate().back(); + wait.until(titleIs("We Leave From Here")); + + driver.navigate().forward(); + wait.until(titleIs("We Arrive Here")); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(EDGE) + void refreshReloadsCurrentPage() { + String url = appServer.whereIs("formPage.html"); + driver.get(url); + assertThat(driver.getTitle()).isEqualTo("We Leave From Here"); + + driver.navigate().refresh(); + + assertThat(driver.getCurrentUrl()).contains("formPage.html"); + assertThat(driver.getTitle()).isEqualTo("We Leave From Here"); + } +}