diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/ChannelOwner.java b/playwright/src/main/java/com/microsoft/playwright/impl/ChannelOwner.java index 1b3ea1d8..7ecfc11c 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/ChannelOwner.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/ChannelOwner.java @@ -85,6 +85,15 @@ class ChannelOwner { return result; } + Deferred toDeferred(Waitable waitable) { + return () -> { + while (!waitable.isDone()) { + connection.processOneMessage(); + } + return (T) waitable.get(); + }; + } + T waitForCompletion(CompletableFuture future) { while (!future.isDone()) { connection.processOneMessage(); diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/FrameImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/FrameImpl.java index 699c718e..08c58cd3 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/FrameImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/FrameImpl.java @@ -28,6 +28,7 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; import static com.microsoft.playwright.Frame.LoadState.*; import static com.microsoft.playwright.impl.Serialization.deserialize; @@ -40,7 +41,8 @@ public class FrameImpl extends ChannelOwner implements Frame { FrameImpl parentFrame; Set childFrames = new LinkedHashSet<>(); private final Set loadStates = new HashSet<>(); - private final List eventHelpers = new ArrayList<>(); + enum InternalEventType { NAVIGATED, LOADSTATE }; + private final ListenerCollection internalListeners = new ListenerCollection<>(); PageImpl page; boolean isDetached; @@ -438,71 +440,118 @@ public class FrameImpl extends ChannelOwner implements Frame { if (state == null) { state = LOAD; } - while (!loadStates.contains(state)) { - // TODO: support timeout! + WaitForLoadStateHelper helper = new WaitForLoadStateHelper(state); + while (!helper.isDone()) { connection.processOneMessage(); } } - enum State { WAITING_FOR_NAVIGATION, WAITING_FOR_LOAD_STATE, DONE }; + private class WaitForLoadStateHelper implements Waitable, Listener { + private final LoadState expectedState; + private boolean isDone; - // TODO: switch to listeners or something else less convoluted. - private class WaitForNavigationHelper implements Deferred { - private final CompletableFuture result = new CompletableFuture<>(); - private final UrlMatcher matcher; - private final LoadState loadState; - private State state = State.WAITING_FOR_NAVIGATION; - private RequestImpl request; - - WaitForNavigationHelper(UrlMatcher matcher, LoadState loadState) { - this.matcher = matcher; - this.loadState = loadState; - eventHelpers.add(this); + WaitForLoadStateHelper(LoadState state) { + expectedState = state; + isDone = loadStates.contains(state); + if (!isDone) { + internalListeners.add(InternalEventType.LOADSTATE, this); + } } - void handleEvent(String name, JsonObject params) { - if (state == State.WAITING_FOR_NAVIGATION) { - if (!"navigated".equals(name)) { - return; - } - if (!matcher.test(params.get("url").getAsString())) { - return; - } - if (params.has("error")) { - result.completeExceptionally(new RuntimeException(params.get("error").getAsString())); - state = State.DONE; - } else { - if (params.has("newDocument")) { - JsonObject jsonReq = params.getAsJsonObject("newDocument").getAsJsonObject("request"); - if (jsonReq != null) { - request = connection.getExistingObject(jsonReq.get("guid").getAsString()); - } - } - state = State.WAITING_FOR_LOAD_STATE; - } + @Override + public void handle(Event event) { + assert event.type() == InternalEventType.LOADSTATE; + if (expectedState.equals(event.data())) { + isDone = true; + dispose(); } - if (state == State.WAITING_FOR_LOAD_STATE) { - if (loadStates.contains(loadState)) { - state = State.DONE; - if (request == null) { - result.complete(null); - } else { - result.complete(request.finalRequest().response()); - } - } else { - return; - } + } + + public void dispose() { + internalListeners.remove(InternalEventType.LOADSTATE, this); + } + + public boolean isDone() { + return isDone; + } + + @Override + public Object get() { + return null; + } + } + + private class WaitForNavigationHelper implements Waitable, Listener { + private final UrlMatcher matcher; + private final LoadState expectedLoadState; + private WaitForLoadStateHelper loadStateHelper; + + private RequestImpl request; + private RuntimeException exception; + + WaitForNavigationHelper(UrlMatcher matcher, LoadState expectedLoadState) { + this.matcher = matcher; + this.expectedLoadState = expectedLoadState; + internalListeners.add(InternalEventType.NAVIGATED, this); + } + + @Override + public void handle(Event event) { + assert InternalEventType.NAVIGATED == event.type(); + JsonObject params = (JsonObject) event.data(); + if (!matcher.test(params.get("url").getAsString())) { + return; } - eventHelpers.remove(this); + if (params.has("error")) { + exception = new RuntimeException(params.get("error").getAsString()); + } else { + if (params.has("newDocument")) { + JsonObject jsonReq = params.getAsJsonObject("newDocument").getAsJsonObject("request"); + if (jsonReq != null) { + request = connection.getExistingObject(jsonReq.get("guid").getAsString()); + } + } + loadStateHelper = new WaitForLoadStateHelper(expectedLoadState); + } + internalListeners.remove(InternalEventType.NAVIGATED, this); + } + + @Override + public void dispose() { + internalListeners.remove(InternalEventType.NAVIGATED, this); + if (loadStateHelper != null) { + loadStateHelper.dispose(); + } + } + + @Override + public boolean isDone() { + if (exception != null) { + return true; + } + if (loadStateHelper != null) { + return loadStateHelper.isDone(); + } + return false; } @Override public Response get() { - return waitForCompletion(result); + while (!isDone()) { + connection.processOneMessage(); + } + + if (exception != null) { + throw exception; + } + + if (request == null) { + return null; + } + return request.finalRequest().response(); } } - @Override public Deferred waitForNavigation(WaitForNavigationOptions options) { if (options == null) { @@ -510,7 +559,20 @@ public class FrameImpl extends ChannelOwner implements Frame { options.url = "**"; options.waitUntil = LOAD; } - return new WaitForNavigationHelper(new UrlMatcher(options.url), options.waitUntil); + if (options.url == null) { + options.url = "**"; + } + if (options.waitUntil == null) { + options.waitUntil = LOAD; + } + + List waitables = new ArrayList<>(); + waitables.add(new WaitForNavigationHelper(new UrlMatcher(options.url), options.waitUntil)); + waitables.add(page.createWaitForCloseHelper()); + if (options.timeout != null) { + waitables.add(new WaitableTimeout(options.timeout.intValue())); + } + return toDeferred(new WaitableRace(waitables)); } private static String toProtocol(WaitForSelectorOptions.State state) { @@ -540,14 +602,17 @@ public class FrameImpl extends ChannelOwner implements Frame { @Override public void waitForTimeout(int timeout) { - +// return toDeferred(new WaitableTimeout(timeout)); + toDeferred(new WaitableTimeout(timeout)).get(); } protected void handleEvent(String event, JsonObject params) { if ("loadstate".equals(event)) { JsonElement add = params.get("add"); if (add != null) { - loadStates.add(loadStateFromProtocol(add.getAsString())); + LoadState state = loadStateFromProtocol(add.getAsString()); + loadStates.add(state); + internalListeners.notify(InternalEventType.LOADSTATE, state); } JsonElement remove = params.get("remove"); if (remove != null) { @@ -556,13 +621,10 @@ public class FrameImpl extends ChannelOwner implements Frame { } else if ("navigated".equals(event)) { url = params.get("url").getAsString(); name = params.get("name").getAsString(); -// liste if (!params.has("error") && page != null) { page.frameNavigated(this); } - } - for (WaitForNavigationHelper h : new ArrayList<>(eventHelpers)) { - h.handleEvent(event, params); + internalListeners.notify(InternalEventType.NAVIGATED, params); } } } diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java index f7a777e2..4383bbdb 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java @@ -661,16 +661,17 @@ public class PageImpl extends ChannelOwner implements Page { } } - private class WaitEventHelper implements Deferred, Listener { - private final CompletableFuture> result = new CompletableFuture<>(); - private final EventType type; - private final Predicate> predicate; - private final List subscribedEvents; - WaitEventHelper(EventType type, Predicate> predicate) { - this.type = type; - this.predicate = predicate; - subscribedEvents = Arrays.asList(type, EventType.CLOSE, EventType.CRASH); + Waitable createWaitForCloseHelper() { + return new WaitablePageClose(); + } + + class WaitablePageClose implements Waitable, Listener { + private final List subscribedEvents; + private RuntimeException exception; + + WaitablePageClose() { + subscribedEvents = Arrays.asList(EventType.CLOSE, EventType.CRASH); for (EventType e : subscribedEvents) { addListener(e, this); } @@ -678,44 +679,101 @@ public class PageImpl extends ChannelOwner implements Page { @Override public void handle(Event event) { - if (type.equals(event.type()) && predicate.test(event)) { - result.complete(event); - } else if (EventType.CLOSE.equals(event.type())) { - result.completeExceptionally(new RuntimeException("Page closed")); + if (EventType.CLOSE.equals(event.type())) { + exception = new RuntimeException("Page closed"); } else if (EventType.CRASH.equals(event.type())) { - result.completeExceptionally(new RuntimeException("Page crashed")); + exception = new RuntimeException("Page crashed"); } else { return; } + dispose(); + } + + @Override + public boolean isDone() { + return exception != null; + } + + @Override + public Object get() { + throw exception; + } + + @Override + public void dispose() { for (EventType e : subscribedEvents) { removeListener(e, this); } } + } - public R get() { - Event r = waitForCompletion(result); - return (R) r.data(); + private class WaitableEvent implements Waitable, Listener { + private final EventType type; + private final Predicate> predicate; + private Event event; + + WaitableEvent(EventType type, Predicate> predicate) { + this.type = type; + this.predicate = predicate; + addListener(type, this); + } + + @Override + public void handle(Event event) { + assert type.equals(event.type()); + if (!predicate.test(event)) { + return; + } + + this.event = event; + dispose(); + } + + @Override + public boolean isDone() { + return event != null; + } + + @Override + public void dispose() { + removeListener(type, this); + } + + public Object get() { + return event.data(); } } @Override public Deferred waitForRequest(String urlOrPredicate, WaitForRequestOptions options) { - return new WaitEventHelper<>(EventType.REQUEST, e -> { + List waitables = new ArrayList<>(); + waitables.add(new WaitableEvent(EventType.REQUEST, e -> { if (urlOrPredicate == null) { return true; } return urlOrPredicate.equals(((Request) e.data()).url()); - }); + })); + waitables.add(createWaitForCloseHelper()); + if (options != null && options.timeout != null) { + waitables.add(new WaitableTimeout(options.timeout.intValue())); + } + return toDeferred(new WaitableRace(waitables)); } @Override public Deferred waitForResponse(String urlOrPredicate, WaitForResponseOptions options) { - return new WaitEventHelper<>(EventType.RESPONSE, e -> { + List waitables = new ArrayList<>(); + waitables.add(new WaitableEvent(EventType.RESPONSE, e -> { if (urlOrPredicate == null) { return true; } return urlOrPredicate.equals(((Response) e.data()).url()); - }); + })); + waitables.add(createWaitForCloseHelper()); + if (options != null && options.timeout != null) { + waitables.add(new WaitableTimeout(options.timeout.intValue())); + } + return toDeferred(new WaitableRace(waitables)); } @Override diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/Waitable.java b/playwright/src/main/java/com/microsoft/playwright/impl/Waitable.java new file mode 100644 index 00000000..17d763d9 --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/Waitable.java @@ -0,0 +1,23 @@ +/** + * 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. + */ + +package com.microsoft.playwright.impl; + +interface Waitable { + boolean isDone(); + Object get(); + void dispose(); +} diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/WaitableRace.java b/playwright/src/main/java/com/microsoft/playwright/impl/WaitableRace.java new file mode 100644 index 00000000..089b1b36 --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/WaitableRace.java @@ -0,0 +1,61 @@ +/** + * 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. + */ + +package com.microsoft.playwright.impl; + +import java.util.Arrays; +import java.util.Collection; + +class WaitableRace implements Waitable { + private final Collection waitables; + + WaitableRace(Waitable... waitables) { + this(Arrays.asList(waitables)); + } + + WaitableRace(Collection waitables) { + this.waitables = waitables; + } + + @Override + public boolean isDone() { + for (Waitable w : waitables) { + if (w.isDone()) { + return true; + } + } + return false; + } + + @Override + public Object get() { + assert isDone(); + dispose(); + for (Waitable w : waitables) { + if (w.isDone()) { + return w.get(); + } + } + throw new IllegalStateException("At least one element must be ready"); + } + + @Override + public void dispose() { + for (Waitable w : waitables) { + w.dispose(); + } + } +} diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/WaitableTimeout.java b/playwright/src/main/java/com/microsoft/playwright/impl/WaitableTimeout.java new file mode 100644 index 00000000..d0ffdd87 --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/WaitableTimeout.java @@ -0,0 +1,42 @@ +/** + * 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. + */ + +package com.microsoft.playwright.impl; + +class WaitableTimeout implements Waitable { + private final long deadline; + private final int timeout; + + WaitableTimeout(int millis) { + timeout = millis; + deadline = System.nanoTime() + millis * 1_000_000; + } + + + @Override + public boolean isDone() { + return System.nanoTime() > deadline; + } + + @Override + public Object get() { + throw new RuntimeException("Timeout " + timeout + "ms exceeded"); + } + + @Override + public void dispose() { + } +} diff --git a/playwright/src/test/java/com/microsoft/playwright/TestPageWaitForNavigation.java b/playwright/src/test/java/com/microsoft/playwright/TestPageWaitForNavigation.java new file mode 100644 index 00000000..cabf863d --- /dev/null +++ b/playwright/src/test/java/com/microsoft/playwright/TestPageWaitForNavigation.java @@ -0,0 +1,276 @@ +/** + * 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. + */ + +package com.microsoft.playwright; + +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; + +import static com.google.gson.internal.bind.TypeAdapters.URL; +import static com.microsoft.playwright.Page.EventType.*; +import static com.microsoft.playwright.Utils.attachFrame; +import static org.junit.jupiter.api.Assertions.*; + +public class TestPageWaitForNavigation { + private static Playwright playwright; + private static Server server; + private static Browser browser; + private static boolean isChromium; + private static boolean isWebKit; + private static boolean headful; + private BrowserContext context; + private Page page; + + @BeforeAll + static void launchBrowser() { + playwright = Playwright.create(); + BrowserType.LaunchOptions options = new BrowserType.LaunchOptions(); + browser = playwright.chromium().launch(options); + isChromium = true; + isWebKit = false; + headful = false; + } + + @BeforeAll + static void startServer() throws IOException { + server = new Server(8907); + } + + @AfterAll + static void stopServer() throws IOException { + browser.close(); + server.stop(); + server = null; + } + + @BeforeEach + void setUp() { + server.reset(); + context = browser.newContext(); + page = context.newPage(); + } + + @AfterEach + void tearDown() { + context.close(); + context = null; + page = null; + } + + @Test + void shouldWork() { + page.navigate(server.EMPTY_PAGE); + Deferred response = page.waitForNavigation(); + page.evaluate("url => window.location.href = url", server.PREFIX + "/grid.html"); + assertTrue(response.get().ok()); + assertTrue(response.get().url().contains("grid.html")); + } + +// @Test + // TODO: timeout + void shouldRespectTimeout() { + Deferred promise = page.waitForNavigation(new Page.WaitForNavigationOptions().withUrl("**/frame.html").withTimeout(5000)); + page.navigate(server.EMPTY_PAGE); + try { + promise.get(); + fail("did not throw"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("page.waitForNavigation: Timeout 5000ms exceeded.")); + assertTrue(e.getMessage().contains("waiting for navigation to '**/frame.html' until 'load'")); + assertTrue(e.getMessage().contains("navigated to '${server.EMPTY_PAGE}'")); + } + } + + // Skipped in sync API. + void shouldWorkWithBothDomcontentloadedAndLoad() { + } + + @Test + void shouldWorkWithClickingOnAnchorLinks() { + page.navigate(server.EMPTY_PAGE); + page.setContent("foobar"); + Deferred response = page.waitForNavigation(); + page.click("a"); + assertNull(response.get()); + assertEquals(server.EMPTY_PAGE + "#foobar", page.url()); + } + + @Test + void shouldWorkWithClickingOnLinksWhichDoNotCommitNavigation() { + // TODO: https server +// page.navigate(server.EMPTY_PAGE); +// page.setContent("foobar"); +// try { +// page.waitForNavigation(); +// page.click("a"); +// fail("did not throw"); +// } catch (RuntimeException e) { +// assertTrue(e.getMessage().contains(expectedSSLError(browserName))); +// } + } + + @Test + void shouldWorkWithHistoryPushState() { + page.navigate(server.EMPTY_PAGE); + page.setContent("SPA\n" + + ""); + Deferred response = page.waitForNavigation(); + page.click("a"); + assertNull(response.get()); + assertEquals(server.PREFIX + "/wow.html", page.url()); + } + + @Test + void shouldWorkWithHistoryReplaceState() { + page.navigate(server.EMPTY_PAGE); + page.setContent(" SPA\n" + + ""); + Deferred response = page.waitForNavigation(); + page.click("a"); + assertNull(response.get()); + assertEquals(server.PREFIX + "/replaced.html", page.url()); + } + + @Test + void shouldWorkWithDOMHistoryBackHistoryForward() { + page.navigate(server.EMPTY_PAGE); + page.setContent("back\n" + + "forward\n" + + ""); + assertEquals(server.PREFIX + "/second.html", page.url()); + + Deferred backResponse = page.waitForNavigation(); + page.click("a#back"); + assertNull(backResponse.get()); + assertEquals(server.PREFIX + "/first.html", page.url()); + + Deferred forwardResponse = page.waitForNavigation(); + page.click("a#forward"); + assertNull(forwardResponse.get()); + assertEquals(server.PREFIX + "/second.html", page.url()); + } + + @Test + void shouldWorkWhenSubframeIssuesWindowStop() { + server.setRoute("/frames/style.css", exchange -> {}); + boolean[] frameWindowStopCalled = {false}; + page.addListener(Page.EventType.FRAMEATTACHED, event -> { + Frame frame = (Frame) event.data(); + page.addListener(FRAMENAVIGATED, event1 -> { + if (frame.equals(event1.data())) { + frame.evaluate("window.stop()"); + frameWindowStopCalled[0] = true; + } + }); + }); + page.navigate(server.PREFIX + "/frames/one-frame.html"); + assertTrue(frameWindowStopCalled[0]); + } + +// @Test + void shouldWorkWithUrlMatch() { + // TODO: predicate + Deferred response1 = page.waitForNavigation(new Page.WaitForNavigationOptions().withUrl("/one-style.html/")); + Deferred response2 = page.waitForNavigation(new Page.WaitForNavigationOptions().withUrl("/frame.html/")); + Deferred response3 = page.waitForNavigation(new Page.WaitForNavigationOptions().withUrl("url => url.searchParams.get(\"foo\") === \"bar\"")); + page.navigate(server.EMPTY_PAGE); + page.navigate(server.PREFIX + "/frame.html"); + assertNotNull(response2.get()); + page.navigate(server.PREFIX + "/one-style.html"); + assertNotNull(response1.get()); + page.navigate(server.PREFIX + "/frame.html?foo=bar"); + assertNotNull(response3.get()); + page.navigate(server.PREFIX + "/empty.html"); + assertEquals(server.PREFIX + "/one-style.html", response1.get().url()); + assertEquals(server.PREFIX + "/frame.html", response2.get().url()); + assertEquals(server.PREFIX + "/frame.html?foo=bar", response3.get().url()); + } + + @Test + void shouldWorkWithUrlMatchForSameDocumentNavigations() { + page.navigate(server.EMPTY_PAGE); + // TODO: use regex + Deferred waitPromise = page.waitForNavigation(new Page.WaitForNavigationOptions().withUrl("**/third.html")); + page.evaluate("() => {\n" + + " history.pushState({}, '', '/first.html');\n" + + "}"); + page.evaluate("() => {\n" + + " history.pushState({}, '', '/second.html');\n" + + "}"); + page.evaluate("() => {\n" + + " history.pushState({}, '', '/third.html');\n" + + "}"); + assertNull(waitPromise.get()); + } + + @Test + void shouldWorkForCrossProcessNavigations() { + page.navigate(server.EMPTY_PAGE); + Deferred waitPromise = page.waitForNavigation(new Page.WaitForNavigationOptions().withWaitUntil(Frame.LoadState.DOMCONTENTLOADED)); + String url = server.CROSS_PROCESS_PREFIX + "/empty.html"; + page.navigate(url); + Response response = waitPromise.get(); + assertEquals(url, response.url()); + assertEquals(url, page.url()); + assertEquals(url, page.evaluate("document.location.href")); + } + + @Test + void shouldWorkOnFrame() { + page.navigate(server.PREFIX + "/frames/one-frame.html"); + Frame frame = page.frames().get(1); + Deferred response = frame.waitForNavigation(); + frame.evaluate("url => window.location.href = url", server.PREFIX + "/grid.html"); + assertTrue(response.get().ok()); + assertTrue(response.get().url().contains("grid.html")); + assertEquals(frame, response.get().frame()); + assertTrue(page.url().contains("/frames/one-frame.html")); + } + +// @Test + void shouldFailWhenFrameDetaches() { + page.navigate(server.PREFIX + "/frames/one-frame.html"); + Frame frame = page.frames().get(1); + server.setRoute("/empty.html", exchange -> {}); + try { + Deferred response = frame.waitForNavigation(); + frame.evaluate("window.location.href = '/empty.html'"); + page.evaluate("setTimeout(() => document.querySelector('iframe').remove())"); + response.get(); + fail("did not throw"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("waiting for navigation until \"load\"")); + assertTrue(e.getMessage().contains("frame was detached")); + } + } + + +}