From 4b873ec3ad1f3fd615b4926a09101e280de3c97f Mon Sep 17 00:00:00 2001 From: Raphi Date: Fri, 11 Aug 2023 01:42:57 +0200 Subject: [PATCH] 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/ --- .../com/microsoft/playwright/Browser.java | 8 + .../microsoft/playwright/BrowserContext.java | 20 +++ .../com/microsoft/playwright/CDPSession.java | 94 ++++++++++ .../playwright/impl/BrowserContextImpl.java | 16 ++ .../playwright/impl/BrowserImpl.java | 7 + .../playwright/impl/CDPSessionImpl.java | 56 ++++++ .../microsoft/playwright/impl/Connection.java | 3 + .../com/microsoft/playwright/TestBrowser.java | 29 +++ .../TestBrowserContextCDPSession.java | 165 ++++++++++++++++++ .../playwright/tools/ApiGenerator.java | 5 +- 10 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 playwright/src/main/java/com/microsoft/playwright/CDPSession.java create mode 100644 playwright/src/main/java/com/microsoft/playwright/impl/CDPSessionImpl.java create mode 100644 playwright/src/test/java/com/microsoft/playwright/TestBrowserContextCDPSession.java diff --git a/playwright/src/main/java/com/microsoft/playwright/Browser.java b/playwright/src/main/java/com/microsoft/playwright/Browser.java index b418332e..b4a8a84b 100644 --- a/playwright/src/main/java/com/microsoft/playwright/Browser.java +++ b/playwright/src/main/java/com/microsoft/playwright/Browser.java @@ -1186,6 +1186,14 @@ public interface Browser extends AutoCloseable { * @since v1.8 */ boolean isConnected(); + /** + * NOTE: CDP Sessions are only supported on Chromium-based browsers. + * + *

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. * diff --git a/playwright/src/main/java/com/microsoft/playwright/BrowserContext.java b/playwright/src/main/java/com/microsoft/playwright/BrowserContext.java index 51886ae4..74378506 100644 --- a/playwright/src/main/java/com/microsoft/playwright/BrowserContext.java +++ b/playwright/src/main/java/com/microsoft/playwright/BrowserContext.java @@ -752,6 +752,26 @@ public interface BrowserContext extends AutoCloseable { * @since v1.8 */ void grantPermissions(List permissions, GrantPermissionsOptions options); + /** + * NOTE: CDP sessions are only supported on Chromium-based browsers. + * + *

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); + /** + * NOTE: CDP sessions are only supported on Chromium-based browsers. + * + *

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. * diff --git a/playwright/src/main/java/com/microsoft/playwright/CDPSession.java b/playwright/src/main/java/com/microsoft/playwright/CDPSession.java new file mode 100644 index 00000000..a1c5b0d2 --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/CDPSession.java @@ -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: + *

+ * + *

Useful links: + *

+ */ +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 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 handler); +} + diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/BrowserContextImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/BrowserContextImpl.java index 1fbcac3b..efb0f394 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/BrowserContextImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/BrowserContextImpl.java @@ -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()); diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/BrowserImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/BrowserImpl.java index 17549fab..e630f9ba 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/BrowserImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/BrowserImpl.java @@ -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); diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/CDPSessionImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/CDPSessionImpl.java new file mode 100644 index 00000000..929b9053 --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/CDPSessionImpl.java @@ -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 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 handler) { + listeners.add(event, handler); + } + + @Override + public void off(String event, Consumer handler) { + listeners.remove(event, handler); + } + + @Override + public void detach() { + sendMessage("detach"); + } +} diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java b/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java index d996f0f1..e268aef7 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java @@ -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); } diff --git a/playwright/src/test/java/com/microsoft/playwright/TestBrowser.java b/playwright/src/test/java/com/microsoft/playwright/TestBrowser.java index faa8425d..9abfed89 100644 --- a/playwright/src/test/java/com/microsoft/playwright/TestBrowser.java +++ b/playwright/src/test/java/com/microsoft/playwright/TestBrowser.java @@ -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 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(); + } } diff --git a/playwright/src/test/java/com/microsoft/playwright/TestBrowserContextCDPSession.java b/playwright/src/test/java/com/microsoft/playwright/TestBrowserContextCDPSession.java new file mode 100644 index 00000000..eeba7b6d --- /dev/null +++ b/playwright/src/test/java/com/microsoft/playwright/TestBrowserContextCDPSession.java @@ -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 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 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 events = new ArrayList<>(); + Consumer 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()); + } +} diff --git a/tools/api-generator/src/main/java/com/microsoft/playwright/tools/ApiGenerator.java b/tools/api-generator/src/main/java/com/microsoft/playwright/tools/ApiGenerator.java index 9774eb30..caf601f5 100644 --- a/tools/api-generator/src/main/java/com/microsoft/playwright/tools/ApiGenerator.java +++ b/tools/api-generator/src/main/java/com/microsoft/playwright/tools/ApiGenerator.java @@ -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;");