1
0
mirror of synced 2026-05-22 18:53:15 +00:00

feat: Add support for Chrome DevTools Protocol (CDPSession) (#1329)

Add new methods BrowserContext.newCDPSession and
Browser.newBrowserCDPSession to create a Chrome
DevTools Protocol[1] session for the page and
browser respectively.

Fixes #823
[1] https://chromedevtools.github.io/devtools-protocol/
This commit is contained in:
Raphi
2023-08-11 01:42:57 +02:00
committed by GitHub
parent 463146ab11
commit 4b873ec3ad
10 changed files with 402 additions and 1 deletions
@@ -1186,6 +1186,14 @@ public interface Browser extends AutoCloseable {
* @since v1.8
*/
boolean isConnected();
/**
* <strong>NOTE:</strong> CDP Sessions are only supported on Chromium-based browsers.
*
* <p> Returns the newly created browser session.
*
* @since v1.11
*/
CDPSession newBrowserCDPSession();
/**
* Creates a new browser context. It won't share cookies/cache with other browser contexts.
*
@@ -752,6 +752,26 @@ public interface BrowserContext extends AutoCloseable {
* @since v1.8
*/
void grantPermissions(List<String> permissions, GrantPermissionsOptions options);
/**
* <strong>NOTE:</strong> CDP sessions are only supported on Chromium-based browsers.
*
* <p> Returns the newly created session.
*
* @param page Target to create new session for. For backwards-compatibility, this parameter is named {@code page}, but it can be a
* {@code Page} or {@code Frame} type.
* @since v1.11
*/
CDPSession newCDPSession(Page page);
/**
* <strong>NOTE:</strong> CDP sessions are only supported on Chromium-based browsers.
*
* <p> Returns the newly created session.
*
* @param page Target to create new session for. For backwards-compatibility, this parameter is named {@code page}, but it can be a
* {@code Page} or {@code Frame} type.
* @since v1.11
*/
CDPSession newCDPSession(Frame page);
/**
* Creates a new page in the browser context.
*
@@ -0,0 +1,94 @@
/*
* 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 java.util.function.Consumer;
import com.google.gson.JsonObject;
/**
* The {@code CDPSession} instances are used to talk raw Chrome Devtools Protocol:
* <ul>
* <li> protocol methods can be called with {@code session.send} method.</li>
* <li> protocol events can be subscribed to with {@code session.on} method.</li>
* </ul>
*
* <p> Useful links:
* <ul>
* <li> Documentation on DevTools Protocol can be found here: <a
* href="https://chromedevtools.github.io/devtools-protocol/">DevTools Protocol Viewer</a>.</li>
* <li> Getting Started with DevTools Protocol: https://github.com/aslushnikov/getting-started-with-cdp/blob/master/README.md</li>
* <pre>{@code
* CDPSession client = page.context().newCDPSession(page);
* client.send("Runtime.enable");
*
* client.on("Animation.animationCreated", (event) -> System.out.println("Animation created!"));
*
* JsonObject response = client.send("Animation.getPlaybackRate");
* double playbackRate = response.get("playbackRate").getAsDouble();
* System.out.println("playback rate is " + playbackRate);
*
* JsonObject params = new JsonObject();
* params.addProperty("playbackRate", playbackRate / 2);
* client.send("Animation.setPlaybackRate", params);
* }</pre>
* </ul>
*/
public interface CDPSession {
/**
* Detaches the CDPSession from the target. Once detached, the CDPSession object won't emit any events and can't be used to
* send messages.
*
* @since v1.8
*/
void detach();
/**
*
*
* @param method Protocol method name.
* @since v1.8
*/
default JsonObject send(String method) {
return send(method, null);
}
/**
*
*
* @param method Protocol method name.
* @param args Optional method parameters.
* @since v1.8
*/
JsonObject send(String method, JsonObject args);
/**
* Register an event handler for events with the specified event name. The given handler will be called for every event
* with the given name.
*
* @param eventName CDP event name.
* @param handler Event handler.
* @since v1.37
*/
void on(String eventName, Consumer<JsonObject> handler);
/**
* Unregister an event handler for events with the specified event name. The given handler will not be called anymore for
* events with the given name.
*
* @param eventName CDP event name.
* @param handler Event handler.
* @since v1.37
*/
void off(String eventName, Consumer<JsonObject> handler);
}
@@ -214,6 +214,22 @@ class BrowserContextImpl extends ChannelOwner implements BrowserContext {
return waitForEventWithTimeout(EventType.PAGE, code, options.predicate, options.timeout);
}
@Override
public CDPSession newCDPSession(Page page) {
JsonObject params = new JsonObject();
params.add("page", ((PageImpl) page).toProtocolRef());
JsonObject result = sendMessage("newCDPSession", params).getAsJsonObject();
return connection.getExistingObject(result.getAsJsonObject("session").get("guid").getAsString());
}
@Override
public CDPSession newCDPSession(Frame frame) {
JsonObject params = new JsonObject();
params.add("frame", ((FrameImpl) frame).toProtocolRef());
JsonObject result = sendMessage("newCDPSession", params).getAsJsonObject();
return connection.getExistingObject(result.getAsJsonObject("session").get("guid").getAsString());
}
@Override
public void close() {
withLogging("BrowserContext.close", () -> closeImpl());
@@ -276,6 +276,13 @@ class BrowserImpl extends ChannelOwner implements Browser {
}
}
@Override
public CDPSession newBrowserCDPSession() {
JsonObject params = new JsonObject();
JsonObject result = sendMessage("newBrowserCDPSession", params).getAsJsonObject();
return connection.getExistingObject(result.getAsJsonObject("session").get("guid").getAsString());
}
private void didClose() {
isConnected = false;
listeners.notify(EventType.DISCONNECTED, this);
@@ -0,0 +1,56 @@
package com.microsoft.playwright.impl;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.microsoft.playwright.CDPSession;
import java.util.HashMap;
import java.util.function.Consumer;
public class CDPSessionImpl extends ChannelOwner implements CDPSession {
private final ListenerCollection<String> listeners = new ListenerCollection<>(new HashMap<>(), this);
protected CDPSessionImpl(ChannelOwner parent, String type, String guid, JsonObject initializer) {
super(parent, type, guid, initializer);
}
@Override
void handleEvent(String event, JsonObject parameters) {
super.handleEvent(event, parameters);
if ("event".equals(event)) {
String method = parameters.get("method").getAsString();
JsonObject params = parameters.get("params").getAsJsonObject();
listeners.notify(method, params);
}
}
public JsonObject send(String method) {
return send(method, null);
}
public JsonObject send(String method, JsonObject params) {
JsonObject args = new JsonObject();
if (params != null) {
args.add("params", params);
}
args.addProperty("method", method);
JsonElement response = connection.sendMessage(guid, "send", args);
if (response == null) return null;
else return response.getAsJsonObject().get("result").getAsJsonObject();
}
@Override
public void on(String event, Consumer<JsonObject> handler) {
listeners.add(event, handler);
}
@Override
public void off(String event, Consumer<JsonObject> handler) {
listeners.remove(event, handler);
}
@Override
public void detach() {
sendMessage("detach");
}
}
@@ -359,6 +359,9 @@ public class Connection {
case "WritableStream":
result = new WritableStream(parent, type, guid, initializer);
break;
case "CDPSession":
result = new CDPSessionImpl(parent, type, guid, initializer);
break;
default:
throw new PlaywrightException("Unknown type " + type);
}
@@ -16,10 +16,14 @@
package com.microsoft.playwright;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.microsoft.playwright.options.BrowserChannel;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
@@ -98,4 +102,29 @@ public class TestBrowser extends TestBase {
void shouldReturnBrowserType() {
assertEquals(browserType, browser.browserType());
}
@Test
@EnabledIf(value = "com.microsoft.playwright.TestBase#isChromium", disabledReason = "Chrome Devtools Protocol supported by chromium only")
void shouldWorkWithNewBrowserCDPSession() {
CDPSession session = browser.newBrowserCDPSession();
JsonElement response = session.send("Browser.getVersion");
assertNotNull(response.getAsJsonObject().get("userAgent").toString());
AtomicReference<Boolean> gotEvent = new AtomicReference<>(false);
session.on("Target.targetCreated", jsonElement -> {
gotEvent.set(true);
});
JsonObject params = new JsonObject();
params.addProperty("discover", true);
session.send("Target.setDiscoverTargets", params);
Page page = browser.newPage();
assertTrue(gotEvent.get());
page.close();
session.detach();
}
}
@@ -0,0 +1,165 @@
/*
* 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 com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
@EnabledIf(value = "com.microsoft.playwright.TestBase#isChromium", disabledReason = "Chrome Devtools Protocol supported by chromium only")
public class TestBrowserContextCDPSession extends TestBase {
@Test
void shouldWork() {
CDPSession cdpSession = page.context().newCDPSession(page);
cdpSession.send("Runtime.enable");
JsonObject params = new JsonObject();
params.addProperty("expression", "window.foo = 'bar'");
cdpSession.send("Runtime.evaluate", params);
Object foo = page.evaluate("window['foo']");
assertEquals("bar", foo);
}
@Test
void shouldSendEvents() {
CDPSession cdpSession = page.context().newCDPSession(page);
cdpSession.send("Network.enable");
List<JsonElement> events = new ArrayList<>();
cdpSession.on("Network.requestWillBeSent", events::add);
page.navigate(server.EMPTY_PAGE);
assertEquals(1, events.size());
}
@Test
void shouldDetachSession() {
CDPSession cdpSession = page.context().newCDPSession(page);
cdpSession.send("Runtime.enable");
JsonObject params = new JsonObject();
params.addProperty("expression", "1 + 2");
params.addProperty("returnByValue", true);
JsonElement evaluateResult = cdpSession.send("Runtime.evaluate", params);
assertEquals(3, evaluateResult.getAsJsonObject().getAsJsonObject("result").get("value").getAsInt());
cdpSession.detach();
PlaywrightException exception = assertThrows(PlaywrightException.class, () -> {
cdpSession.send("Runtime.evaluate", params);
});
assertTrue(exception.getMessage().contains("Target page, context or browser has been closed"));
}
@Test
void shouldThrowNiceErrors() {
CDPSession cdpSession = page.context().newCDPSession(page);
PlaywrightException exception = assertThrows(PlaywrightException.class, () -> {
cdpSession.send("ThisCommand.DoesNotExist");
});
assertTrue(exception.getMessage().contains("'ThisCommand.DoesNotExist' wasn't found"));
}
@Test
void shouldWorkWithMainFrame() {
CDPSession cdpSession = page.context().newCDPSession(page.mainFrame());
JsonObject params = new JsonObject();
params.addProperty("expression", "window.foo = 'bar'");
cdpSession.send("Runtime.evaluate", params);
Object foo = page.evaluate("window['foo']");
assertEquals("bar", foo);
}
@Test
void shouldThrowIfTargetIsPartOfMain() {
page.navigate(server.PREFIX + "/frames/one-frame.html");
assertEquals(server.PREFIX + "/frames/one-frame.html", page.frames().get(0).url());
assertEquals(server.PREFIX + "/frames/frame.html", page.frames().get(1).url());
PlaywrightException exception = assertThrows(PlaywrightException.class, () -> {
page.context().newCDPSession(page.frames().get(1));
});
assertTrue(exception.getMessage().contains("This frame does not have a separate CDP session, it is a part of the parent frame's session"));
}
@Test
void shouldNotBreakPageClose() {
BrowserContext context = browser.newContext();
Page page = context.newPage();
CDPSession session = page.context().newCDPSession(page);
session.detach();
page.close();
context.close();
}
@Test
void shouldDetachWhenPageCloses() {
BrowserContext context = browser.newContext();
Page page = context.newPage();
CDPSession session = page.context().newCDPSession(page);
page.close();
PlaywrightException exception = assertThrows(PlaywrightException.class, session::detach);
assertTrue(exception.getMessage().contains("Target page, context or browser has been closed"));
context.close();
}
@Test
void shouldAddMultipleEventListeners() {
CDPSession cdpSession = page.context().newCDPSession(page);
cdpSession.send("Network.enable");
List<JsonObject> events = new ArrayList<>();
cdpSession.on("Network.requestWillBeSent", events::add);
cdpSession.on("Network.requestWillBeSent", events::add);
page.navigate(server.EMPTY_PAGE);
assertEquals(2, events.size());
}
@Test
void shouldRemoveEventListeners() {
CDPSession cdpSession = page.context().newCDPSession(page);
cdpSession.send("Network.enable");
List<JsonObject> events = new ArrayList<>();
Consumer<JsonObject> listener1 = events::add;
cdpSession.on("Network.requestWillBeSent", listener1);
cdpSession.on("Network.requestWillBeSent", events::add);
page.navigate(server.EMPTY_PAGE);
assertEquals(2, events.size());
cdpSession.off("Network.requestWillBeSent", listener1);
events.clear();
page.navigate(server.EMPTY_PAGE);
assertEquals(1, events.size());
}
}
@@ -964,7 +964,7 @@ class Interface extends TypeDefinition {
if (asList("Page", "Frame", "ElementHandle", "Locator", "APIRequest", "Browser", "BrowserContext", "BrowserType", "Route", "Request", "Response", "JSHandle", "ConsoleMessage", "APIResponse", "Playwright").contains(jsonName)) {
output.add("import java.util.*;");
}
if (asList("Page", "Browser", "BrowserContext", "WebSocket", "Worker").contains(jsonName)) {
if (asList("Page", "Browser", "BrowserContext", "WebSocket", "Worker", "CDPSession").contains(jsonName)) {
output.add("import java.util.function.Consumer;");
}
if (asList("Page", "BrowserContext").contains(jsonName)) {
@@ -977,6 +977,9 @@ class Interface extends TypeDefinition {
if (asList("Page", "Frame", "FrameLocator", "Locator", "Browser", "BrowserType", "BrowserContext", "PageAssertions", "LocatorAssertions").contains(jsonName)) {
output.add("import java.util.regex.Pattern;");
}
if ("CDPSession".equals(jsonName)) {
output.add("import com.google.gson.JsonObject;");
}
if ("PlaywrightAssertions".equals(jsonName)) {
output.add("import com.microsoft.playwright.APIResponse;");
output.add("import com.microsoft.playwright.Locator;");