diff --git a/java/client/src/org/openqa/selenium/devtools/events/CdpEventTypes.java b/java/client/src/org/openqa/selenium/devtools/events/CdpEventTypes.java index 664d155956e7c..164b8b9aaa091 100644 --- a/java/client/src/org/openqa/selenium/devtools/events/CdpEventTypes.java +++ b/java/client/src/org/openqa/selenium/devtools/events/CdpEventTypes.java @@ -17,16 +17,32 @@ package org.openqa.selenium.devtools.events; +import com.google.common.base.Joiner; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; import org.openqa.selenium.devtools.DevTools; import org.openqa.selenium.devtools.HasDevTools; import org.openqa.selenium.internal.Require; +import org.openqa.selenium.json.Json; import org.openqa.selenium.logging.EventType; import org.openqa.selenium.logging.HasLogEvents; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; import java.util.function.Consumer; +import static org.openqa.selenium.json.Json.MAP_TYPE; + public class CdpEventTypes { + private static final WeakHashMap> PINNED_SCRIPTS = new WeakHashMap<>(); + private static final Json JSON = new Json(); + private CdpEventTypes() { // Utility class. } @@ -56,4 +72,97 @@ public void initializeLogger(HasLogEvents loggable) { }; } + public static EventType domMutation(Consumer handler) { + String script = Joiner.on("\n").join(new String[]{ + "(function() {", + "const observer = new MutationObserver((mutations) => {", + " for (const mutation of mutations) {", + " switch (mutation.type) {", + " case \"attributes\":", + // Don't report our own attribute has changed. + " if (mutation.attributeName == 'data-__webdriver_id') {", + " break;", + " }", + " const curr = mutation.target.getAttribute(mutation.attributeName);", + " var id = mutation.target.dataset.__webdriver_id", + " if (!id) {", + " id = Math.random().toString(36).substring(2) + Date.now().toString(36);", + " mutation.target.dataset.__webdriver_id = id;", + " }", + " const json = JSON.stringify({", + " \"target\": id,", + " \"name\": mutation.attributeName,", + " \"value\": curr,", + " \"oldValue\": mutation.oldValue", + " });", + " __webdriver_attribute(json);", + " break;", + " default:", + " break;", + " }", + " }", + "});", + "observer.observe(document, {", + " \"attributes\": true,", + " \"attributeOldValue\": true,", + " \"characterData\": true,", + " \"characterDataOldValue\": true,", + " \"childList\": true,", + " \"subtree\": true", + "});", + "})();" + }); + + return new EventType() { + @Override + public void consume(Void event) { + handler.accept(null); + } + + @Override + public void initializeLogger(HasLogEvents loggable) { + Require.precondition(loggable instanceof WebDriver, "Loggable must be a WebDriver"); + Require.precondition(loggable instanceof HasDevTools, "Loggable must implement HasDevTools"); + + DevTools tools = ((HasDevTools) loggable).getDevTools(); + tools.createSession(); + + WebDriver driver = (WebDriver) loggable; + Set scripts = PINNED_SCRIPTS.computeIfAbsent(driver, ignored -> new HashSet<>()); + if (!scripts.contains(script)) { + // Pin the script + tools.send(tools.getDomains().runtime().enable()); + tools.send(tools.getDomains().runtime().addBinding("__webdriver_attribute")); + + tools.send(tools.getDomains().page().enable()); + tools.send(tools.getDomains().page().addScriptToEvaluateOnNewDocument(script)); + + // And add the script to the current page + ((JavascriptExecutor) driver).executeScript(script); + + scripts.add(script); + } + + tools.addListener( + tools.getDomains().runtime().bindingCalled(), + bindingCalled -> { + Map values = JSON.toType(bindingCalled.getPayload(), MAP_TYPE); + String id = (String) values.get("target"); + + List elements = driver.findElements(By.cssSelector(String.format("*[data-__webdriver_id='%s']", id))); + + if (!elements.isEmpty()) { + DomMutationEvent event = new DomMutationEvent( + elements.get(0), + String.valueOf(values.get("name")), + String.valueOf(values.get("value")), + String.valueOf(values.get("oldValue"))); + handler.accept(event); + } + } + ); + } + }; + } + } diff --git a/java/client/src/org/openqa/selenium/devtools/events/DomMutationEvent.java b/java/client/src/org/openqa/selenium/devtools/events/DomMutationEvent.java new file mode 100644 index 0000000000000..0aba4aff2001a --- /dev/null +++ b/java/client/src/org/openqa/selenium/devtools/events/DomMutationEvent.java @@ -0,0 +1,34 @@ +package org.openqa.selenium.devtools.events; + +import org.openqa.selenium.WebElement; + +public class DomMutationEvent { + + private final WebElement element; + private final String attributeName; + private final String currentValue; + private final String oldValue; + + public DomMutationEvent(WebElement element, String attributeName, String currentValue, String oldValue) { + this.element = element; + this.attributeName = attributeName; + this.currentValue = currentValue; + this.oldValue = oldValue; + } + + public WebElement getElement() { + return element; + } + + public String getAttributeName() { + return attributeName; + } + + public String getCurrentValue() { + return currentValue; + } + + public String getOldValue() { + return oldValue; + } +} diff --git a/java/client/src/org/openqa/selenium/devtools/idealized/Domains.java b/java/client/src/org/openqa/selenium/devtools/idealized/Domains.java index d7ffe7b1cc3be..b937200102eeb 100644 --- a/java/client/src/org/openqa/selenium/devtools/idealized/Domains.java +++ b/java/client/src/org/openqa/selenium/devtools/idealized/Domains.java @@ -19,6 +19,7 @@ import org.openqa.selenium.devtools.idealized.fetch.Fetch; import org.openqa.selenium.devtools.idealized.log.Log; +import org.openqa.selenium.devtools.idealized.page.Page; import org.openqa.selenium.devtools.idealized.runtime.RuntimeDomain; import org.openqa.selenium.devtools.idealized.target.Target; @@ -33,6 +34,8 @@ public interface Domains { Log log(); + Page page(); + Target target(); RuntimeDomain runtime(); diff --git a/java/client/src/org/openqa/selenium/devtools/idealized/page/Page.java b/java/client/src/org/openqa/selenium/devtools/idealized/page/Page.java new file mode 100644 index 0000000000000..4a12301b76728 --- /dev/null +++ b/java/client/src/org/openqa/selenium/devtools/idealized/page/Page.java @@ -0,0 +1,12 @@ +package org.openqa.selenium.devtools.idealized.page; + +import org.openqa.selenium.devtools.Command; +import org.openqa.selenium.devtools.idealized.page.model.ScriptIdentifier; + +public interface Page { + + Command enable(); + + Command addScriptToEvaluateOnNewDocument(String source); + +} diff --git a/java/client/src/org/openqa/selenium/devtools/idealized/page/model/ScriptIdentifier.java b/java/client/src/org/openqa/selenium/devtools/idealized/page/model/ScriptIdentifier.java new file mode 100644 index 0000000000000..a40f175fb7c4c --- /dev/null +++ b/java/client/src/org/openqa/selenium/devtools/idealized/page/model/ScriptIdentifier.java @@ -0,0 +1,17 @@ +package org.openqa.selenium.devtools.idealized.page.model; + +import org.openqa.selenium.internal.Require; + +public class ScriptIdentifier { + + private final Object actualIdentifier; + + public ScriptIdentifier(Object actualIdentifier) { + this.actualIdentifier = Require.nonNull("Actual identifier", actualIdentifier); + } + + public Object getActualIdentifier() { + return actualIdentifier; + } + +} diff --git a/java/client/src/org/openqa/selenium/devtools/idealized/runtime/RuntimeDomain.java b/java/client/src/org/openqa/selenium/devtools/idealized/runtime/RuntimeDomain.java index b2718dfc720c0..2c0680241cf0a 100644 --- a/java/client/src/org/openqa/selenium/devtools/idealized/runtime/RuntimeDomain.java +++ b/java/client/src/org/openqa/selenium/devtools/idealized/runtime/RuntimeDomain.java @@ -19,6 +19,7 @@ import org.openqa.selenium.devtools.Command; import org.openqa.selenium.devtools.Event; +import org.openqa.selenium.devtools.idealized.runtime.model.BindingCalled; import org.openqa.selenium.devtools.idealized.runtime.model.ConsoleAPICalled; public interface RuntimeDomain { @@ -26,4 +27,8 @@ public interface RuntimeDomain { Command enable(); Event consoleAPICalled(); + + Command addBinding(String webdriver_attribute); + + Event bindingCalled(); } diff --git a/java/client/src/org/openqa/selenium/devtools/idealized/runtime/model/BindingCalled.java b/java/client/src/org/openqa/selenium/devtools/idealized/runtime/model/BindingCalled.java new file mode 100644 index 0000000000000..8af88673d2b6e --- /dev/null +++ b/java/client/src/org/openqa/selenium/devtools/idealized/runtime/model/BindingCalled.java @@ -0,0 +1,30 @@ +package org.openqa.selenium.devtools.idealized.runtime.model; + +import org.openqa.selenium.internal.Require; + +public class BindingCalled { + + private final String name; + private final String payload; + + public BindingCalled(String name, String payload) { + this.name = Require.nonNull("Name", name); + this.payload = Require.nonNull("Payload", payload); + } + + public String getName() { + return name; + } + + public String getPayload() { + return payload; + } + + @Override + public String toString() { + return "BindingCalled{" + + "name='" + name + '\'' + + ", payload='" + payload + '\'' + + '}'; + } +} diff --git a/java/client/src/org/openqa/selenium/devtools/noop/NoOpDomains.java b/java/client/src/org/openqa/selenium/devtools/noop/NoOpDomains.java index b50f27ba7a9f0..ae4af515577ec 100644 --- a/java/client/src/org/openqa/selenium/devtools/noop/NoOpDomains.java +++ b/java/client/src/org/openqa/selenium/devtools/noop/NoOpDomains.java @@ -21,6 +21,7 @@ import org.openqa.selenium.devtools.idealized.Domains; import org.openqa.selenium.devtools.idealized.fetch.Fetch; import org.openqa.selenium.devtools.idealized.log.Log; +import org.openqa.selenium.devtools.idealized.page.Page; import org.openqa.selenium.devtools.idealized.runtime.RuntimeDomain; import org.openqa.selenium.devtools.idealized.target.Target; @@ -44,6 +45,11 @@ public Log log() { throw new DevToolsException(WARNING); } + @Override + public Page page() { + throw new DevToolsException(WARNING); + } + @Override public RuntimeDomain runtime() { throw new DevToolsException(WARNING); diff --git a/java/client/src/org/openqa/selenium/devtools/v84/V84Domains.java b/java/client/src/org/openqa/selenium/devtools/v84/V84Domains.java index d2f0f8d62f404..8bc9b2b0082da 100644 --- a/java/client/src/org/openqa/selenium/devtools/v84/V84Domains.java +++ b/java/client/src/org/openqa/selenium/devtools/v84/V84Domains.java @@ -20,6 +20,7 @@ import org.openqa.selenium.devtools.idealized.Domains; import org.openqa.selenium.devtools.idealized.fetch.Fetch; import org.openqa.selenium.devtools.idealized.log.Log; +import org.openqa.selenium.devtools.idealized.page.Page; import org.openqa.selenium.devtools.idealized.runtime.RuntimeDomain; import org.openqa.selenium.devtools.idealized.target.Target; @@ -34,6 +35,11 @@ public Log log() { return new V84Log(); } + @Override + public Page page() { + throw new UnsupportedOperationException("page"); + } + @Override public RuntimeDomain runtime() { return new V84Runtime(); diff --git a/java/client/src/org/openqa/selenium/devtools/v84/V84Runtime.java b/java/client/src/org/openqa/selenium/devtools/v84/V84Runtime.java index 410676982d946..891c2dd6d961d 100644 --- a/java/client/src/org/openqa/selenium/devtools/v84/V84Runtime.java +++ b/java/client/src/org/openqa/selenium/devtools/v84/V84Runtime.java @@ -21,12 +21,15 @@ import org.openqa.selenium.devtools.Command; import org.openqa.selenium.devtools.Event; import org.openqa.selenium.devtools.idealized.runtime.RuntimeDomain; +import org.openqa.selenium.devtools.idealized.runtime.model.BindingCalled; import org.openqa.selenium.devtools.idealized.runtime.model.RemoteObject; +import org.openqa.selenium.devtools.v84.runtime.Runtime; import org.openqa.selenium.devtools.v84.runtime.model.ConsoleAPICalled; import java.math.BigDecimal; import java.time.Instant; import java.util.List; +import java.util.Optional; public class V84Runtime implements RuntimeDomain { @Override @@ -56,4 +59,22 @@ public Event addBinding(String name) { + return Runtime.addBinding(name, Optional.empty()); + } + + @Override + public Event bindingCalled() { + return new Event( + org.openqa.selenium.devtools.v84.runtime.Runtime.bindingCalled().getMethod(), + input -> { + org.openqa.selenium.devtools.v84.runtime.model.BindingCalled res = input.read( + org.openqa.selenium.devtools.v84.runtime.model.BindingCalled.class); + + return new BindingCalled(res.getName(), res.getPayload()); + } + ); + } } diff --git a/java/client/src/org/openqa/selenium/devtools/v85/V85Domains.java b/java/client/src/org/openqa/selenium/devtools/v85/V85Domains.java index 2b4fb39ba69d1..8c3e1841a9274 100644 --- a/java/client/src/org/openqa/selenium/devtools/v85/V85Domains.java +++ b/java/client/src/org/openqa/selenium/devtools/v85/V85Domains.java @@ -20,6 +20,7 @@ import org.openqa.selenium.devtools.idealized.Domains; import org.openqa.selenium.devtools.idealized.fetch.Fetch; import org.openqa.selenium.devtools.idealized.log.Log; +import org.openqa.selenium.devtools.idealized.page.Page; import org.openqa.selenium.devtools.idealized.runtime.RuntimeDomain; import org.openqa.selenium.devtools.idealized.target.Target; @@ -34,6 +35,11 @@ public Log log() { return new V85Log(); } + @Override + public Page page() { + return new V85Page(); + } + @Override public RuntimeDomain runtime() { return new V85Runtime(); diff --git a/java/client/src/org/openqa/selenium/devtools/v85/V85Page.java b/java/client/src/org/openqa/selenium/devtools/v85/V85Page.java new file mode 100644 index 0000000000000..9d3fecd22be34 --- /dev/null +++ b/java/client/src/org/openqa/selenium/devtools/v85/V85Page.java @@ -0,0 +1,36 @@ +package org.openqa.selenium.devtools.v85; + +import com.google.common.collect.ImmutableMap; +import org.openqa.selenium.devtools.Command; +import org.openqa.selenium.devtools.ConverterFunctions; +import org.openqa.selenium.devtools.idealized.page.Page; +import org.openqa.selenium.devtools.v85.page.model.ScriptIdentifier; +import org.openqa.selenium.internal.Require; +import org.openqa.selenium.json.JsonInput; + +import java.util.function.Function; + +public class V85Page implements Page { + + @Override + public Command enable() { + return org.openqa.selenium.devtools.v85.page.Page.enable(); + } + + @Override + public Command addScriptToEvaluateOnNewDocument(String source) { + Require.nonNull("Source", source); + ImmutableMap.Builder params = ImmutableMap.builder(); + params.put("source", source); + + Function mapper = ConverterFunctions.map("identifier", ScriptIdentifier.class); + + return new Command<>( + "Page.addScriptToEvaluateOnNewDocument", + ImmutableMap.of("source", source), + input -> { + ScriptIdentifier actualId = mapper.apply(input); + return new org.openqa.selenium.devtools.idealized.page.model.ScriptIdentifier(actualId); + }); + } +} diff --git a/java/client/src/org/openqa/selenium/devtools/v85/V85Runtime.java b/java/client/src/org/openqa/selenium/devtools/v85/V85Runtime.java index caa97d355febb..ecd9237019deb 100644 --- a/java/client/src/org/openqa/selenium/devtools/v85/V85Runtime.java +++ b/java/client/src/org/openqa/selenium/devtools/v85/V85Runtime.java @@ -21,12 +21,15 @@ import org.openqa.selenium.devtools.Command; import org.openqa.selenium.devtools.Event; import org.openqa.selenium.devtools.idealized.runtime.RuntimeDomain; +import org.openqa.selenium.devtools.idealized.runtime.model.BindingCalled; import org.openqa.selenium.devtools.idealized.runtime.model.RemoteObject; +import org.openqa.selenium.devtools.v85.runtime.Runtime; import org.openqa.selenium.devtools.v85.runtime.model.ConsoleAPICalled; import java.math.BigDecimal; import java.time.Instant; import java.util.List; +import java.util.Optional; public class V85Runtime implements RuntimeDomain { @Override @@ -57,4 +60,22 @@ public Event addBinding(String name) { + return Runtime.addBinding(name, Optional.empty()); + } + + @Override + public Event bindingCalled() { + return new Event( + Runtime.bindingCalled().getMethod(), + input -> { + org.openqa.selenium.devtools.v85.runtime.model.BindingCalled res = input.read( + org.openqa.selenium.devtools.v85.runtime.model.BindingCalled.class); + + return new BindingCalled(res.getName(), res.getPayload()); + } + ); + } } diff --git a/java/client/test/org/openqa/selenium/chromium/LoggingTest.java b/java/client/test/org/openqa/selenium/chromium/LoggingTest.java index 2adac19c7e5b0..27624300d1a2a 100644 --- a/java/client/test/org/openqa/selenium/chromium/LoggingTest.java +++ b/java/client/test/org/openqa/selenium/chromium/LoggingTest.java @@ -19,8 +19,12 @@ import org.junit.Before; import org.junit.Test; +import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.devtools.events.CdpEventTypes; import org.openqa.selenium.devtools.events.ConsoleEvent; +import org.openqa.selenium.devtools.events.DomMutationEvent; import org.openqa.selenium.logging.HasLogEvents; import org.openqa.selenium.testing.JUnit4TestBase; @@ -31,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assumptions.assumeThat; import static org.openqa.selenium.devtools.events.CdpEventTypes.consoleEvent; +import static org.openqa.selenium.devtools.events.CdpEventTypes.domMutation; public class LoggingTest extends JUnit4TestBase { @@ -56,4 +61,25 @@ public void demonstrateLoggingWorks() throws InterruptedException { assertThat(latch.await(10, SECONDS)).isTrue(); assertThat(seen.get().toString()).contains("I like cheese"); } + + @Test + public void watchDomMutations() throws InterruptedException { + HasLogEvents logger = (HasLogEvents) driver; + + AtomicReference seen = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + logger.onLogEvent(domMutation(mutation -> { + seen.set(mutation); + latch.countDown(); + })); + + driver.get(pages.simpleTestPage); + WebElement span = driver.findElement(By.id("span")); + + ((JavascriptExecutor) driver).executeScript("arguments[0].setAttribute('cheese', 'gouda');", span); + + assertThat(latch.await(10, SECONDS)).isTrue(); + assertThat(seen.get().getAttributeName()).isEqualTo("cheese"); + assertThat(seen.get().getCurrentValue()).isEqualTo("gouda"); + } }