diff --git a/java/src/org/openqa/selenium/bidi/module/BUILD.bazel b/java/src/org/openqa/selenium/bidi/module/BUILD.bazel index 652d563957a45..9dfa606989a4a 100644 --- a/java/src/org/openqa/selenium/bidi/module/BUILD.bazel +++ b/java/src/org/openqa/selenium/bidi/module/BUILD.bazel @@ -25,6 +25,7 @@ java_library( "//java/src/org/openqa/selenium/bidi/network", "//java/src/org/openqa/selenium/bidi/permissions", "//java/src/org/openqa/selenium/bidi/script", + "//java/src/org/openqa/selenium/bidi/speculation", "//java/src/org/openqa/selenium/bidi/storage", "//java/src/org/openqa/selenium/bidi/webextension", "//java/src/org/openqa/selenium/json", diff --git a/java/src/org/openqa/selenium/bidi/module/SpeculationInspector.java b/java/src/org/openqa/selenium/bidi/module/SpeculationInspector.java new file mode 100644 index 0000000000000..30b0ea059bde0 --- /dev/null +++ b/java/src/org/openqa/selenium/bidi/module/SpeculationInspector.java @@ -0,0 +1,76 @@ +// 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.module; + +import static java.util.Collections.emptySet; + +import java.util.Collections; +import java.util.Set; +import java.util.function.Consumer; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.bidi.BiDi; +import org.openqa.selenium.bidi.Event; +import org.openqa.selenium.bidi.HasBiDi; +import org.openqa.selenium.bidi.speculation.PrefetchStatusUpdatedParameters; +import org.openqa.selenium.bidi.speculation.Speculation; +import org.openqa.selenium.internal.Require; + +public class SpeculationInspector implements AutoCloseable { + private final Event prefetchStatusUpdatedEvent; + private final Set browsingContextIds; + + private final BiDi bidi; + + public SpeculationInspector(WebDriver driver) { + this(emptySet(), driver); + } + + public SpeculationInspector(String browsingContextId, WebDriver driver) { + this(Collections.singleton(Require.nonNull("Browsing context id", browsingContextId)), driver); + } + + public SpeculationInspector(Set browsingContextIds, WebDriver driver) { + Require.nonNull("WebDriver", driver); + Require.nonNull("Browsing context id list", browsingContextIds); + + if (!(driver instanceof HasBiDi)) { + throw new IllegalArgumentException("WebDriver instance must support BiDi protocol"); + } + + this.bidi = ((HasBiDi) driver).getBiDi(); + this.browsingContextIds = browsingContextIds; + this.prefetchStatusUpdatedEvent = Speculation.prefetchStatusUpdated(); + } + + public long onPrefetchStatusUpdated(Consumer consumer) { + if (browsingContextIds.isEmpty()) { + return this.bidi.addListener(this.prefetchStatusUpdatedEvent, consumer); + } else { + return this.bidi.addListener(browsingContextIds, this.prefetchStatusUpdatedEvent, consumer); + } + } + + public void removeListener(long subscriptionId) { + this.bidi.removeListener(subscriptionId); + } + + @Override + public void close() { + this.bidi.clearListener(Speculation.prefetchStatusUpdated()); + } +} diff --git a/java/src/org/openqa/selenium/bidi/speculation/BUILD.bazel b/java/src/org/openqa/selenium/bidi/speculation/BUILD.bazel new file mode 100644 index 0000000000000..2b94ce85bafff --- /dev/null +++ b/java/src/org/openqa/selenium/bidi/speculation/BUILD.bazel @@ -0,0 +1,24 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "java_library") + +java_library( + name = "speculation", + srcs = glob( + [ + "*.java", + ], + ), + visibility = [ + "//java/src/org/openqa/selenium/bidi:__subpackages__", + "//java/src/org/openqa/selenium/remote:__pkg__", + "//java/test/org/openqa/selenium/bidi:__subpackages__", + "//java/test/org/openqa/selenium/grid:__subpackages__", + ], + deps = [ + "//java/src/org/openqa/selenium:core", + "//java/src/org/openqa/selenium/bidi", + "//java/src/org/openqa/selenium/json", + "//java/src/org/openqa/selenium/remote/http", + artifact("org.jspecify:jspecify"), + ], +) diff --git a/java/src/org/openqa/selenium/bidi/speculation/PrefetchStatusUpdatedParameters.java b/java/src/org/openqa/selenium/bidi/speculation/PrefetchStatusUpdatedParameters.java new file mode 100644 index 0000000000000..357bead447868 --- /dev/null +++ b/java/src/org/openqa/selenium/bidi/speculation/PrefetchStatusUpdatedParameters.java @@ -0,0 +1,54 @@ +// 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.speculation; + +import java.util.Map; + +public class PrefetchStatusUpdatedParameters { + + private final String context; + private final String url; + private final PreloadingStatus status; + + private PrefetchStatusUpdatedParameters(String context, String url, PreloadingStatus status) { + this.context = context; + this.url = url; + this.status = status; + } + + public static PrefetchStatusUpdatedParameters fromJson(Map params) { + String context = (String) params.get("context"); + String url = (String) params.get("url"); + String statusStr = (String) params.get("status"); + PreloadingStatus status = PreloadingStatus.fromString(statusStr); + + return new PrefetchStatusUpdatedParameters(context, url, status); + } + + public String getContext() { + return context; + } + + public String getUrl() { + return url; + } + + public PreloadingStatus getStatus() { + return status; + } +} diff --git a/java/src/org/openqa/selenium/bidi/speculation/PreloadingStatus.java b/java/src/org/openqa/selenium/bidi/speculation/PreloadingStatus.java new file mode 100644 index 0000000000000..334ed7f58b476 --- /dev/null +++ b/java/src/org/openqa/selenium/bidi/speculation/PreloadingStatus.java @@ -0,0 +1,45 @@ +// 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.speculation; + +public enum PreloadingStatus { + PENDING("pending"), + READY("ready"), + SUCCESS("success"), + FAILURE("failure"); + + private final String status; + + PreloadingStatus(String status) { + this.status = status; + } + + @Override + public String toString() { + return status; + } + + public static PreloadingStatus fromString(String status) { + for (PreloadingStatus s : PreloadingStatus.values()) { + if (s.status.equalsIgnoreCase(status)) { + return s; + } + } + throw new IllegalArgumentException("Unknown preloading status: " + status); + } +} diff --git a/java/src/org/openqa/selenium/bidi/speculation/Speculation.java b/java/src/org/openqa/selenium/bidi/speculation/Speculation.java new file mode 100644 index 0000000000000..b4b79e4049940 --- /dev/null +++ b/java/src/org/openqa/selenium/bidi/speculation/Speculation.java @@ -0,0 +1,28 @@ +// 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.speculation; + +import org.openqa.selenium.bidi.Event; + +public class Speculation { + public static Event prefetchStatusUpdated() { + return new Event<>( + "speculation.prefetchStatusUpdated", + params -> PrefetchStatusUpdatedParameters.fromJson(params)); + } +} diff --git a/java/src/org/openqa/selenium/bidi/speculation/package-info.java b/java/src/org/openqa/selenium/bidi/speculation/package-info.java new file mode 100644 index 0000000000000..a613fd23f8b90 --- /dev/null +++ b/java/src/org/openqa/selenium/bidi/speculation/package-info.java @@ -0,0 +1,21 @@ +// 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. + +@NullMarked +package org.openqa.selenium.bidi.speculation; + +import org.jspecify.annotations.NullMarked; diff --git a/java/test/org/openqa/selenium/bidi/speculation/BUILD.bazel b/java/test/org/openqa/selenium/bidi/speculation/BUILD.bazel new file mode 100644 index 0000000000000..5d579439ef43a --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/speculation/BUILD.bazel @@ -0,0 +1,31 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "BIDI_BROWSERS", "JUNIT5_DEPS", "java_selenium_test_suite") + +java_selenium_test_suite( + name = "large-tests", + size = "large", + srcs = glob(["*Test.java"]), + browsers = BIDI_BROWSERS, + tags = [ + "selenium-remote", + ], + deps = [ + "//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/script", + "//java/src/org/openqa/selenium/bidi/speculation", + "//java/src/org/openqa/selenium/firefox", + "//java/src/org/openqa/selenium/grid/security", + "//java/src/org/openqa/selenium/json", + "//java/src/org/openqa/selenium/remote", + "//java/src/org/openqa/selenium/support", + "//java/test/org/openqa/selenium/environment", + "//java/test/org/openqa/selenium/testing:annotations", + "//java/test/org/openqa/selenium/testing:test-base", + "//java/test/org/openqa/selenium/testing/drivers", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/speculation/SpeculationInspectorTest.java b/java/test/org/openqa/selenium/bidi/speculation/SpeculationInspectorTest.java new file mode 100644 index 0000000000000..459a3e058bbe3 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/speculation/SpeculationInspectorTest.java @@ -0,0 +1,273 @@ +// 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.speculation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openqa.selenium.testing.drivers.Browser.FIREFOX; +import static org.openqa.selenium.testing.drivers.Browser.SAFARI; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.bidi.module.Script; +import org.openqa.selenium.bidi.module.SpeculationInspector; +import org.openqa.selenium.testing.JupiterTestBase; +import org.openqa.selenium.testing.NeedsFreshDriver; +import org.openqa.selenium.testing.NotYetImplemented; + +class SpeculationInspectorTest extends JupiterTestBase { + + private Script script; + private SpeculationInspector speculationInspector; + + @BeforeEach + public void setUp() { + script = new Script(driver); + speculationInspector = new SpeculationInspector(driver); + } + + @AfterEach + public void cleanUp() { + if (speculationInspector != null) { + speculationInspector.close(); + } + if (script != null) { + script.close(); + } + } + + void addSpeculationRulesAndLink(String rules, String href, String linkText, String linkId) { + String functionDeclaration = + String.format( + "() => {" + + "const script = document.createElement('script');" + + "script.type = 'speculationrules';" + + "script.textContent = `%s`;" + + "document.head.appendChild(script);" + + "const link = document.createElement('a');" + + "link.href = '%s';" + + "link.textContent = '%s';" + + "link.id = '%s';" + + "document.body.appendChild(link);" + + "}", + rules, href, linkText, linkId); + + script.callFunctionInBrowsingContext( + driver.getWindowHandle(), + functionDeclaration, + false, + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(FIREFOX) + @NotYetImplemented(SAFARI) + void canListenToPrefetchStatusUpdatedWithPendingAndReadyEvents() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + + speculationInspector.onPrefetchStatusUpdated( + event -> { + events.add(event); + latch.countDown(); + }); + + String testUrl = appServer.whereIs("/common/blank.html"); + driver.get(testUrl); + + String prefetchTarget = appServer.whereIs("/common/dummy.xml"); + String speculationRules = + String.format( + "{\"prefetch\": [{\"source\": \"list\", \"urls\": [\"%s\"]}]}", prefetchTarget); + + addSpeculationRulesAndLink(speculationRules, prefetchTarget, "Test Link", "prefetch-page"); + + // Wait for at least one prefetch event + latch.await(5, TimeUnit.SECONDS); + + // Verify we got at least one event + assertThat(events).hasSizeGreaterThanOrEqualTo(1); + + PrefetchStatusUpdatedParameters firstEvent = events.get(0); + assertThat(firstEvent.getUrl()).isEqualTo(prefetchTarget); + assertThat(firstEvent.getContext()).isEqualTo(driver.getWindowHandle()); + assertThat(firstEvent.getStatus()).isNotNull(); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(FIREFOX) + @NotYetImplemented(SAFARI) + void canListenToPrefetchStatusUpdatedWithNavigationAndSuccess() + throws ExecutionException, InterruptedException, TimeoutException { + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + + speculationInspector.onPrefetchStatusUpdated( + event -> { + events.add(event); + latch.countDown(); + }); + + String testUrl = appServer.whereIs("/common/blank.html"); + driver.get(testUrl); + + String prefetchTarget = appServer.whereIs("/common/dummy.xml"); + String speculationRules = + String.format( + "{\"prefetch\": [{\"source\": \"list\", \"urls\": [\"%s\"]}]}", prefetchTarget); + + addSpeculationRulesAndLink(speculationRules, prefetchTarget, "Test Link", "prefetch-page"); + + // Wait for prefetch event + latch.await(5, TimeUnit.SECONDS); + + assertThat(events).hasSizeGreaterThanOrEqualTo(1); + + // Verify first event + assertThat(events.get(0).getUrl()).isEqualTo(prefetchTarget); + assertThat(events.get(0).getContext()).isEqualTo(driver.getWindowHandle()); + + // If prefetch succeeded (status is READY), proceed with success test; otherwise skip + if (events.stream().noneMatch(e -> e.getStatus() == PreloadingStatus.READY)) { + // Prefetch didn't succeed, likely due to Chrome's restrictions + return; + } + + // Set up for success event + CompletableFuture successFuture = new CompletableFuture<>(); + speculationInspector.onPrefetchStatusUpdated( + event -> { + if (event.getStatus() == PreloadingStatus.SUCCESS) { + successFuture.complete(event); + } + }); + + // Navigate to the prefetched page by clicking the link + script.callFunctionInBrowsingContext( + driver.getWindowHandle(), + "() => { const link = document.getElementById('prefetch-page'); if (link) { link.click(); }" + + " }", + false, + Optional.empty(), + Optional.empty(), + Optional.empty()); + + // Wait for success event + PrefetchStatusUpdatedParameters successEvent = successFuture.get(5, TimeUnit.SECONDS); + assertThat(successEvent.getUrl()).isEqualTo(prefetchTarget); + assertThat(successEvent.getStatus()).isEqualTo(PreloadingStatus.SUCCESS); + assertThat(successEvent.getContext()).isEqualTo(driver.getWindowHandle()); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(FIREFOX) + @NotYetImplemented(SAFARI) + void canListenToPrefetchStatusUpdatedWithFailureEvents() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + + speculationInspector.onPrefetchStatusUpdated( + event -> { + events.add(event); + latch.countDown(); + }); + + String testUrl = appServer.whereIs("/common/blank.html"); + driver.get(testUrl); + + // Use a non-existent path that will return 404 + String failedTarget = appServer.whereIs("/nonexistent/path/that/will/404.xml"); + String speculationRules = + String.format("{\"prefetch\": [{\"source\": \"list\", \"urls\": [\"%s\"]}]}", failedTarget); + + addSpeculationRulesAndLink(speculationRules, failedTarget, "Test Link", "prefetch-page"); + + // Wait for event + latch.await(5, TimeUnit.SECONDS); + + // Verify we got at least one event + assertThat(events).hasSizeGreaterThanOrEqualTo(1); + + PrefetchStatusUpdatedParameters firstEvent = events.get(0); + assertThat(firstEvent.getUrl()).isEqualTo(failedTarget); + assertThat(firstEvent.getContext()).isEqualTo(driver.getWindowHandle()); + // Verify status is either PENDING or FAILURE + assertThat(firstEvent.getStatus()).isIn(PreloadingStatus.PENDING, PreloadingStatus.FAILURE); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(FIREFOX) + @NotYetImplemented(SAFARI) + void canUnsubscribeFromPrefetchStatusUpdated() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + + long subscriptionId = + speculationInspector.onPrefetchStatusUpdated( + event -> { + events.add(event); + latch.countDown(); + }); + + String testUrl = appServer.whereIs("/common/blank.html"); + driver.get(testUrl); + + String prefetchTarget = appServer.whereIs("/common/dummy.xml"); + String speculationRules = + String.format( + "{\"prefetch\": [{\"source\": \"list\", \"urls\": [\"%s\"]}]}", prefetchTarget); + + addSpeculationRulesAndLink(speculationRules, prefetchTarget, "Test Link", "prefetch-page"); + + // Wait for events to be emitted + latch.await(5, TimeUnit.SECONDS); + assertThat(events).hasSizeGreaterThanOrEqualTo(1); + + // Unsubscribe + speculationInspector.removeListener(subscriptionId); + + // Clear events and reload + events.clear(); + driver.get(testUrl); + + String prefetchTarget2 = appServer.whereIs("/common/square.png"); + String speculationRules2 = + String.format( + "{\"prefetch\": [{\"source\": \"list\", \"urls\": [\"%s\"]}]}", prefetchTarget2); + + addSpeculationRulesAndLink( + speculationRules2, prefetchTarget2, "Test Link 2", "prefetch-page-2"); + + // Verify no events are emitted after unsubscribing + assertThat(events).isEmpty(); + } +}