1
0
mirror of synced 2026-08-05 15:06:54 +00:00

chore: rework waitFor* to use Waitable (#17)

This commit is contained in:
Yury Semikhatsky
2020-10-16 11:05:04 -07:00
committed by GitHub
parent f6bdcd240b
commit f0a34fc4ca
7 changed files with 609 additions and 78 deletions
@@ -85,6 +85,15 @@ class ChannelOwner {
return result;
}
<T> Deferred<T> toDeferred(Waitable waitable) {
return () -> {
while (!waitable.isDone()) {
connection.processOneMessage();
}
return (T) waitable.get();
};
}
<T> T waitForCompletion(CompletableFuture<T> future) {
while (!future.isDone()) {
connection.processOneMessage();
@@ -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<FrameImpl> childFrames = new LinkedHashSet<>();
private final Set<LoadState> loadStates = new HashSet<>();
private final List<WaitForNavigationHelper> eventHelpers = new ArrayList<>();
enum InternalEventType { NAVIGATED, LOADSTATE };
private final ListenerCollection<InternalEventType> 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<InternalEventType> {
private final LoadState expectedState;
private boolean isDone;
// TODO: switch to listeners or something else less convoluted.
private class WaitForNavigationHelper implements Deferred<Response> {
private final CompletableFuture<Response> 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<InternalEventType> 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<InternalEventType> {
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<InternalEventType> 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<Response> 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<Waitable> 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);
}
}
}
@@ -661,16 +661,17 @@ public class PageImpl extends ChannelOwner implements Page {
}
}
private class WaitEventHelper<R> implements Deferred<R>, Listener<EventType> {
private final CompletableFuture<Event<EventType>> result = new CompletableFuture<>();
private final EventType type;
private final Predicate<Event<EventType>> predicate;
private final List<EventType> subscribedEvents;
WaitEventHelper(EventType type, Predicate<Event<EventType>> 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<EventType> {
private final List<EventType> 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<EventType> 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<EventType> r = waitForCompletion(result);
return (R) r.data();
private class WaitableEvent implements Waitable, Listener<EventType> {
private final EventType type;
private final Predicate<Event<EventType>> predicate;
private Event<EventType> event;
WaitableEvent(EventType type, Predicate<Event<EventType>> predicate) {
this.type = type;
this.predicate = predicate;
addListener(type, this);
}
@Override
public void handle(Event<EventType> 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<Request> waitForRequest(String urlOrPredicate, WaitForRequestOptions options) {
return new WaitEventHelper<>(EventType.REQUEST, e -> {
List<Waitable> 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<Response> waitForResponse(String urlOrPredicate, WaitForResponseOptions options) {
return new WaitEventHelper<>(EventType.RESPONSE, e -> {
List<Waitable> 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
@@ -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();
}
@@ -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<Waitable> waitables;
WaitableRace(Waitable... waitables) {
this(Arrays.asList(waitables));
}
WaitableRace(Collection<Waitable> 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();
}
}
}
@@ -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() {
}
}
@@ -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> 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<Response> 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("<a href='#foobar'>foobar</a>");
Deferred<Response> 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("<a href='" + httpsServer.EMPTY_PAGE + "'>foobar</a>");
// 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("<a onclick='javascript:pushState()'>SPA</a>\n" +
"<script>\n" +
" function pushState() { history.pushState({}, '', 'wow.html') }\n" +
"</script>");
Deferred<Response> 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(" <a onclick='javascript:replaceState()'>SPA</a>\n" +
"<script>\n" +
" function replaceState() { history.replaceState({}, '', '/replaced.html') }\n" +
"</script>");
Deferred<Response> 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("<a id=back onclick='javascript:goBack()'>back</a>\n" +
"<a id=forward onclick='javascript:goForward()'>forward</a>\n" +
"<script>\n" +
" function goBack() { history.back(); }\n" +
" function goForward() { history.forward(); }\n" +
" history.pushState({}, '', '/first.html');\n" +
" history.pushState({}, '', '/second.html');\n" +
"</script>");
assertEquals(server.PREFIX + "/second.html", page.url());
Deferred<Response> backResponse = page.waitForNavigation();
page.click("a#back");
assertNull(backResponse.get());
assertEquals(server.PREFIX + "/first.html", page.url());
Deferred<Response> 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<Response> response1 = page.waitForNavigation(new Page.WaitForNavigationOptions().withUrl("/one-style.html/"));
Deferred<Response> response2 = page.waitForNavigation(new Page.WaitForNavigationOptions().withUrl("/frame.html/"));
Deferred<Response> 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<Response> 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<Response> 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> 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> 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"));
}
}
}