setWindowPosition(String targetId, int left, int top) {
+ JsonObject bounds = new JsonObject();
+ bounds.addProperty(FIELD_WINDOW_STATE, WINDOW_STATE_NORMAL);
+ bounds.addProperty(FIELD_LEFT, left);
+ bounds.addProperty(FIELD_TOP, top);
+ return setWindowBounds(targetId, bounds);
+ }
+
+ // ── Target / tab management ───────────────────────────────────────────────
+
+ /**
+ * Returns information about all browser targets (tabs, workers, etc.).
+ *
+ * The response contains a {@code targetInfos} array. Each element has
+ * {@code targetId}, {@code type} ({@code "page"}, {@code "worker"}, etc.),
+ * {@code url}, and {@code title}.
+ *
+ * @return future completing with the CDP response
+ */
+ public CompletableFuture getTargets() {
+ return wsClient.sendCommand(CMD_TARGET_GET_TARGETS);
+ }
+
+ /**
+ * Returns information about a specific target.
+ *
+ * @param targetId the CDP target ID to query
+ * @return future completing with the CDP response (contains {@code targetInfo})
+ */
+ public CompletableFuture getTargetInfo(String targetId) {
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_TARGET_ID, targetId);
+ return wsClient.sendCommand(CMD_TARGET_GET_INFO, params);
+ }
+
+ /**
+ * Brings the specified target to the foreground (activates that tab/window).
+ *
+ * @param targetId the CDP target ID to activate
+ * @return future completing when the target is activated
+ */
+ public CompletableFuture activateTarget(String targetId) {
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_TARGET_ID, targetId);
+ return wsClient.sendCommand(CMD_TARGET_ACTIVATE, params);
+ }
+
+ /**
+ * Creates a new browser tab/window navigated to {@code about:blank}.
+ *
+ * @return future completing with the CDP response (contains {@code targetId})
+ */
+ public CompletableFuture createTarget() {
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_URL, INITIAL_PAGE_URL);
+ return wsClient.sendCommand(CMD_TARGET_CREATE, params);
+ }
+
+ // ── Browser metadata ──────────────────────────────────────────────────────
+
+ /**
+ * Returns the browser version and protocol version information.
+ *
+ * @return future completing with the CDP response
+ */
+ public CompletableFuture getVersion() {
+ return wsClient.sendCommand(CMD_GET_VERSION);
+ }
+
+ // ── Legacy compatibility (kept for any callers using the old API) ──────────
+
+ /** @deprecated Use {@link #maximizeWindow(String)} with an explicit targetId. */
+ @Deprecated(forRemoval = true)
+ public CompletableFuture maximizeCurrentWindow() {
+ throw new UnsupportedOperationException(
+ "maximizeCurrentWindow() requires a targetId. Use maximizeWindow(targetId) instead.");
+ }
+
+ /** @deprecated Use {@link #minimizeWindow(String)} with an explicit targetId. */
+ @Deprecated(forRemoval = true)
+ public CompletableFuture minimizeCurrentWindow() {
+ throw new UnsupportedOperationException(
+ "minimizeCurrentWindow() requires a targetId. Use minimizeWindow(targetId) instead.");
+ }
+
+ /** @deprecated Use {@link #fullscreenWindow(String)} with an explicit targetId. */
+ @Deprecated(forRemoval = true)
+ public CompletableFuture fullscreenCurrentWindow() {
+ throw new UnsupportedOperationException(
+ "fullscreenCurrentWindow() requires a targetId. Use fullscreenWindow(targetId) instead.");
+ }
+}
diff --git a/nihonium/src/main/java/example/cdp/domain/CSSDomain.java b/nihonium/src/main/java/example/cdp/domain/CSSDomain.java
new file mode 100644
index 000000000..b9d7bf1e5
--- /dev/null
+++ b/nihonium/src/main/java/example/cdp/domain/CSSDomain.java
@@ -0,0 +1,42 @@
+package example.cdp.domain;
+
+import com.google.gson.JsonObject;
+
+import example.websocket.NihoniumWebSocketClient;
+
+import java.util.concurrent.CompletableFuture;
+
+public class CSSDomain {
+
+ private final NihoniumWebSocketClient wsClient;
+
+ public CSSDomain(NihoniumWebSocketClient wsClient) {
+ this.wsClient = wsClient;
+ }
+
+ public CompletableFuture enable() {
+ return wsClient.sendCommand("CSS.enable");
+ }
+
+ public CompletableFuture disable() {
+ return wsClient.sendCommand("CSS.disable");
+ }
+
+ public CompletableFuture getComputedStyleForNode(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("CSS.getComputedStyleForNode", params);
+ }
+
+ public CompletableFuture getInlineStylesForNode(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("CSS.getInlineStylesForNode", params);
+ }
+
+ public CompletableFuture getMatchedStylesForNode(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("CSS.getMatchedStylesForNode", params);
+ }
+}
diff --git a/nihonium/src/main/java/example/cdp/domain/DOMDomain.java b/nihonium/src/main/java/example/cdp/domain/DOMDomain.java
new file mode 100644
index 000000000..cbbe99860
--- /dev/null
+++ b/nihonium/src/main/java/example/cdp/domain/DOMDomain.java
@@ -0,0 +1,101 @@
+package example.cdp.domain;
+
+import com.google.gson.JsonObject;
+
+import example.websocket.NihoniumWebSocketClient;
+
+import java.util.concurrent.CompletableFuture;
+
+public class DOMDomain {
+
+ private final NihoniumWebSocketClient wsClient;
+
+ public DOMDomain(NihoniumWebSocketClient wsClient) {
+ this.wsClient = wsClient;
+ }
+
+ public CompletableFuture enable() {
+ return wsClient.sendCommand("DOM.enable");
+ }
+
+ public CompletableFuture disable() {
+ return wsClient.sendCommand("DOM.disable");
+ }
+
+ public CompletableFuture getDocument() {
+ return wsClient.sendCommand("DOM.getDocument");
+ }
+
+ public CompletableFuture querySelector(int nodeId, String selector) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ params.addProperty("selector", selector);
+ return wsClient.sendCommand("DOM.querySelector", params);
+ }
+
+ public CompletableFuture querySelectorAll(int nodeId, String selector) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ params.addProperty("selector", selector);
+ return wsClient.sendCommand("DOM.querySelectorAll", params);
+ }
+
+ public CompletableFuture getBoxModel(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("DOM.getBoxModel", params);
+ }
+
+ public CompletableFuture getAttributes(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("DOM.getAttributes", params);
+ }
+
+ public CompletableFuture setAttributeValue(int nodeId, String name, String value) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ params.addProperty("name", name);
+ params.addProperty("value", value);
+ return wsClient.sendCommand("DOM.setAttributeValue", params);
+ }
+
+ public CompletableFuture focus(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("DOM.focus", params);
+ }
+
+ public CompletableFuture resolveNode(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("DOM.resolveNode", params);
+ }
+
+ public CompletableFuture requestNode(String objectId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("objectId", objectId);
+ return wsClient.sendCommand("DOM.requestNode", params);
+ }
+
+ public CompletableFuture getOuterHTML(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("DOM.getOuterHTML", params);
+ }
+
+ public CompletableFuture scrollIntoViewIfNeeded(int nodeId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ return wsClient.sendCommand("DOM.scrollIntoViewIfNeeded", params);
+ }
+
+ public CompletableFuture describeNode(int nodeId, Integer depth) {
+ JsonObject params = new JsonObject();
+ params.addProperty("nodeId", nodeId);
+ if (depth != null) {
+ params.addProperty("depth", depth);
+ }
+ return wsClient.sendCommand("DOM.describeNode", params);
+ }
+}
diff --git a/nihonium/src/main/java/example/cdp/domain/InputDomain.java b/nihonium/src/main/java/example/cdp/domain/InputDomain.java
new file mode 100644
index 000000000..eae4424ee
--- /dev/null
+++ b/nihonium/src/main/java/example/cdp/domain/InputDomain.java
@@ -0,0 +1,123 @@
+package example.cdp.domain;
+
+import com.google.gson.JsonObject;
+
+import example.websocket.NihoniumWebSocketClient;
+
+import java.util.concurrent.CompletableFuture;
+
+public class InputDomain {
+
+ private static final String CMD_DISPATCH_MOUSE_EVENT = "Input.dispatchMouseEvent";
+ private static final String CMD_DISPATCH_KEY_EVENT = "Input.dispatchKeyEvent";
+ private static final String CMD_INSERT_TEXT = "Input.insertText";
+
+ private static final String MOUSE_PRESSED = "mousePressed";
+ private static final String MOUSE_RELEASED = "mouseReleased";
+ private static final String MOUSE_MOVED = "mouseMoved";
+
+ private static final String KEY_DOWN = "keyDown";
+ private static final String KEY_UP = "keyUp";
+
+ public static final String BUTTON_NONE = "none";
+ public static final String BUTTON_LEFT = "left";
+ public static final String BUTTON_RIGHT = "right";
+ public static final String BUTTON_MIDDLE = "middle";
+
+ public static final int MODIFIER_NONE = 0;
+ public static final int MODIFIER_ALT = 1;
+ public static final int MODIFIER_CTRL = 2;
+ public static final int MODIFIER_META = 4;
+ public static final int MODIFIER_SHIFT = 8;
+ private static final String PARAM_TYPE = "type";
+ private static final String PARAM_X = "x";
+ private static final String PARAM_Y = "y";
+ private static final String PARAM_BUTTON = "button";
+ private static final String PARAM_CLICK_COUNT = "clickCount";
+ private static final String PARAM_KEY = "key";
+ private static final String PARAM_CODE = "code";
+ private static final String PARAM_MODIFIERS = "modifiers";
+ private static final String PARAM_TEXT = "text";
+
+ private final NihoniumWebSocketClient wsClient;
+
+ public InputDomain(NihoniumWebSocketClient wsClient) {
+ this.wsClient = wsClient;
+ }
+
+ public CompletableFuture dispatchMouseEvent(String type, double x, double y, String button,
+ int clickCount) {
+
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_TYPE, type);
+ params.addProperty(PARAM_X, x);
+ params.addProperty(PARAM_Y, y);
+ // CDP requires "button" field always; default to "none" for move events
+ params.addProperty(PARAM_BUTTON, button != null ? button : BUTTON_NONE);
+
+ if (clickCount > 0) {
+ params.addProperty(PARAM_CLICK_COUNT, clickCount);
+ }
+
+ return wsClient.sendCommand(CMD_DISPATCH_MOUSE_EVENT, params);
+ }
+
+ public CompletableFuture mouseMove(double x, double y) {
+ return dispatchMouseEvent(MOUSE_MOVED, x, y, BUTTON_NONE, 0);
+ }
+
+ public CompletableFuture click(double x, double y, String button) {
+ return mouseMove(x, y).thenCompose(v -> dispatchMouseEvent(MOUSE_PRESSED, x, y, button, 1))
+ .thenCompose(v -> dispatchMouseEvent(MOUSE_RELEASED, x, y, button, 1));
+ }
+
+ public CompletableFuture click(double x, double y) {
+ return click(x, y, BUTTON_LEFT);
+ }
+
+ public CompletableFuture doubleClick(double x, double y) {
+ return mouseMove(x, y).thenCompose(v -> dispatchMouseEvent(MOUSE_PRESSED, x, y, BUTTON_LEFT, 1))
+ .thenCompose(v -> dispatchMouseEvent(MOUSE_RELEASED, x, y, BUTTON_LEFT, 1))
+ .thenCompose(v -> dispatchMouseEvent(MOUSE_PRESSED, x, y, BUTTON_LEFT, 2))
+ .thenCompose(v -> dispatchMouseEvent(MOUSE_RELEASED, x, y, BUTTON_LEFT, 2));
+ }
+
+ public CompletableFuture dispatchKeyEvent(String type, String key, String code, int modifiers) {
+
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_TYPE, type);
+
+ if (key != null) {
+ params.addProperty(PARAM_KEY, key);
+ }
+ if (code != null) {
+ params.addProperty(PARAM_CODE, code);
+ }
+ if (modifiers != MODIFIER_NONE) {
+ params.addProperty(PARAM_MODIFIERS, modifiers);
+ }
+
+ return wsClient.sendCommand(CMD_DISPATCH_KEY_EVENT, params);
+ }
+
+ public CompletableFuture pressKey(String key) {
+ return dispatchKeyEvent(KEY_DOWN, key, null, MODIFIER_NONE)
+ .thenCompose(v -> dispatchKeyEvent(KEY_UP, key, null, MODIFIER_NONE));
+ }
+
+ public CompletableFuture insertText(String text) {
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_TEXT, text);
+ return wsClient.sendCommand(CMD_INSERT_TEXT, params);
+ }
+
+ public CompletableFuture typeText(String text) {
+ CompletableFuture chain = CompletableFuture.completedFuture(null);
+ for (char c : text.toCharArray()) {
+ String key = String.valueOf(c);
+ chain = chain.thenCompose(v -> dispatchKeyEvent(KEY_DOWN, key, null, MODIFIER_NONE))
+ .thenCompose(v -> dispatchKeyEvent(KEY_UP, key, null, MODIFIER_NONE)).thenApply(v -> null);
+ }
+ return chain;
+ }
+}
diff --git a/nihonium/src/main/java/example/cdp/domain/NetworkDomain.java b/nihonium/src/main/java/example/cdp/domain/NetworkDomain.java
new file mode 100644
index 000000000..a0aa857df
--- /dev/null
+++ b/nihonium/src/main/java/example/cdp/domain/NetworkDomain.java
@@ -0,0 +1,130 @@
+package example.cdp.domain;
+
+import com.google.gson.JsonObject;
+
+import example.websocket.NihoniumWebSocketClient;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+public class NetworkDomain {
+
+ private static final String CMD_ENABLE = "Network.enable";
+ private static final String CMD_DISABLE = "Network.disable";
+ private static final String CMD_GET_COOKIES = "Network.getCookies";
+ private static final String CMD_SET_COOKIE = "Network.setCookie";
+ private static final String CMD_DELETE_COOKIES = "Network.deleteCookies";
+ private static final String CMD_CLEAR_BROWSER_COOKIES = "Network.clearBrowserCookies";
+ private static final String CMD_SET_USER_AGENT = "Network.setUserAgentOverride";
+ private static final String CMD_SET_CACHE_DISABLED = "Network.setCacheDisabled";
+ private static final String CMD_SET_EXTRA_HTTP_HEADERS = "Network.setExtraHTTPHeaders";
+ private static final String CMD_GET_RESPONSE_BODY = "Network.getResponseBody";
+
+ private static final String EVENT_REQUEST_WILL_BE_SENT = "Network.requestWillBeSent";
+ private static final String EVENT_LOADING_FINISHED = "Network.loadingFinished";
+ private static final String EVENT_LOADING_FAILED = "Network.loadingFailed";
+
+ private static final String PARAM_USER_AGENT = "userAgent";
+ private static final String PARAM_CACHE_DISABLED = "cacheDisabled";
+ private static final String PARAM_HEADERS = "headers";
+ private static final String PARAM_REQUEST_ID = "requestId";
+ private static final String PARAM_NAME = "name";
+ private static final String PARAM_VALUE = "value";
+ private static final String PARAM_DOMAIN = "domain";
+ private static final String PARAM_PATH = "path";
+ private static final String PARAM_SECURE = "secure";
+ private static final String PARAM_HTTP_ONLY = "httpOnly";
+ private static final String PARAM_EXPIRES = "expires";
+
+ private final NihoniumWebSocketClient wsClient;
+
+ public NetworkDomain(NihoniumWebSocketClient wsClient) {
+ this.wsClient = wsClient;
+ }
+
+ public CompletableFuture enable() {
+ return wsClient.sendCommand(CMD_ENABLE);
+ }
+
+ public CompletableFuture disable() {
+ return wsClient.sendCommand(CMD_DISABLE);
+ }
+
+ public CompletableFuture getCookies() {
+ return wsClient.sendCommand(CMD_GET_COOKIES);
+ }
+
+ public CompletableFuture setCookie(
+ String name, String value,
+ String domain, String path,
+ boolean secure, boolean httpOnly,
+ long expires) {
+
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_NAME, name);
+ params.addProperty(PARAM_VALUE, value);
+
+ if (domain != null) {
+ params.addProperty(PARAM_DOMAIN, domain);
+ }
+ if (path != null) {
+ params.addProperty(PARAM_PATH, path);
+ }
+ params.addProperty(PARAM_SECURE, secure);
+ params.addProperty(PARAM_HTTP_ONLY, httpOnly);
+ if (expires > 0) {
+ params.addProperty(PARAM_EXPIRES, expires);
+ }
+
+ return wsClient.sendCommand(CMD_SET_COOKIE, params);
+ }
+
+ public CompletableFuture deleteCookies(String name, String domain) {
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_NAME, name);
+ if (domain != null) {
+ params.addProperty(PARAM_DOMAIN, domain);
+ }
+ return wsClient.sendCommand(CMD_DELETE_COOKIES, params);
+ }
+
+ public CompletableFuture clearBrowserCookies() {
+ return wsClient.sendCommand(CMD_CLEAR_BROWSER_COOKIES);
+ }
+
+ public CompletableFuture setUserAgentOverride(String userAgent) {
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_USER_AGENT, userAgent);
+ return wsClient.sendCommand(CMD_SET_USER_AGENT, params);
+ }
+
+ public CompletableFuture setCacheDisabled(boolean cacheDisabled) {
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_CACHE_DISABLED, cacheDisabled);
+ return wsClient.sendCommand(CMD_SET_CACHE_DISABLED, params);
+ }
+
+ public CompletableFuture setExtraHTTPHeaders(JsonObject headers) {
+ JsonObject params = new JsonObject();
+ params.add(PARAM_HEADERS, headers);
+ return wsClient.sendCommand(CMD_SET_EXTRA_HTTP_HEADERS, params);
+ }
+
+ public CompletableFuture getResponseBody(String requestId) {
+ JsonObject params = new JsonObject();
+ params.addProperty(PARAM_REQUEST_ID, requestId);
+ return wsClient.sendCommand(CMD_GET_RESPONSE_BODY, params);
+ }
+
+ public void subscribeToRequestWillBeSent(Consumer handler) {
+ wsClient.subscribeToEvent(EVENT_REQUEST_WILL_BE_SENT, handler);
+ }
+
+ public void subscribeToLoadingFinished(Consumer handler) {
+ wsClient.subscribeToEvent(EVENT_LOADING_FINISHED, handler);
+ }
+
+ public void subscribeToLoadingFailed(Consumer handler) {
+ wsClient.subscribeToEvent(EVENT_LOADING_FAILED, handler);
+ }
+}
diff --git a/nihonium/src/main/java/example/cdp/domain/PageDomain.java b/nihonium/src/main/java/example/cdp/domain/PageDomain.java
new file mode 100644
index 000000000..d85e9b925
--- /dev/null
+++ b/nihonium/src/main/java/example/cdp/domain/PageDomain.java
@@ -0,0 +1,77 @@
+package example.cdp.domain;
+
+import com.google.gson.JsonObject;
+
+import example.websocket.NihoniumWebSocketClient;
+
+import java.util.concurrent.CompletableFuture;
+
+public class PageDomain {
+
+ private final NihoniumWebSocketClient wsClient;
+
+ public PageDomain(NihoniumWebSocketClient wsClient) {
+ this.wsClient = wsClient;
+ }
+
+ public CompletableFuture enable() {
+ return wsClient.sendCommand("Page.enable");
+ }
+
+ public CompletableFuture disable() {
+ return wsClient.sendCommand("Page.disable");
+ }
+
+ public CompletableFuture navigate(String url) {
+ JsonObject params = new JsonObject();
+ params.addProperty("url", url);
+ return wsClient.sendCommand("Page.navigate", params);
+ }
+
+ public CompletableFuture reload(boolean ignoreCache) {
+ JsonObject params = new JsonObject();
+ params.addProperty("ignoreCache", ignoreCache);
+ return wsClient.sendCommand("Page.reload", params);
+ }
+
+ public CompletableFuture reload() {
+ return reload(false);
+ }
+
+ public CompletableFuture captureScreenshot(String format, Integer quality) {
+ JsonObject params = new JsonObject();
+ params.addProperty("format", format);
+ if (quality != null) {
+ params.addProperty("quality", quality);
+ }
+ return wsClient.sendCommand("Page.captureScreenshot", params);
+ }
+
+ public CompletableFuture captureScreenshot() {
+ return captureScreenshot("png", null);
+ }
+
+ public CompletableFuture setLifecycleEventsEnabled(boolean enabled) {
+ JsonObject params = new JsonObject();
+ params.addProperty("enabled", enabled);
+ return wsClient.sendCommand("Page.setLifecycleEventsEnabled", params);
+ }
+
+ public CompletableFuture getLayoutMetrics() {
+ return wsClient.sendCommand("Page.getLayoutMetrics");
+ }
+
+ public CompletableFuture getFrameTree() {
+ return wsClient.sendCommand("Page.getFrameTree");
+ }
+
+ public CompletableFuture getNavigationHistory() {
+ return wsClient.sendCommand("Page.getNavigationHistory");
+ }
+
+ public CompletableFuture navigateToHistoryEntry(int entryId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("entryId", entryId);
+ return wsClient.sendCommand("Page.navigateToHistoryEntry", params);
+ }
+}
diff --git a/nihonium/src/main/java/example/cdp/domain/RuntimeDomain.java b/nihonium/src/main/java/example/cdp/domain/RuntimeDomain.java
new file mode 100644
index 000000000..45a638667
--- /dev/null
+++ b/nihonium/src/main/java/example/cdp/domain/RuntimeDomain.java
@@ -0,0 +1,82 @@
+package example.cdp.domain;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import example.websocket.NihoniumWebSocketClient;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+public class RuntimeDomain {
+
+ private final NihoniumWebSocketClient wsClient;
+
+ public RuntimeDomain(NihoniumWebSocketClient wsClient) {
+ this.wsClient = wsClient;
+ }
+
+ public CompletableFuture enable() {
+ return wsClient.sendCommand("Runtime.enable");
+ }
+
+ public CompletableFuture disable() {
+ return wsClient.sendCommand("Runtime.disable");
+ }
+
+ public CompletableFuture evaluate(String expression, boolean awaitPromise) {
+ JsonObject params = new JsonObject();
+ params.addProperty("expression", expression);
+ params.addProperty("awaitPromise", awaitPromise);
+ params.addProperty("returnByValue", false);
+ return wsClient.sendCommand("Runtime.evaluate", params);
+ }
+
+ public CompletableFuture evaluateAndReturnByValue(String expression, boolean awaitPromise) {
+ JsonObject params = new JsonObject();
+ params.addProperty("expression", expression);
+ params.addProperty("awaitPromise", awaitPromise);
+ params.addProperty("returnByValue", true);
+ return wsClient.sendCommand("Runtime.evaluate", params);
+ }
+
+ public CompletableFuture evaluate(String expression) {
+ return evaluate(expression, false);
+ }
+
+ public CompletableFuture callFunctionOn(String objectId, String functionDeclaration, List arguments) {
+ JsonObject params = new JsonObject();
+ params.addProperty("objectId", objectId);
+ params.addProperty("functionDeclaration", functionDeclaration);
+
+ if (arguments != null && !arguments.isEmpty()) {
+ JsonArray argsArray = new JsonArray();
+ arguments.forEach(argsArray::add);
+ params.add("arguments", argsArray);
+ }
+
+ return wsClient.sendCommand("Runtime.callFunctionOn", params);
+ }
+
+ public CompletableFuture callFunctionOn(String objectId, String functionDeclaration) {
+ return callFunctionOn(objectId, functionDeclaration, null);
+ }
+
+ public CompletableFuture getProperties(String objectId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("objectId", objectId);
+ return wsClient.sendCommand("Runtime.getProperties", params);
+ }
+
+ public CompletableFuture releaseObject(String objectId) {
+ JsonObject params = new JsonObject();
+ params.addProperty("objectId", objectId);
+ return wsClient.sendCommand("Runtime.releaseObject", params);
+ }
+
+ public CompletableFuture releaseObjectGroup(String objectGroup) {
+ JsonObject params = new JsonObject();
+ params.addProperty("objectGroup", objectGroup);
+ return wsClient.sendCommand("Runtime.releaseObjectGroup", params);
+ }
+}
diff --git a/nihonium/src/main/java/example/chrome/ChromeDriver.java b/nihonium/src/main/java/example/chrome/ChromeDriver.java
new file mode 100644
index 000000000..a840de04d
--- /dev/null
+++ b/nihonium/src/main/java/example/chrome/ChromeDriver.java
@@ -0,0 +1,364 @@
+package example.chrome;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import example.By;
+import example.WebDriver;
+import example.WebElement;
+import example.browser.BrowserLauncher;
+import example.browser.BrowserOptions;
+import example.browser.LaunchResult;
+import example.cdp.domain.BrowserDomain;
+import example.cdp.domain.CSSDomain;
+import example.cdp.domain.DOMDomain;
+import example.cdp.domain.InputDomain;
+import example.cdp.domain.NetworkDomain;
+import example.cdp.domain.PageDomain;
+import example.cdp.domain.RuntimeDomain;
+import example.exception.BrowserLaunchException;
+import example.exception.CDPException;
+import example.exception.TimeoutException;
+import example.network.NetworkMonitor;
+import example.wait.WaitConfig;
+import example.websocket.NihoniumWebSocketClient;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+public class ChromeDriver implements WebDriver {
+
+ private static final String TARGET_TYPE_PAGE = "page";
+ private static final String TARGET_FIELD_TYPE = "type";
+ private static final String TARGET_FIELD_ID = "targetId";
+ private static final String TARGET_INFOS_FIELD = "targetInfos";
+
+ private static final String WS_TARGET_PATH_PREFIX = "/devtools/page/";
+
+ private final PageDomain pageDomain;
+ private final DOMDomain domDomain;
+ private final RuntimeDomain runtimeDomain;
+ private final InputDomain inputDomain;
+ private final NetworkDomain networkDomain;
+ private final CSSDomain cssDomain;
+ private final BrowserDomain browserDomain;
+
+ private final BrowserLauncher launcher;
+ private final NihoniumWebSocketClient wsClient;
+ private final WaitConfig waitConfig;
+ private final NetworkMonitor networkMonitor;
+
+ private final String currentTargetId;
+
+ private volatile boolean closed = false;
+
+ public ChromeDriver() {
+ this(new ChromeOptions(), WaitConfig.defaultConfig());
+ }
+
+ public ChromeDriver(ChromeOptions chromeOptions) {
+ this(chromeOptions, WaitConfig.defaultConfig());
+ }
+
+ public ChromeDriver(ChromeOptions chromeOptions, WaitConfig waitConfig) {
+ this.waitConfig = waitConfig;
+
+ try {
+ BrowserOptions options = BrowserOptions.builder().browserType(chromeOptions.getBrowserType())
+ .browserVersion(chromeOptions.getBrowserVersion()).autoDownload(chromeOptions.isAutoDownload())
+ .binaryPath(chromeOptions.getBinaryPath()).headless(chromeOptions.isHeadless())
+ .windowSize(chromeOptions.getWindowWidth(), chromeOptions.getWindowHeight())
+ .addArguments(chromeOptions.getArguments()).build();
+
+ launcher = new BrowserLauncher(options);
+ LaunchResult launchResult = launcher.launch();
+
+ this.currentTargetId = extractTargetId(launchResult.getWebSocketUrl());
+
+ URI wsUri = new URI(launchResult.getWebSocketUrl());
+ wsClient = new NihoniumWebSocketClient(wsUri);
+ wsClient.connectBlocking();
+
+ boolean connected = wsClient.awaitConnection(10, TimeUnit.SECONDS);
+ if (!connected) {
+ throw new BrowserLaunchException("Timed out waiting for CDP WebSocket connection");
+ }
+
+ pageDomain = new PageDomain(wsClient);
+ domDomain = new DOMDomain(wsClient);
+ runtimeDomain = new RuntimeDomain(wsClient);
+ inputDomain = new InputDomain(wsClient);
+ networkDomain = new NetworkDomain(wsClient);
+ cssDomain = new CSSDomain(wsClient);
+ browserDomain = new BrowserDomain(wsClient);
+
+ networkMonitor = new NetworkMonitor(networkDomain);
+ if (waitConfig.isWaitForNetworkIdle()) {
+ networkMonitor.enable();
+ }
+
+ pageDomain.enable().join();
+ domDomain.enable().join();
+ runtimeDomain.enable().join();
+
+ } catch (BrowserLaunchException e) {
+ throw e;
+ } catch (Exception e) {
+ cleanup();
+ throw new BrowserLaunchException("Failed to initialise ChromeDriver", e);
+ }
+ }
+
+ @Override
+ public void get(String url) {
+ try {
+ pageDomain.navigate(url).join();
+ } catch (Exception e) {
+ throw new CDPException("Failed to navigate to: " + url, e);
+ }
+ }
+
+ @Override
+ public String getCurrentUrl() {
+ waitForPageReady();
+ try {
+ JsonObject result = runtimeDomain.evaluate("window.location.href", false).join();
+ return result.getAsJsonObject("result").get("value").getAsString();
+ } catch (Exception e) {
+ throw new CDPException("Failed to get current URL", e);
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ waitForPageReady();
+ try {
+ JsonObject result = runtimeDomain.evaluate("document.title", false).join();
+ return result.getAsJsonObject("result").get("value").getAsString();
+ } catch (Exception e) {
+ throw new CDPException("Failed to get page title", e);
+ }
+ }
+
+ @Override
+ public String getPageSource() {
+ waitForPageReady();
+ try {
+ JsonObject result = runtimeDomain.evaluate("document.documentElement.outerHTML", false).join();
+ return result.getAsJsonObject("result").get("value").getAsString();
+ } catch (Exception e) {
+ throw new CDPException("Failed to get page source", e);
+ }
+ }
+
+ private static final String DOM_ROOT = "root";
+ private static final String DOM_NODE_ID = "nodeId";
+ private static final String DOM_NODE_IDS = "nodeIds";
+ private static final String RUNTIME_RESULT = "result";
+ private static final String RUNTIME_VALUE = "value";
+
+ @Override
+ public WebElement findElement(By by) {
+ return new ChromeElement(by, domDomain, runtimeDomain, inputDomain, cssDomain, waitConfig, networkMonitor);
+ }
+
+ @Override
+ public List findElements(By by) {
+ try {
+ String cssSelector = by.toCssSelector();
+ if (cssSelector != null) {
+ JsonObject docResult = domDomain.getDocument().join();
+ int documentNode = docResult.getAsJsonObject(DOM_ROOT).get(DOM_NODE_ID).getAsInt();
+ JsonObject r = domDomain.querySelectorAll(documentNode, cssSelector).join();
+ JsonArray nodeIds = r.getAsJsonArray(DOM_NODE_IDS);
+ List elements = new ArrayList<>();
+ if (nodeIds != null) {
+ for (int i = 0; i < nodeIds.size(); i++) {
+ elements.add(new ChromeElement(By.index(by, i), domDomain, runtimeDomain, inputDomain,
+ cssDomain, waitConfig, networkMonitor));
+ }
+ }
+ return elements;
+ }
+
+ if (by.isXPath()) {
+ return findElementsByXPath(by.getSelector());
+ }
+
+ return new ArrayList<>();
+ } catch (Exception e) {
+ throw new CDPException("Failed to find elements: " + by, e);
+ }
+ }
+
+ private List findElementsByXPath(String xpath) {
+ String escaped = xpath.replace("\\", "\\\\").replace("'", "\\'");
+ String countScript = "document.evaluate('" + escaped + "', document, null, "
+ + "XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength";
+ try {
+ JsonObject result = runtimeDomain.evaluate(countScript, false).join();
+ int count = result.getAsJsonObject(RUNTIME_RESULT).get(RUNTIME_VALUE).getAsInt();
+ List elements = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ elements.add(new ChromeElement(By.index(By.xpath(xpath), i), domDomain, runtimeDomain, inputDomain,
+ cssDomain, waitConfig, networkMonitor));
+ }
+ return elements;
+ } catch (Exception e) {
+ return new ArrayList<>();
+ }
+ }
+
+ @Override
+ public Set getWindowHandles() {
+ try {
+ JsonObject response = browserDomain.getTargets().join();
+ JsonArray targets = response.getAsJsonArray(TARGET_INFOS_FIELD);
+ Set handles = new HashSet<>();
+ for (JsonElement el : targets) {
+ JsonObject target = el.getAsJsonObject();
+ if (TARGET_TYPE_PAGE.equals(target.get(TARGET_FIELD_TYPE).getAsString())) {
+ handles.add(target.get(TARGET_FIELD_ID).getAsString());
+ }
+ }
+ return handles;
+ } catch (Exception e) {
+ throw new CDPException("Failed to get window handles", e);
+ }
+ }
+
+ @Override
+ public String getWindowHandle() {
+ return currentTargetId;
+ }
+
+ @Override
+ public void close() {
+ cleanup();
+ }
+
+ @Override
+ public void quit() {
+ cleanup();
+ }
+
+ @Override
+ public TargetLocator switchTo() {
+ return new ChromeTargetLocator(this);
+ }
+
+ @Override
+ public Navigation navigate() {
+ return new ChromeNavigation(this);
+ }
+
+ @Override
+ public Options manage() {
+ return new ChromeManageOptions(this);
+ }
+
+ PageDomain getPageDomain() {
+ return pageDomain;
+ }
+
+ BrowserDomain getBrowserDomain() {
+ return browserDomain;
+ }
+
+ RuntimeDomain getRuntimeDomain() {
+ return runtimeDomain;
+ }
+
+ DOMDomain getDomDomain() {
+ return domDomain;
+ }
+
+ NetworkDomain getNetworkDomain() {
+ return networkDomain;
+ }
+
+ String getCurrentTargetId() {
+ return currentTargetId;
+ }
+
+ private void waitForPageReady() {
+ long deadline = System.currentTimeMillis() + waitConfig.getTimeoutMillis();
+
+ while (System.currentTimeMillis() < deadline) {
+ try {
+ JsonObject result = runtimeDomain.evaluate("document.readyState", false).join();
+ String readyState = result.getAsJsonObject("result").get("value").getAsString();
+ if ("complete".equals(readyState)) {
+ // DOM is fully parsed — optionally drain in-flight network requests
+ if (waitConfig.isWaitForNetworkIdle() && networkMonitor != null) {
+ waitForNetworkIdleWithDeadline(deadline);
+ }
+ return;
+ }
+ } catch (Exception ignored) {
+ // DOM not reachable yet — keep polling
+ }
+
+ try {
+ Thread.sleep(waitConfig.getPollingIntervalMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TimeoutException("Interrupted while waiting for page to be ready");
+ }
+ }
+
+ throw new TimeoutException(
+ "Page did not reach readyState=complete within " + waitConfig.getTimeoutMillis() + " ms");
+ }
+
+ private void waitForNetworkIdleWithDeadline(long deadline) {
+ while (System.currentTimeMillis() < deadline) {
+ if (networkMonitor.isNetworkIdle(waitConfig.getNetworkIdleMaxConnections(),
+ waitConfig.getNetworkIdleDurationMillis())) {
+ return;
+ }
+ try {
+ Thread.sleep(waitConfig.getPollingIntervalMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TimeoutException("Interrupted while waiting for network idle");
+ }
+ }
+ throw new TimeoutException("Network did not become idle within " + waitConfig.getTimeoutMillis() + " ms");
+ }
+
+ private static String extractTargetId(String webSocketUrl) {
+ URI uri = URI.create(webSocketUrl);
+ String path = uri.getPath();
+ int idx = path.lastIndexOf(WS_TARGET_PATH_PREFIX);
+ if (idx < 0) {
+ throw new IllegalArgumentException("Cannot extract targetId from WebSocket URL: " + webSocketUrl);
+ }
+ return path.substring(idx + WS_TARGET_PATH_PREFIX.length());
+ }
+
+ private void cleanup() {
+ if (closed)
+ return;
+ closed = true;
+
+ try {
+ if (wsClient != null && wsClient.isOpen()) {
+ wsClient.close();
+ }
+ } catch (Exception ignored) {
+ }
+
+ try {
+ if (launcher != null) {
+ launcher.shutdown();
+ }
+ } catch (Exception ignored) {
+ }
+ }
+}
diff --git a/nihonium/src/main/java/example/chrome/ChromeElement.java b/nihonium/src/main/java/example/chrome/ChromeElement.java
new file mode 100644
index 000000000..1855e3cde
--- /dev/null
+++ b/nihonium/src/main/java/example/chrome/ChromeElement.java
@@ -0,0 +1,694 @@
+package example.chrome;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import example.By;
+import example.Dimension;
+import example.Point;
+import example.Rectangle;
+import example.WebElement;
+import example.cdp.domain.CSSDomain;
+import example.cdp.domain.DOMDomain;
+import example.cdp.domain.InputDomain;
+import example.cdp.domain.RuntimeDomain;
+import example.exception.ElementNotFoundException;
+import example.network.NetworkMonitor;
+import example.wait.AutoWaitEngine;
+import example.wait.ElementWaitConditions;
+import example.wait.WaitConfig;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ChromeElement implements WebElement {
+
+ private static final Logger log = LoggerFactory.getLogger(ChromeElement.class);
+
+ private static final long SCROLL_STABILITY_TIMEOUT_MILLIS = 200L;
+
+ private static final long SCROLL_STABILITY_POLL_MILLIS = 20L;
+
+ private static final int SCROLL_STABLE_CHECKS_REQUIRED = 3;
+
+ // ── CDP box-model content-quad indices ────────────────────────────────────
+ // The CDP `content` quad is an 8-element flat array of (x,y) pairs:
+ // [x0,y0, x1,y1, x2,y2, x3,y3] (top-left, top-right, bottom-right, bottom-left)
+
+ private static final int BOX_X_TOP_LEFT = 0;
+ private static final int BOX_Y_TOP_LEFT = 1;
+ private static final int BOX_X_BOTTOM_RIGHT = 4;
+ private static final int BOX_Y_BOTTOM_RIGHT = 5;
+
+ private static final String SCRIPT_CLEAR_INPUT = "function() {"
+ + " var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');"
+ + " if (nativeSetter && nativeSetter.set) {" + " nativeSetter.set.call(this, '');" + " } else {"
+ + " this.value = '';" + " }" + " this.dispatchEvent(new Event('input', {bubbles: true}));"
+ + " this.dispatchEvent(new Event('change', {bubbles: true}));" + "}";
+
+ private static final String SCRIPT_GET_TEXT = "function() { return this.textContent; }";
+ private static final String SCRIPT_IS_VISIBLE = "function() { return !!(this.offsetWidth || this.offsetHeight || this.getClientRects().length); }";
+
+ // ── CDP JSON keys ─────────────────────────────────────────────────────────
+
+ private static final String KEY_ROOT = "root";
+ private static final String KEY_NODE_ID = "nodeId";
+ private static final String KEY_NODE_IDS = "nodeIds";
+ private static final String KEY_NODE_NAME = "nodeName";
+ private static final String KEY_NODE = "node";
+ private static final String KEY_OBJECT = "object";
+ private static final String KEY_OBJECT_ID = "objectId";
+ private static final String KEY_RESULT = "result";
+ private static final String KEY_VALUE = "value";
+ private static final String KEY_TYPE = "type";
+ private static final String KEY_MODEL = "model";
+ private static final String KEY_CONTENT = "content";
+ private static final String KEY_NAME = "name";
+
+ private static final String TYPE_OBJECT = "object";
+ private static final String ATTR_CHECKED = "checked";
+ private static final String ATTR_DISABLED = "disabled";
+ private static final String CSS_DISPLAY = "display";
+ private static final String CSS_VISIBILITY = "visibility";
+ private static final String CSS_OPACITY = "opacity";
+ private static final String CSS_DISPLAY_NONE = "none";
+ private static final String CSS_VISIBILITY_HIDDEN = "hidden";
+ private static final String CSS_OPACITY_ZERO = "0";
+
+ private final By locator;
+ private final DOMDomain domDomain;
+ private final RuntimeDomain runtimeDomain;
+ private final InputDomain inputDomain;
+ private final CSSDomain cssDomain;
+ private final WaitConfig waitConfig;
+ private final NetworkMonitor networkMonitor;
+ private final AutoWaitEngine autoWaitEngine;
+
+ public ChromeElement(By locator, DOMDomain domDomain, RuntimeDomain runtimeDomain, InputDomain inputDomain,
+ CSSDomain cssDomain) {
+ this(locator, domDomain, runtimeDomain, inputDomain, cssDomain, WaitConfig.defaultConfig(), null);
+ }
+
+ public ChromeElement(By locator, DOMDomain domDomain, RuntimeDomain runtimeDomain, InputDomain inputDomain,
+ CSSDomain cssDomain, WaitConfig waitConfig) {
+ this(locator, domDomain, runtimeDomain, inputDomain, cssDomain, waitConfig, null);
+ }
+
+ public ChromeElement(By locator, DOMDomain domDomain, RuntimeDomain runtimeDomain, InputDomain inputDomain,
+ CSSDomain cssDomain, WaitConfig waitConfig, NetworkMonitor networkMonitor) {
+ this.locator = locator;
+ this.domDomain = domDomain;
+ this.runtimeDomain = runtimeDomain;
+ this.inputDomain = inputDomain;
+ this.cssDomain = cssDomain;
+ this.waitConfig = waitConfig;
+ this.networkMonitor = networkMonitor;
+
+ ElementWaitConditions conditions = new ElementWaitConditions(domDomain, cssDomain, runtimeDomain);
+ this.autoWaitEngine = new AutoWaitEngine(conditions, waitConfig, networkMonitor);
+ }
+
+ @Override
+ public void click() {
+ autoWaitEngine.waitForElementClickable(locator);
+ try {
+ int nodeId = resolveNodeId();
+ domDomain.scrollIntoViewIfNeeded(nodeId).join();
+ waitForScrollStability(nodeId);
+
+ JsonObject boxModel = domDomain.getBoxModel(nodeId).join();
+ double[] center = extractCenter(boxModel);
+
+ inputDomain.click(center[0], center[1]).join();
+ log.debug("Clicked {} at ({}, {})", locator, center[0], center[1]);
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to click element: " + locator, e);
+ }
+ }
+
+ @Override
+ public void sendKeys(CharSequence... keysToSend) {
+ if (keysToSend == null || keysToSend.length == 0) {
+ return;
+ }
+ autoWaitEngine.waitForElementInteractable(locator);
+ try {
+ StringBuilder text = new StringBuilder();
+ for (CharSequence seq : keysToSend) {
+ if (seq != null) {
+ text.append(seq);
+ }
+ }
+ // Equivalent of text.isEmpty() in Java 11:
+ if (text.length() == 0) {
+ return;
+ }
+
+ int nodeId = resolveNodeId();
+ domDomain.focus(nodeId).join();
+ inputDomain.insertText(text.toString()).join();
+ log.debug("Sent {} char(s) to {}", text.length(), locator);
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to send keys to element: " + locator, e);
+ }
+ }
+
+ @Override
+ public void clear() {
+ autoWaitEngine.waitForElementInteractable(locator);
+ try {
+ int nodeId = resolveNodeId();
+ String objId = resolveObjectId(nodeId);
+ runtimeDomain.callFunctionOn(objId, SCRIPT_CLEAR_INPUT, null).join();
+ runtimeDomain.releaseObject(objId).join();
+ log.debug("Cleared {}", locator);
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to clear element: " + locator, e);
+ }
+ }
+
+ @Override
+ public void submit() {
+ try {
+ int nodeId = resolveNodeId();
+ String objId = resolveObjectId(nodeId);
+ runtimeDomain.callFunctionOn(objId, "function() { this.form ? this.form.submit() : this.submit(); }", null)
+ .join();
+ runtimeDomain.releaseObject(objId).join();
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to submit element: " + locator, e);
+ }
+ }
+
+ @Override
+ public String getTagName() {
+ try {
+ int nodeId = resolveNodeId();
+ JsonObject result = domDomain.describeNode(nodeId, 0).join();
+ return result.getAsJsonObject(KEY_NODE).get(KEY_NODE_NAME).getAsString().toLowerCase();
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to get tag name for element: " + locator, e);
+ }
+ }
+
+ @Override
+ public String getAttribute(String name) {
+ try {
+ int nodeId = resolveNodeId();
+ JsonObject result = domDomain.getAttributes(nodeId).join();
+ JsonArray attrs = result.getAsJsonArray("attributes");
+
+ for (int i = 0; i < attrs.size() - 1; i += 2) {
+ if (attrs.get(i).getAsString().equals(name)) {
+ return attrs.get(i + 1).getAsString();
+ }
+ }
+ return null;
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to get attribute '" + name + "' for: " + locator, e);
+ }
+ }
+
+ @Override
+ public boolean isSelected() {
+ try {
+ return getAttribute(ATTR_CHECKED) != null;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean isEnabled() {
+ try {
+ return getAttribute(ATTR_DISABLED) == null;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public String getText() {
+ autoWaitEngine.waitForElementVisible(locator);
+ try {
+ int nodeId = resolveNodeId();
+ String objId = resolveObjectId(nodeId);
+
+ JsonObject result = runtimeDomain.callFunctionOn(objId, SCRIPT_GET_TEXT, null).join();
+ runtimeDomain.releaseObject(objId).join();
+
+ JsonObject resultObj = result.getAsJsonObject(KEY_RESULT);
+ return resultObj.has(KEY_VALUE) ? resultObj.get(KEY_VALUE).getAsString() : "";
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to get text for element: " + locator, e);
+ }
+ }
+
+ @Override
+ public boolean isDisplayed() {
+ try {
+ String display = getCssValue(CSS_DISPLAY);
+ String visibility = getCssValue(CSS_VISIBILITY);
+ String opacity = getCssValue(CSS_OPACITY);
+
+ return !CSS_DISPLAY_NONE.equals(display) && !CSS_VISIBILITY_HIDDEN.equals(visibility)
+ && !CSS_OPACITY_ZERO.equals(opacity);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public Point getLocation() {
+ try {
+ int nodeId = resolveNodeId();
+ JsonObject model = extractBoxModel(nodeId);
+ JsonArray content = model.getAsJsonArray(KEY_CONTENT);
+ return new Point(content.get(BOX_X_TOP_LEFT).getAsInt(), content.get(BOX_Y_TOP_LEFT).getAsInt());
+ } catch (Exception e) {
+ return new Point(0, 0);
+ }
+ }
+
+ @Override
+ public Dimension getSize() {
+ try {
+ int nodeId = resolveNodeId();
+ JsonObject model = extractBoxModel(nodeId);
+ JsonArray content = model.getAsJsonArray(KEY_CONTENT);
+
+ int width = content.get(BOX_X_BOTTOM_RIGHT).getAsInt() - content.get(BOX_X_TOP_LEFT).getAsInt();
+ int height = content.get(BOX_Y_BOTTOM_RIGHT).getAsInt() - content.get(BOX_Y_TOP_LEFT).getAsInt();
+ return new Dimension(width, height);
+ } catch (Exception e) {
+ return new Dimension(0, 0);
+ }
+ }
+
+ @Override
+ public Rectangle getRect() {
+ return new Rectangle(getLocation(), getSize());
+ }
+
+ @Override
+ public String getCssValue(String propertyName) {
+ try {
+ int nodeId = resolveNodeId();
+ JsonObject result = cssDomain.getComputedStyleForNode(nodeId).join();
+ JsonArray computedStyle = result.getAsJsonArray("computedStyle");
+
+ for (JsonElement el : computedStyle) {
+ JsonObject prop = el.getAsJsonObject();
+ if (prop.get(KEY_NAME).getAsString().equals(propertyName)) {
+ return prop.get(KEY_VALUE).getAsString();
+ }
+ }
+ return "";
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to get CSS value '" + propertyName + "' for: " + locator, e);
+ }
+ }
+
+ @Override
+ public WebElement findElement(By by) {
+ return new ChromeElement(By.chained(this.locator, by), domDomain, runtimeDomain, inputDomain, cssDomain,
+ waitConfig, networkMonitor);
+ }
+
+ @Override
+ public List findElements(By by) {
+ try {
+ int parentNodeId = resolveNodeId();
+ return findAllChildElements(parentNodeId, by);
+ } catch (Exception e) {
+ return new ArrayList<>();
+ }
+ }
+
+ private List findAllChildElements(int parentNodeId, By childBy) {
+ try {
+ String cssSelector = childBy.toCssSelector();
+ if (cssSelector != null) {
+ JsonObject r = domDomain.querySelectorAll(parentNodeId, cssSelector).join();
+ JsonArray nodeIds = r.getAsJsonArray(KEY_NODE_IDS);
+ List elements = new ArrayList<>();
+ if (nodeIds != null) {
+ By scopedBy = By.chained(this.locator, childBy);
+ for (int i = 0; i < nodeIds.size(); i++) {
+ elements.add(new ChromeElement(By.index(scopedBy, i), domDomain, runtimeDomain, inputDomain,
+ cssDomain, waitConfig, networkMonitor));
+ }
+ }
+ return elements;
+ }
+
+ if (childBy.isXPath()) {
+ String objId = resolveObjectId(parentNodeId);
+ try {
+ String escaped = childBy.getSelector().replace("\\", "\\\\").replace("'", "\\'");
+ String countScript = "function() { return document.evaluate('" + escaped + "', this, null, "
+ + "XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength; }";
+ JsonObject countResult = runtimeDomain.callFunctionOn(objId, countScript, null).join();
+ int count = countResult.getAsJsonObject(KEY_RESULT).get(KEY_VALUE).getAsInt();
+ By scopedBy = By.chained(this.locator, childBy);
+ List elements = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ elements.add(new ChromeElement(By.index(scopedBy, i), domDomain, runtimeDomain, inputDomain,
+ cssDomain, waitConfig, networkMonitor));
+ }
+ return elements;
+ } finally {
+ try {
+ runtimeDomain.releaseObject(objId).join();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ return new ArrayList<>();
+ } catch (Exception e) {
+ return new ArrayList<>();
+ }
+ }
+
+ private int resolveNodeId() {
+ // ByIndex: find the nth element among all matches
+ if (locator instanceof By.ByIndex) {
+ return resolveIndexedNode((By.ByIndex) locator);
+ }
+
+ try {
+ // CSS path (also handles CSS-combinable ByChained transparently)
+ String cssSelector = locator.toCssSelector();
+ if (cssSelector != null) {
+ JsonObject docResult = domDomain.getDocument().join();
+ int documentNode = docResult.getAsJsonObject(KEY_ROOT).get(KEY_NODE_ID).getAsInt();
+ JsonObject r = domDomain.querySelector(documentNode, cssSelector).join();
+ int nodeId = r.get(KEY_NODE_ID).getAsInt();
+ if (nodeId == 0) {
+ throw new ElementNotFoundException("Element not found: " + locator);
+ }
+ return nodeId;
+ }
+
+ // XPath path
+ if (locator.isXPath()) {
+ return resolveNodeIdByXPath(locator.getSelector());
+ }
+
+ // Mixed ByChained (non-CSS-combinable): step-by-step resolution
+ if (locator instanceof By.ByChained) {
+ return resolveChainedNode((By.ByChained) locator);
+ }
+
+ throw new UnsupportedOperationException("Unsupported locator type: " + locator.getClass().getSimpleName());
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ElementNotFoundException("Failed to locate element: " + locator, e);
+ }
+ }
+
+ private int resolveIndexedNode(By.ByIndex byIndex) {
+ By parent = byIndex.getParent();
+ int index = byIndex.getIndex();
+
+ try {
+ // CSS-based parent (including CSS-combinable ByChained)
+ String cssSelector = parent.toCssSelector();
+ if (cssSelector != null) {
+ JsonObject docResult = domDomain.getDocument().join();
+ int documentNode = docResult.getAsJsonObject(KEY_ROOT).get(KEY_NODE_ID).getAsInt();
+ JsonObject r = domDomain.querySelectorAll(documentNode, cssSelector).join();
+ JsonArray nodeIds = r.getAsJsonArray(KEY_NODE_IDS);
+ if (nodeIds == null || index >= nodeIds.size()) {
+ throw new ElementNotFoundException("Index " + index + " out of bounds ("
+ + (nodeIds == null ? 0 : nodeIds.size()) + " matches) for: " + parent);
+ }
+ return nodeIds.get(index).getAsInt();
+ }
+
+ if (parent.isXPath()) {
+ String xpath = "(" + parent.getSelector() + ")[" + (index + 1) + "]";
+ return resolveNodeIdByXPath(xpath);
+ }
+
+ if (parent instanceof By.ByChained) {
+ By[] bys = ((By.ByChained) parent).getBys();
+ int contextNodeId = resolveChainedToContext(bys);
+ return resolveNthInContext(contextNodeId, bys[bys.length - 1], index);
+ }
+
+ throw new UnsupportedOperationException(
+ "Unsupported parent locator type for By.index: " + parent.getClass().getSimpleName());
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ElementNotFoundException("Failed to resolve indexed element: " + byIndex, e);
+ }
+ }
+
+ private int resolveChainedNode(By.ByChained chained) {
+ By[] bys = chained.getBys();
+ int nodeId = new ChromeElement(bys[0], domDomain, runtimeDomain, inputDomain, cssDomain, waitConfig,
+ networkMonitor).resolveNodeId();
+ for (int i = 1; i < bys.length; i++) {
+ nodeId = resolveWithContext(nodeId, bys[i]);
+ }
+ return nodeId;
+ }
+
+ private int resolveChainedToContext(By[] bys) {
+ int nodeId = new ChromeElement(bys[0], domDomain, runtimeDomain, inputDomain, cssDomain, waitConfig,
+ networkMonitor).resolveNodeId();
+ for (int i = 1; i < bys.length - 1; i++) {
+ nodeId = resolveWithContext(nodeId, bys[i]);
+ }
+ return nodeId;
+ }
+
+ private int resolveWithContext(int contextNodeId, By by) {
+ String cssSelector = by.toCssSelector();
+ if (cssSelector != null) {
+ try {
+ JsonObject r = domDomain.querySelector(contextNodeId, cssSelector).join();
+ int nodeId = r.get(KEY_NODE_ID).getAsInt();
+ if (nodeId == 0) {
+ throw new ElementNotFoundException("Element not found within context: " + by);
+ }
+ return nodeId;
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ElementNotFoundException("Failed to find element in context: " + by, e);
+ }
+ }
+
+ if (by.isXPath()) {
+ String objectId = resolveObjectId(contextNodeId);
+ try {
+ return resolveNodeByXPathInContext(objectId, by.getSelector());
+ } finally {
+ try {
+ runtimeDomain.releaseObject(objectId).join();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ throw new UnsupportedOperationException(
+ "Unsupported locator in chained resolution: " + by.getClass().getSimpleName());
+ }
+
+ private int resolveNodeByXPathInContext(String contextObjectId, String xpath) {
+ String escaped = xpath.replace("\\", "\\\\").replace("'", "\\'");
+ String script = "function() { " + "var r = document.evaluate('" + escaped + "', this, null, "
+ + "XPathResult.FIRST_ORDERED_NODE_TYPE, null); " + "return r.singleNodeValue; }";
+ try {
+ JsonObject result = runtimeDomain.callFunctionOn(contextObjectId, script, null).join();
+ JsonObject resultObj = result.getAsJsonObject(KEY_RESULT);
+ if (TYPE_OBJECT.equals(resultObj.get(KEY_TYPE).getAsString()) && resultObj.has(KEY_OBJECT_ID)) {
+ String objId = resultObj.get(KEY_OBJECT_ID).getAsString();
+ JsonObject nodeRes = domDomain.requestNode(objId).join();
+ int nodeId = nodeRes.get(KEY_NODE_ID).getAsInt();
+ runtimeDomain.releaseObject(objId).join();
+ if (nodeId == 0) {
+ throw new ElementNotFoundException("XPath returned no node in context: " + xpath);
+ }
+ return nodeId;
+ }
+ throw new ElementNotFoundException("XPath matched no element in context: " + xpath);
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ElementNotFoundException("Failed to resolve XPath in context: " + xpath, e);
+ }
+ }
+
+ private int resolveNthInContext(int contextNodeId, By by, int index) {
+ String css = by.toCssSelector();
+ if (css != null) {
+ try {
+ JsonObject r = domDomain.querySelectorAll(contextNodeId, css).join();
+ JsonArray nodeIds = r.getAsJsonArray(KEY_NODE_IDS);
+ if (nodeIds == null || index >= nodeIds.size()) {
+ throw new ElementNotFoundException("Index " + index + " out of bounds ("
+ + (nodeIds == null ? 0 : nodeIds.size()) + " matches) for: " + by);
+ }
+ return nodeIds.get(index).getAsInt();
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ElementNotFoundException("Failed to find nth element in context: " + by, e);
+ }
+ }
+
+ if (by.isXPath()) {
+ String objId = resolveObjectId(contextNodeId);
+ try {
+ return resolveNthByXPathInContext(objId, by.getSelector(), index);
+ } finally {
+ try {
+ runtimeDomain.releaseObject(objId).join();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ throw new UnsupportedOperationException(
+ "Unsupported locator in context resolution: " + by.getClass().getSimpleName());
+ }
+
+ private int resolveNthByXPathInContext(String contextObjectId, String xpath, int index) {
+ String escaped = xpath.replace("\\", "\\\\").replace("'", "\\'");
+ String script = "function() { " + "var snap = document.evaluate('" + escaped + "', this, null, "
+ + "XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); " + "return snap.snapshotLength > " + index
+ + " ? snap.snapshotItem(" + index + ") : null; }";
+ try {
+ JsonObject result = runtimeDomain.callFunctionOn(contextObjectId, script, null).join();
+ JsonObject resultObj = result.getAsJsonObject(KEY_RESULT);
+ if (TYPE_OBJECT.equals(resultObj.get(KEY_TYPE).getAsString()) && resultObj.has(KEY_OBJECT_ID)) {
+ String objId = resultObj.get(KEY_OBJECT_ID).getAsString();
+ JsonObject nodeRes = domDomain.requestNode(objId).join();
+ int nodeId = nodeRes.get(KEY_NODE_ID).getAsInt();
+ runtimeDomain.releaseObject(objId).join();
+ if (nodeId == 0) {
+ throw new ElementNotFoundException("XPath snapshot item was null: " + xpath);
+ }
+ return nodeId;
+ }
+ throw new ElementNotFoundException("XPath returned null at index " + index + ": " + xpath);
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ElementNotFoundException("Failed to resolve XPath[" + index + "] in context: " + xpath, e);
+ }
+ }
+
+ private int resolveNodeIdByXPath(String xpath) {
+ try {
+ // Use a parameterised approach to avoid XPath-in-JS injection issues
+ String escaped = xpath.replace("\\", "\\\\").replace("'", "\\'");
+ String script = "document.evaluate('" + escaped + "', document, null, "
+ + "XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue";
+
+ JsonObject result = runtimeDomain.evaluate(script, false).join();
+ JsonObject resultObj = result.getAsJsonObject(KEY_RESULT);
+
+ if (TYPE_OBJECT.equals(resultObj.get(KEY_TYPE).getAsString()) && resultObj.has(KEY_OBJECT_ID)) {
+ String objectId = resultObj.get(KEY_OBJECT_ID).getAsString();
+ JsonObject nodeRes = domDomain.requestNode(objectId).join();
+ int nodeId = nodeRes.get(KEY_NODE_ID).getAsInt();
+ runtimeDomain.releaseObject(objectId).join();
+
+ if (nodeId == 0) {
+ throw new ElementNotFoundException("XPath returned no node: " + xpath);
+ }
+ return nodeId;
+ }
+ throw new ElementNotFoundException("XPath matched no element: " + xpath);
+ } catch (ElementNotFoundException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ElementNotFoundException("Failed to resolve node by XPath: " + xpath, e);
+ }
+ }
+
+ private String resolveObjectId(int nodeId) {
+ JsonObject resolved = domDomain.resolveNode(nodeId).join();
+ return resolved.getAsJsonObject(KEY_OBJECT).get(KEY_OBJECT_ID).getAsString();
+ }
+
+ private JsonObject extractBoxModel(int nodeId) {
+ return domDomain.getBoxModel(nodeId).join().getAsJsonObject(KEY_MODEL);
+ }
+
+ private double[] extractCenter(JsonObject boxModel) {
+ JsonArray content = boxModel.getAsJsonObject(KEY_MODEL).getAsJsonArray(KEY_CONTENT);
+ double x1 = content.get(BOX_X_TOP_LEFT).getAsDouble();
+ double y1 = content.get(BOX_Y_TOP_LEFT).getAsDouble();
+ double x2 = content.get(BOX_X_BOTTOM_RIGHT).getAsDouble();
+ double y2 = content.get(BOX_Y_BOTTOM_RIGHT).getAsDouble();
+ return new double[] { (x1 + x2) / 2.0, (y1 + y2) / 2.0 };
+ }
+
+ private void waitForScrollStability(int nodeId) {
+ long deadline = System.currentTimeMillis() + SCROLL_STABILITY_TIMEOUT_MILLIS;
+ Point previous = null;
+ int stableCount = 0;
+
+ while (System.currentTimeMillis() < deadline) {
+ Point current = samplePosition(nodeId);
+
+ if (previous != null && previous.getX() == current.getX() && previous.getY() == current.getY()) {
+ if (++stableCount >= SCROLL_STABLE_CHECKS_REQUIRED) {
+ return;
+ }
+ } else {
+ stableCount = 0;
+ }
+ previous = current;
+
+ try {
+ Thread.sleep(SCROLL_STABILITY_POLL_MILLIS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return; // Give up on stability check — proceed with click
+ }
+ }
+ }
+
+ private Point samplePosition(int nodeId) {
+ try {
+ JsonObject model = extractBoxModel(nodeId);
+ JsonArray content = model.getAsJsonArray(KEY_CONTENT);
+ return new Point(content.get(BOX_X_TOP_LEFT).getAsInt(), content.get(BOX_Y_TOP_LEFT).getAsInt());
+ } catch (Exception e) {
+ return new Point(0, 0);
+ }
+ }
+
+ public String toString() {
+ return "ChromeElement[" + locator + "]";
+ }
+}
diff --git a/nihonium/src/main/java/example/chrome/ChromeManageOptions.java b/nihonium/src/main/java/example/chrome/ChromeManageOptions.java
new file mode 100644
index 000000000..2a00776f5
--- /dev/null
+++ b/nihonium/src/main/java/example/chrome/ChromeManageOptions.java
@@ -0,0 +1,225 @@
+package example.chrome;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import example.Cookie;
+import example.WebDriver;
+
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * CDP-backed implementation of {@link WebDriver.Options}.
+ *
+ * Cookie operations are forwarded to the CDP {@code Network} domain via
+ * {@link example.cdp.domain.NetworkDomain}.
+ * Timeout values are stored locally; script/page-load timeouts are applied
+ * when the corresponding CDP session commands are invoked.
+ */
+class ChromeManageOptions implements WebDriver.Options {
+
+ // ── Default timeout values ────────────────────────────────────────────────
+
+ private static final long DEFAULT_IMPLICIT_WAIT_MILLIS = 0L;
+ private static final long DEFAULT_SCRIPT_TIMEOUT_MILLIS = 30_000L;
+ private static final long DEFAULT_PAGE_LOAD_TIMEOUT_MILLIS = 30_000L;
+
+ // ── CDP response field names ──────────────────────────────────────────────
+
+ private static final String FIELD_COOKIES = "cookies";
+ private static final String FIELD_NAME = "name";
+ private static final String FIELD_VALUE = "value";
+ private static final String FIELD_DOMAIN = "domain";
+ private static final String FIELD_PATH = "path";
+ private static final String FIELD_EXPIRES = "expires";
+ private static final String FIELD_SECURE = "secure";
+ private static final String FIELD_HTTP_ONLY = "httpOnly";
+
+ /** CDP uses {@code -1} for session cookies that have no expiry date. */
+ private static final long SESSION_COOKIE_EXPIRES = -1L;
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private final ChromeDriver driver;
+
+ private long implicitWaitMillis = DEFAULT_IMPLICIT_WAIT_MILLIS;
+ private long scriptTimeoutMillis = DEFAULT_SCRIPT_TIMEOUT_MILLIS;
+ private long pageLoadTimeoutMillis = DEFAULT_PAGE_LOAD_TIMEOUT_MILLIS;
+
+ ChromeManageOptions(ChromeDriver driver) {
+ this.driver = driver;
+ }
+
+ // ── Cookie management ─────────────────────────────────────────────────────
+
+ /**
+ * Adds a cookie to the current browser session.
+ *
+ *
The cookie is sent to the CDP {@code Network.setCookie} command.
+ *
+ * @param cookie the cookie to add; must not be {@code null}
+ */
+ @Override
+ public void addCookie(Cookie cookie) {
+ long expiresSeconds = SESSION_COOKIE_EXPIRES;
+ if (cookie.getExpiry() != null) {
+ expiresSeconds = cookie.getExpiry().getTime() / 1_000L;
+ }
+
+ driver.getNetworkDomain().setCookie(
+ cookie.getName(),
+ cookie.getValue(),
+ cookie.getDomain(),
+ cookie.getPath(),
+ cookie.isSecure(),
+ cookie.isHttpOnly(),
+ expiresSeconds
+ ).join();
+ }
+
+ /**
+ * Deletes the cookie with the given name visible from the current page.
+ *
+ * @param name cookie name to delete; must not be {@code null}
+ */
+ @Override
+ public void deleteCookieNamed(String name) {
+ driver.getNetworkDomain().deleteCookies(name, null).join();
+ }
+
+ /**
+ * Deletes the specified cookie.
+ *
+ * @param cookie cookie to delete (name and domain are used for matching)
+ */
+ @Override
+ public void deleteCookie(Cookie cookie) {
+ driver.getNetworkDomain().deleteCookies(cookie.getName(), cookie.getDomain()).join();
+ }
+
+ /** Clears all cookies in the browser. */
+ @Override
+ public void deleteAllCookies() {
+ driver.getNetworkDomain().clearBrowserCookies().join();
+ }
+
+ /**
+ * Returns all cookies visible to the current page.
+ *
+ * @return set of {@link Cookie} instances (never {@code null}, may be empty)
+ */
+ @Override
+ public Set getCookies() {
+ try {
+ JsonObject response = driver.getNetworkDomain().getCookies().join();
+ JsonArray rawList = response.getAsJsonArray(FIELD_COOKIES);
+ Set result = new HashSet<>();
+
+ for (JsonElement el : rawList) {
+ JsonObject raw = el.getAsJsonObject();
+ result.add(parseCookie(raw));
+ }
+ return result;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to retrieve cookies", e);
+ }
+ }
+
+ /**
+ * Returns the cookie with the given name, or {@code null} if not found.
+ *
+ * @param name cookie name to search for
+ * @return the matching {@link Cookie}, or {@code null}
+ */
+ @Override
+ public Cookie getCookieNamed(String name) {
+ return getCookies().stream()
+ .filter(c -> name.equals(c.getName()))
+ .findFirst()
+ .orElse(null);
+ }
+
+ // ── Timeouts ──────────────────────────────────────────────────────────────
+
+ @Override
+ public WebDriver.Timeouts timeouts() {
+ return new ChromeTimeouts(this);
+ }
+
+ @Override
+ public WebDriver.Window window() {
+ return new ChromeWindow(driver);
+ }
+
+ // ── Package-visible accessors (used by ChromeElement auto-wait) ───────────
+
+ long getImplicitWaitMillis() { return implicitWaitMillis; }
+ void setImplicitWaitMillis(long millis){ this.implicitWaitMillis = millis; }
+
+ long getScriptTimeoutMillis() { return scriptTimeoutMillis; }
+ void setScriptTimeoutMillis(long millis) { this.scriptTimeoutMillis = millis; }
+
+ long getPageLoadTimeoutMillis() { return pageLoadTimeoutMillis; }
+ void setPageLoadTimeoutMillis(long millis) { this.pageLoadTimeoutMillis = millis; }
+
+ // ── Private helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Converts a raw CDP cookie JSON object into a {@link Cookie}.
+ *
+ * @param raw CDP cookie JSON object
+ * @return corresponding {@link Cookie}
+ */
+ private Cookie parseCookie(JsonObject raw) {
+ String name = raw.get(FIELD_NAME).getAsString();
+ String value = raw.get(FIELD_VALUE).getAsString();
+
+ String domain = raw.has(FIELD_DOMAIN) ? raw.get(FIELD_DOMAIN).getAsString() : null;
+ String path = raw.has(FIELD_PATH) ? raw.get(FIELD_PATH).getAsString() : null;
+ boolean secure = raw.has(FIELD_SECURE) && raw.get(FIELD_SECURE).getAsBoolean();
+ boolean httpOnly = raw.has(FIELD_HTTP_ONLY) && raw.get(FIELD_HTTP_ONLY).getAsBoolean();
+
+ Date expiry = null;
+ if (raw.has(FIELD_EXPIRES)) {
+ long expiresSeconds = raw.get(FIELD_EXPIRES).getAsLong();
+ if (expiresSeconds > 0) {
+ expiry = new Date(expiresSeconds * 1_000L);
+ }
+ }
+
+ return new Cookie(name, value, domain, path, expiry, secure, httpOnly);
+ }
+
+ // ── Timeouts inner class ──────────────────────────────────────────────────
+
+ private static final class ChromeTimeouts implements WebDriver.Timeouts {
+
+ private final ChromeManageOptions options;
+
+ ChromeTimeouts(ChromeManageOptions options) {
+ this.options = options;
+ }
+
+ @Override
+ public WebDriver.Timeouts implicitlyWait(long time, TimeUnit unit) {
+ options.setImplicitWaitMillis(unit.toMillis(time));
+ return this;
+ }
+
+ @Override
+ public WebDriver.Timeouts setScriptTimeout(long time, TimeUnit unit) {
+ options.setScriptTimeoutMillis(unit.toMillis(time));
+ return this;
+ }
+
+ @Override
+ public WebDriver.Timeouts pageLoadTimeout(long time, TimeUnit unit) {
+ options.setPageLoadTimeoutMillis(unit.toMillis(time));
+ return this;
+ }
+ }
+}
diff --git a/nihonium/src/main/java/example/chrome/ChromeNavigation.java b/nihonium/src/main/java/example/chrome/ChromeNavigation.java
new file mode 100644
index 000000000..650f39f37
--- /dev/null
+++ b/nihonium/src/main/java/example/chrome/ChromeNavigation.java
@@ -0,0 +1,44 @@
+package example.chrome;
+
+import example.WebDriver;
+
+class ChromeNavigation implements WebDriver.Navigation {
+
+ private final ChromeDriver driver;
+
+ ChromeNavigation(ChromeDriver driver) {
+ this.driver = driver;
+ }
+
+ @Override
+ public void back() {
+ try {
+ driver.getRuntimeDomain().evaluate("window.history.back()", false).join();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to navigate back", e);
+ }
+ }
+
+ @Override
+ public void forward() {
+ try {
+ driver.getRuntimeDomain().evaluate("window.history.forward()", false).join();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to navigate forward", e);
+ }
+ }
+
+ @Override
+ public void to(String url) {
+ driver.get(url);
+ }
+
+ @Override
+ public void refresh() {
+ try {
+ driver.getPageDomain().reload().join();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to refresh page", e);
+ }
+ }
+}
diff --git a/nihonium/src/main/java/example/chrome/ChromeOptions.java b/nihonium/src/main/java/example/chrome/ChromeOptions.java
new file mode 100644
index 000000000..999acd4d6
--- /dev/null
+++ b/nihonium/src/main/java/example/chrome/ChromeOptions.java
@@ -0,0 +1,226 @@
+package example.chrome;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import example.browser.BrowserType;
+
+/**
+ * Configuration options for launching Chrome/Chromium via {@link ChromeDriver}.
+ *
+ * This class mirrors the Selenium {@code ChromeOptions} API and adds Nihonium-specific
+ * settings such as browser auto-download.
+ *
+ *
{@code
+ * ChromeOptions options = new ChromeOptions()
+ * .setHeadless(true)
+ * .setBrowserType(BrowserType.CHROME)
+ * .setAutoDownload(true);
+ *
+ * WebDriver driver = new ChromeDriver(options);
+ * }
+ */
+public class ChromeOptions {
+
+ // ── Defaults ──────────────────────────────────────────────────────────────
+
+ private static final int DEFAULT_WINDOW_WIDTH = 1280;
+ private static final int DEFAULT_WINDOW_HEIGHT = 720;
+ private static final int DEFAULT_DEBUGGING_PORT = 0; // 0 = auto-select
+ private static final boolean DEFAULT_HEADLESS = false;
+ private static final boolean DEFAULT_AUTO_DOWNLOAD = true;
+
+ // ── Fields ────────────────────────────────────────────────────────────────
+
+ private String binaryPath;
+ private BrowserType browserType = BrowserType.CHROME;
+ private String browserVersion = null; // null = latest stable
+ private boolean autoDownload = DEFAULT_AUTO_DOWNLOAD;
+ private boolean headless = DEFAULT_HEADLESS;
+ private List arguments = new ArrayList<>();
+ private Map experimentalOptions = new HashMap<>();
+ private int debuggingPort = DEFAULT_DEBUGGING_PORT;
+ private String userDataDir;
+ private int windowWidth = DEFAULT_WINDOW_WIDTH;
+ private int windowHeight = DEFAULT_WINDOW_HEIGHT;
+
+ public ChromeOptions() { }
+
+ // ── Binary / browser selection ────────────────────────────────────────────
+
+ /**
+ * Sets the path to an existing Chrome/Chromium binary.
+ * Supplying a path disables auto-download for that invocation.
+ *
+ * @param path absolute path to the binary
+ * @return {@code this}
+ */
+ public ChromeOptions setBinaryPath(String path) {
+ this.binaryPath = path;
+ return this;
+ }
+
+ /** @deprecated Use {@link #setBinaryPath(String)} */
+ @Deprecated(forRemoval = true)
+ public ChromeOptions setBinary(String path) {
+ return setBinaryPath(path);
+ }
+
+ public String getBinaryPath() { return binaryPath; }
+
+ /** @deprecated Use {@link #getBinaryPath()} */
+ @Deprecated(forRemoval = true)
+ public String getBinary() { return binaryPath; }
+
+ /**
+ * Selects which browser to download/launch (default: {@link BrowserType#CHROME}).
+ *
+ * @param browserType the browser type
+ * @return {@code this}
+ */
+ public ChromeOptions setBrowserType(BrowserType browserType) {
+ this.browserType = browserType;
+ return this;
+ }
+
+ public BrowserType getBrowserType() { return browserType; }
+
+ /**
+ * Pins the auto-download to a specific browser version (e.g. {@code "131.0.6778.108"}).
+ * Pass {@code null} (the default) to always use the latest stable release.
+ *
+ * @param version dotted version string, or {@code null} for latest
+ * @return {@code this}
+ */
+ public ChromeOptions setBrowserVersion(String version) {
+ this.browserVersion = version;
+ return this;
+ }
+
+ public String getBrowserVersion() { return browserVersion; }
+
+ /**
+ * Controls whether Nihonium automatically downloads the browser when no local
+ * installation is found (default: {@code true}).
+ *
+ * @param autoDownload {@code false} to disable auto-download
+ * @return {@code this}
+ */
+ public ChromeOptions setAutoDownload(boolean autoDownload) {
+ this.autoDownload = autoDownload;
+ return this;
+ }
+
+ public boolean isAutoDownload() { return autoDownload; }
+
+ // ── Launch flags ──────────────────────────────────────────────────────────
+
+ /**
+ * Enables or disables headless mode (default: {@code false}).
+ *
+ * @param headless {@code true} for headless
+ * @return {@code this}
+ */
+ public ChromeOptions setHeadless(boolean headless) {
+ this.headless = headless;
+ return this;
+ }
+
+ public boolean isHeadless() { return headless; }
+
+ /**
+ * Appends one or more command-line arguments to the browser launch command.
+ *
+ * @param arguments arguments to add (e.g. {@code "--no-sandbox"})
+ * @return {@code this}
+ */
+ public ChromeOptions addArguments(String... arguments) {
+ for (String arg : arguments) {
+ this.arguments.add(arg);
+ }
+ return this;
+ }
+
+ public ChromeOptions addArguments(List arguments) {
+ this.arguments.addAll(arguments);
+ return this;
+ }
+
+ public List getArguments() { return new ArrayList<>(arguments); }
+
+ /**
+ * Sets a Chrome experimental option.
+ * Use this for options that do not have a dedicated setter.
+ *
+ * @param name option name
+ * @param value option value
+ * @return {@code this}
+ */
+ public ChromeOptions setExperimentalOption(String name, Object value) {
+ this.experimentalOptions.put(name, value);
+ return this;
+ }
+
+ public Object getExperimentalOption(String name) {
+ return experimentalOptions.get(name);
+ }
+
+ public Map getExperimentalOptions() {
+ return new HashMap<>(experimentalOptions);
+ }
+
+ // ── Connection ────────────────────────────────────────────────────────────
+
+ /**
+ * Sets the remote-debugging port Chrome should listen on.
+ * Use {@code 0} (the default) to let the OS assign a free port automatically.
+ *
+ * @param port port number, or {@code 0} for automatic
+ * @return {@code this}
+ */
+ public ChromeOptions setDebuggingPort(int port) {
+ this.debuggingPort = port;
+ return this;
+ }
+
+ public int getDebuggingPort() { return debuggingPort; }
+
+ /** No-op — kept for Selenium API compatibility. */
+ public ChromeOptions setDebuggerAddress(String address) {
+ return this;
+ }
+
+ // ── Profile / window ──────────────────────────────────────────────────────
+
+ /**
+ * Sets the user-data directory for the browser profile.
+ * If not set, a temporary directory is created for each session.
+ *
+ * @param userDataDir path to the user data directory
+ * @return {@code this}
+ */
+ public ChromeOptions setUserDataDir(String userDataDir) {
+ this.userDataDir = userDataDir;
+ return this;
+ }
+
+ public String getUserDataDir() { return userDataDir; }
+
+ /**
+ * Sets the initial browser window size.
+ *
+ * @param width width in pixels
+ * @param height height in pixels
+ * @return {@code this}
+ */
+ public ChromeOptions setWindowSize(int width, int height) {
+ this.windowWidth = width;
+ this.windowHeight = height;
+ return this;
+ }
+
+ public int getWindowWidth() { return windowWidth; }
+ public int getWindowHeight() { return windowHeight; }
+}
diff --git a/nihonium/src/main/java/example/chrome/ChromeTargetLocator.java b/nihonium/src/main/java/example/chrome/ChromeTargetLocator.java
new file mode 100644
index 000000000..fc806c601
--- /dev/null
+++ b/nihonium/src/main/java/example/chrome/ChromeTargetLocator.java
@@ -0,0 +1,213 @@
+package example.chrome;
+
+import com.google.gson.JsonObject;
+
+import example.By;
+import example.WebDriver;
+import example.WebElement;
+
+/**
+ * CDP-backed implementation of {@link WebDriver.TargetLocator}.
+ *
+ * Supported operations
+ *
+ * - {@link #window(String)} — activates a tab/window by its CDP target ID
+ * - {@link #defaultContent()} — re-focuses the top-level document
+ * - {@link #activeElement()} — returns the element that currently has focus
+ *
+ *
+ * Unsupported operations
+ * Frame switching ({@link #frame(int)}, {@link #frame(String)},
+ * {@link #frame(WebElement)}, {@link #parentFrame()}) requires connecting to a
+ * child browsing context over a separate CDP session. This multi-session
+ * architecture is not yet implemented. These methods throw
+ * {@link UnsupportedOperationException} so that callers receive an explicit
+ * failure rather than a silent no-op.
+ */
+class ChromeTargetLocator implements WebDriver.TargetLocator {
+
+ // ── CDP field names ───────────────────────────────────────────────────────
+
+ private static final String SCRIPT_ACTIVE_ELEMENT = "document.activeElement";
+ private static final String FIELD_RESULT = "result";
+ private static final String FIELD_TYPE = "type";
+ private static final String FIELD_OBJECT_ID = "objectId";
+ private static final String TYPE_OBJECT = "object";
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private final ChromeDriver driver;
+
+ ChromeTargetLocator(ChromeDriver driver) {
+ this.driver = driver;
+ }
+
+ // ── Frame switching (not yet supported) ───────────────────────────────────
+
+ /**
+ * @throws UnsupportedOperationException always — frame switching requires
+ * multi-session CDP support which is not yet implemented
+ */
+ @Override
+ public WebDriver frame(int index) {
+ throw new UnsupportedOperationException(
+ "Frame switching by index is not yet supported. "
+ + "CDP frame isolation requires a separate child session.");
+ }
+
+ /**
+ * @throws UnsupportedOperationException always — frame switching requires
+ * multi-session CDP support which is not yet implemented
+ */
+ @Override
+ public WebDriver frame(String nameOrId) {
+ throw new UnsupportedOperationException(
+ "Frame switching by name/id is not yet supported. "
+ + "CDP frame isolation requires a separate child session.");
+ }
+
+ /**
+ * @throws UnsupportedOperationException always — frame switching requires
+ * multi-session CDP support which is not yet implemented
+ */
+ @Override
+ public WebDriver frame(WebElement frameElement) {
+ throw new UnsupportedOperationException(
+ "Frame switching by element is not yet supported. "
+ + "CDP frame isolation requires a separate child session.");
+ }
+
+ /**
+ * @throws UnsupportedOperationException always — frame switching requires
+ * multi-session CDP support which is not yet implemented
+ */
+ @Override
+ public WebDriver parentFrame() {
+ throw new UnsupportedOperationException(
+ "parentFrame() is not yet supported. "
+ + "CDP frame isolation requires a separate child session.");
+ }
+
+ // ── Window switching ──────────────────────────────────────────────────────
+
+ /**
+ * Activates the tab or window identified by {@code nameOrHandle}.
+ *
+ *
{@code nameOrHandle} must be a CDP target ID as returned by
+ * {@link WebDriver#getWindowHandles()}. Named windows (via
+ * {@code window.open(..., 'name')}) are not yet supported.
+ *
+ * @param nameOrHandle CDP target ID of the window to switch to
+ * @return this driver
+ * @throws RuntimeException if the target cannot be activated
+ */
+ @Override
+ public WebDriver window(String nameOrHandle) {
+ try {
+ driver.getBrowserDomain().activateTarget(nameOrHandle).join();
+ return driver;
+ } catch (Exception e) {
+ throw new RuntimeException(
+ "Failed to switch to window with handle: " + nameOrHandle, e);
+ }
+ }
+
+ // ── Document focus ────────────────────────────────────────────────────────
+
+ /**
+ * Switches focus back to the top-level document (main frame).
+ *
+ *
Since Nihonium does not yet implement frame switching, this is
+ * effectively a no-op — the driver is always focused on the top frame.
+ *
+ * @return this driver
+ */
+ @Override
+ public WebDriver defaultContent() {
+ return driver;
+ }
+
+ // ── Active element ────────────────────────────────────────────────────────
+
+ /**
+ * Returns the element that currently has keyboard focus.
+ *
+ *
If no element has focus (e.g. {@code document.activeElement} is
+ * {@code document.body}), the body element is returned.
+ *
+ * @return a {@link WebElement} representing the focused element
+ * @throws RuntimeException if the active element cannot be determined
+ */
+ @Override
+ public WebElement activeElement() {
+ try {
+ // Evaluate document.activeElement → get objectId
+ JsonObject evalResult = driver.getRuntimeDomain()
+ .evaluate(SCRIPT_ACTIVE_ELEMENT, false).join();
+ JsonObject resultObj = evalResult.getAsJsonObject(FIELD_RESULT);
+
+ if (!TYPE_OBJECT.equals(resultObj.get(FIELD_TYPE).getAsString())
+ || !resultObj.has(FIELD_OBJECT_ID)) {
+ // Fallback: return body element
+ return driver.findElement(By.tagName("body"));
+ }
+
+ // Request the DOM node ID so we can build a stable CSS selector
+ String objectId = resultObj.get(FIELD_OBJECT_ID).getAsString();
+ JsonObject nodeResult = driver.getDomDomain().requestNode(objectId).join();
+ int nodeId = nodeResult.get("nodeId").getAsInt();
+ driver.getRuntimeDomain().releaseObject(objectId).join();
+
+ // Build a unique CSS selector from the element's attributes
+ String cssSelector = buildUniqueSelectorForNode(nodeId);
+ return driver.findElement(By.cssSelector(cssSelector));
+
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to get active element", e);
+ }
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Attempts to build a unique CSS selector for a DOM node.
+ *
+ *
Priority: {@code #id} → {@code [name="..."]} → JS-generated path.
+ *
+ * @param nodeId CDP node ID
+ * @return a CSS selector string
+ */
+ private String buildUniqueSelectorForNode(int nodeId) {
+ try {
+ JsonObject resolved = driver.getDomDomain().resolveNode(nodeId).join();
+ String objectId = resolved.getAsJsonObject("object").get("objectId").getAsString();
+
+ // Ask JS to give us the best available selector
+ String script =
+ "function() {" +
+ " if (this.id) return '#' + CSS.escape(this.id);" +
+ " if (this.name) return '[name=\"' + this.name + '\"]';" +
+ " var path = [], el = this;" +
+ " while (el && el.nodeType === 1) {" +
+ " var idx = 1, sib = el.previousElementSibling;" +
+ " while (sib) { idx++; sib = sib.previousElementSibling; }" +
+ " path.unshift(el.tagName.toLowerCase() + ':nth-child(' + idx + ')');" +
+ " el = el.parentElement;" +
+ " }" +
+ " return path.join(' > ');" +
+ "}";
+
+ JsonObject result = driver.getRuntimeDomain()
+ .callFunctionOn(objectId, script, null).join();
+ driver.getRuntimeDomain().releaseObject(objectId).join();
+
+ JsonObject resultObj = result.getAsJsonObject("result");
+ if (resultObj.has("value")) {
+ return resultObj.get("value").getAsString();
+ }
+ } catch (Exception ignored) { }
+
+ // Last resort fallback
+ return "body";
+ }
+}
diff --git a/nihonium/src/main/java/example/chrome/ChromeWindow.java b/nihonium/src/main/java/example/chrome/ChromeWindow.java
new file mode 100644
index 000000000..3551a39c8
--- /dev/null
+++ b/nihonium/src/main/java/example/chrome/ChromeWindow.java
@@ -0,0 +1,152 @@
+package example.chrome;
+
+import com.google.gson.JsonObject;
+
+import example.Dimension;
+import example.Point;
+import example.WebDriver;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * CDP-backed implementation of {@link WebDriver.Window}.
+ *
+ *
All operations delegate to {@link example.cdp.domain.BrowserDomain}
+ * using the current page's CDP target ID, so the correct browser window is always targeted.
+ */
+class ChromeWindow implements WebDriver.Window {
+
+ private static final Logger log = LoggerFactory.getLogger(ChromeWindow.class);
+
+ // ── CDP bounds field names ────────────────────────────────────────────────
+
+ private static final String FIELD_WIDTH = "width";
+ private static final String FIELD_HEIGHT = "height";
+ private static final String FIELD_LEFT = "left";
+ private static final String FIELD_TOP = "top";
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private final ChromeDriver driver;
+
+ ChromeWindow(ChromeDriver driver) {
+ this.driver = driver;
+ }
+
+ // ── Size ──────────────────────────────────────────────────────────────────
+
+ /**
+ * Returns the current inner size of the browser window.
+ *
+ * @return window size, or {@link Dimension#ZERO} if it cannot be determined
+ */
+ @Override
+ public Dimension getSize() {
+ try {
+ JsonObject bounds = driver.getBrowserDomain()
+ .getWindowBounds(driver.getCurrentTargetId())
+ .join();
+ return new Dimension(
+ bounds.get(FIELD_WIDTH).getAsInt(),
+ bounds.get(FIELD_HEIGHT).getAsInt());
+ } catch (Exception e) {
+ log.warn("Failed to get window size, returning zero: {}", e.getMessage());
+ return new Dimension(0, 0);
+ }
+ }
+
+ /**
+ * Resizes the browser window.
+ *
+ * @param targetSize the desired window size; must not be {@code null}
+ */
+ @Override
+ public void setSize(Dimension targetSize) {
+ try {
+ driver.getBrowserDomain()
+ .setWindowSize(driver.getCurrentTargetId(),
+ targetSize.getWidth(), targetSize.getHeight())
+ .join();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set window size to " + targetSize, e);
+ }
+ }
+
+ // ── Position ──────────────────────────────────────────────────────────────
+
+ /**
+ * Returns the current top-left position of the browser window on screen.
+ *
+ * @return window position, or {@link Point#ORIGIN} if it cannot be determined
+ */
+ @Override
+ public Point getPosition() {
+ try {
+ JsonObject bounds = driver.getBrowserDomain()
+ .getWindowBounds(driver.getCurrentTargetId())
+ .join();
+ return new Point(
+ bounds.get(FIELD_LEFT).getAsInt(),
+ bounds.get(FIELD_TOP).getAsInt());
+ } catch (Exception e) {
+ log.warn("Failed to get window position, returning origin: {}", e.getMessage());
+ return new Point(0, 0);
+ }
+ }
+
+ /**
+ * Moves the browser window to the given screen position.
+ *
+ * @param targetPosition the desired top-left position; must not be {@code null}
+ */
+ @Override
+ public void setPosition(Point targetPosition) {
+ try {
+ driver.getBrowserDomain()
+ .setWindowPosition(driver.getCurrentTargetId(),
+ targetPosition.getX(), targetPosition.getY())
+ .join();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set window position to " + targetPosition, e);
+ }
+ }
+
+ // ── Window state ──────────────────────────────────────────────────────────
+
+ /** Maximises the browser window. */
+ @Override
+ public void maximize() {
+ try {
+ driver.getBrowserDomain()
+ .maximizeWindow(driver.getCurrentTargetId())
+ .join();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to maximise window", e);
+ }
+ }
+
+ /** Minimises the browser window. */
+ @Override
+ public void minimize() {
+ try {
+ driver.getBrowserDomain()
+ .minimizeWindow(driver.getCurrentTargetId())
+ .join();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to minimise window", e);
+ }
+ }
+
+ /** Puts the browser window into fullscreen mode. */
+ @Override
+ public void fullscreen() {
+ try {
+ driver.getBrowserDomain()
+ .fullscreenWindow(driver.getCurrentTargetId())
+ .join();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to enter fullscreen", e);
+ }
+ }
+}
diff --git a/nihonium/src/main/java/example/exception/BrowserLaunchException.java b/nihonium/src/main/java/example/exception/BrowserLaunchException.java
new file mode 100644
index 000000000..53c4d98db
--- /dev/null
+++ b/nihonium/src/main/java/example/exception/BrowserLaunchException.java
@@ -0,0 +1,12 @@
+package example.exception;
+
+public class BrowserLaunchException extends NihoniumException {
+
+ public BrowserLaunchException(String message) {
+ super(message);
+ }
+
+ public BrowserLaunchException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/nihonium/src/main/java/example/exception/CDPException.java b/nihonium/src/main/java/example/exception/CDPException.java
new file mode 100644
index 000000000..c4132e864
--- /dev/null
+++ b/nihonium/src/main/java/example/exception/CDPException.java
@@ -0,0 +1,12 @@
+package example.exception;
+
+public class CDPException extends NihoniumException {
+
+ public CDPException(String message) {
+ super(message);
+ }
+
+ public CDPException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/nihonium/src/main/java/example/exception/ElementNotFoundException.java b/nihonium/src/main/java/example/exception/ElementNotFoundException.java
new file mode 100644
index 000000000..bc6720154
--- /dev/null
+++ b/nihonium/src/main/java/example/exception/ElementNotFoundException.java
@@ -0,0 +1,12 @@
+package example.exception;
+
+public class ElementNotFoundException extends NihoniumException {
+
+ public ElementNotFoundException(String message) {
+ super(message);
+ }
+
+ public ElementNotFoundException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/nihonium/src/main/java/example/exception/NihoniumException.java b/nihonium/src/main/java/example/exception/NihoniumException.java
new file mode 100644
index 000000000..49698930d
--- /dev/null
+++ b/nihonium/src/main/java/example/exception/NihoniumException.java
@@ -0,0 +1,16 @@
+package example.exception;
+
+public class NihoniumException extends RuntimeException {
+
+ public NihoniumException(String message) {
+ super(message);
+ }
+
+ public NihoniumException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public NihoniumException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/nihonium/src/main/java/example/exception/NoSuchFrameException.java b/nihonium/src/main/java/example/exception/NoSuchFrameException.java
new file mode 100644
index 000000000..5202119a7
--- /dev/null
+++ b/nihonium/src/main/java/example/exception/NoSuchFrameException.java
@@ -0,0 +1,12 @@
+package example.exception;
+
+public class NoSuchFrameException extends NihoniumException {
+
+ public NoSuchFrameException(String message) {
+ super(message);
+ }
+
+ public NoSuchFrameException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/nihonium/src/main/java/example/exception/StaleElementException.java b/nihonium/src/main/java/example/exception/StaleElementException.java
new file mode 100644
index 000000000..2e959a208
--- /dev/null
+++ b/nihonium/src/main/java/example/exception/StaleElementException.java
@@ -0,0 +1,12 @@
+package example.exception;
+
+public class StaleElementException extends NihoniumException {
+
+ public StaleElementException(String message) {
+ super(message);
+ }
+
+ public StaleElementException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/nihonium/src/main/java/example/exception/TimeoutException.java b/nihonium/src/main/java/example/exception/TimeoutException.java
new file mode 100644
index 000000000..f158a1cb9
--- /dev/null
+++ b/nihonium/src/main/java/example/exception/TimeoutException.java
@@ -0,0 +1,12 @@
+package example.exception;
+
+public class TimeoutException extends NihoniumException {
+
+ public TimeoutException(String message) {
+ super(message);
+ }
+
+ public TimeoutException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/nihonium/src/main/java/example/network/NetworkMonitor.java b/nihonium/src/main/java/example/network/NetworkMonitor.java
new file mode 100644
index 000000000..29e68fb14
--- /dev/null
+++ b/nihonium/src/main/java/example/network/NetworkMonitor.java
@@ -0,0 +1,85 @@
+package example.network;
+
+import com.google.gson.JsonObject;
+
+import example.cdp.domain.NetworkDomain;
+import example.wait.WaitConfig;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class NetworkMonitor {
+
+ private final NetworkDomain networkDomain;
+ private final ConcurrentHashMap activeRequests;
+ private final AtomicLong lastActivityTime;
+ private volatile boolean enabled = false;
+
+ public NetworkMonitor(NetworkDomain networkDomain) {
+ this.networkDomain = networkDomain;
+ this.activeRequests = new ConcurrentHashMap<>();
+ this.lastActivityTime = new AtomicLong(System.currentTimeMillis());
+ }
+
+ public void enable() {
+ if (!enabled) {
+ networkDomain.enable().join();
+
+ networkDomain.subscribeToRequestWillBeSent(this::onRequestStarted);
+ networkDomain.subscribeToLoadingFinished(this::onRequestFinished);
+ networkDomain.subscribeToLoadingFailed(this::onRequestFailed);
+
+ enabled = true;
+ }
+ }
+
+ public void disable() {
+ if (enabled) {
+ networkDomain.disable().join();
+ activeRequests.clear();
+ enabled = false;
+ }
+ }
+
+ private void onRequestStarted(JsonObject event) {
+ String requestId = event.get("requestId").getAsString();
+ activeRequests.put(requestId, System.currentTimeMillis());
+ lastActivityTime.set(System.currentTimeMillis());
+ }
+
+ private void onRequestFinished(JsonObject event) {
+ String requestId = event.get("requestId").getAsString();
+ activeRequests.remove(requestId);
+ lastActivityTime.set(System.currentTimeMillis());
+ }
+
+ private void onRequestFailed(JsonObject event) {
+ String requestId = event.get("requestId").getAsString();
+ activeRequests.remove(requestId);
+ lastActivityTime.set(System.currentTimeMillis());
+ }
+
+ public boolean isNetworkIdle(int maxConnections, long idleDurationMillis) {
+ if (!enabled) {
+ return true;
+ }
+
+ int activeCount = activeRequests.size();
+ if (activeCount > maxConnections) {
+ return false;
+ }
+
+ long timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime.get();
+ return timeSinceLastActivity >= idleDurationMillis;
+ }
+
+ public boolean isNetworkIdle() {
+ return isNetworkIdle(
+ WaitConfig.DEFAULT_NETWORK_IDLE_MAX_CONNECTIONS,
+ WaitConfig.DEFAULT_NETWORK_IDLE_DURATION_MILLIS);
+ }
+
+ public int getActiveRequestCount() {
+ return activeRequests.size();
+ }
+}
diff --git a/nihonium/src/main/java/example/wait/AutoWaitEngine.java b/nihonium/src/main/java/example/wait/AutoWaitEngine.java
new file mode 100644
index 000000000..81a171620
--- /dev/null
+++ b/nihonium/src/main/java/example/wait/AutoWaitEngine.java
@@ -0,0 +1,88 @@
+package example.wait;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import example.By;
+import example.exception.TimeoutException;
+import example.network.NetworkMonitor;
+
+import java.util.function.Supplier;
+
+public class AutoWaitEngine {
+
+ private static final Logger log = LoggerFactory.getLogger(AutoWaitEngine.class);
+
+ private final ElementWaitConditions conditions;
+ private final WaitConfig config;
+ private final NetworkMonitor networkMonitor;
+
+ public AutoWaitEngine(ElementWaitConditions conditions, WaitConfig config, NetworkMonitor networkMonitor) {
+ this.conditions = conditions;
+ this.config = config;
+ this.networkMonitor = networkMonitor;
+ }
+
+ public void waitForElement(By locator) {
+ waitForCondition(() -> conditions.isPresent(locator), "Element not present in DOM: " + locator);
+ }
+
+ public void waitForElementVisible(By locator) {
+ waitForElement(locator);
+ if (config.isWaitForVisibility()) {
+ waitForCondition(() -> conditions.isVisible(locator), "Element not visible: " + locator);
+ }
+ }
+
+ public void waitForElementClickable(By locator) {
+ waitForElementVisible(locator);
+ if (config.isWaitForClickability()) {
+ waitForCondition(() -> conditions.isClickable(locator), "Element not clickable: " + locator);
+ }
+ if (config.isWaitForNetworkIdle()) {
+ waitForNetworkIdle();
+ }
+ }
+
+ public void waitForElementInteractable(By locator) {
+ waitForElementVisible(locator);
+ if (config.isWaitForClickability()) {
+ waitForCondition(() -> conditions.isEditable(locator), "Element not editable: " + locator);
+ }
+ if (config.isWaitForNetworkIdle()) {
+ waitForNetworkIdle();
+ }
+ }
+
+ public void waitForNetworkIdle() {
+ if (networkMonitor == null) {
+ return;
+ }
+ waitForCondition(() -> networkMonitor.isNetworkIdle(config.getNetworkIdleMaxConnections(),
+ config.getNetworkIdleDurationMillis()), "Network did not become idle");
+ }
+
+ private void waitForCondition(Supplier condition, String timeoutMessage) {
+ long deadline = System.currentTimeMillis() + config.getTimeoutMillis();
+
+ while (System.currentTimeMillis() < deadline) {
+ try {
+ if (Boolean.TRUE.equals(condition.get())) {
+ return;
+ }
+ } catch (Exception e) {
+ // Condition threw — treat as "not yet met" and keep polling.
+ log.trace("Condition check threw (will retry): {}", e.getMessage());
+ }
+
+ try {
+ Thread.sleep(config.getPollingIntervalMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TimeoutException("Wait interrupted while polling: " + timeoutMessage);
+ }
+ }
+
+ throw new TimeoutException(timeoutMessage + " (timeout: " + config.getTimeoutMillis() + " ms)");
+ }
+}
diff --git a/nihonium/src/main/java/example/wait/ElementWaitConditions.java b/nihonium/src/main/java/example/wait/ElementWaitConditions.java
new file mode 100644
index 000000000..d94260396
--- /dev/null
+++ b/nihonium/src/main/java/example/wait/ElementWaitConditions.java
@@ -0,0 +1,226 @@
+package example.wait;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import example.By;
+import example.cdp.domain.CSSDomain;
+import example.cdp.domain.DOMDomain;
+import example.cdp.domain.RuntimeDomain;
+
+public class ElementWaitConditions {
+
+ private final DOMDomain domDomain;
+ private final CSSDomain cssDomain;
+ private final RuntimeDomain runtimeDomain;
+
+ public ElementWaitConditions(DOMDomain domDomain, CSSDomain cssDomain, RuntimeDomain runtimeDomain) {
+ this.domDomain = domDomain;
+ this.cssDomain = cssDomain;
+ this.runtimeDomain = runtimeDomain;
+ }
+
+ public boolean isPresent(By locator) {
+ try {
+ int nodeId = findNodeId(locator);
+ return nodeId != 0;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public boolean isVisible(By locator) {
+ try {
+ int nodeId = findNodeId(locator);
+ if (nodeId == 0)
+ return false;
+
+ JsonObject resolved = domDomain.resolveNode(nodeId).join();
+ String objectId = resolved.getAsJsonObject("object").get("objectId").getAsString();
+
+ String script = "!!(this.offsetWidth || this.offsetHeight || this.getClientRects().length)";
+ JsonObject result = runtimeDomain.callFunctionOn(objectId, "function() { return " + script + "; }", null)
+ .join();
+ runtimeDomain.releaseObject(objectId).join();
+
+ JsonObject resultObj = result.getAsJsonObject("result");
+ if (resultObj.has("value")) {
+ return resultObj.get("value").getAsBoolean();
+ }
+
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public boolean isStable(By locator) {
+ try {
+ int nodeId = findNodeId(locator);
+ if (nodeId == 0)
+ return false;
+
+ JsonObject resolved = domDomain.resolveNode(nodeId).join();
+ String objectId = resolved.getAsJsonObject("object").get("objectId").getAsString();
+
+ String script = "var animations = (typeof this.getAnimations === 'function') "
+ + "? this.getAnimations({subtree: true}) : []; "
+ + "var runningAnimations = animations.filter(function(a) { " + "return a.playState === 'running'; "
+ + "}); " + "return runningAnimations.length === 0;";
+
+ JsonObject result = runtimeDomain.callFunctionOn(objectId, "function() { " + script + " }", null).join();
+ runtimeDomain.releaseObject(objectId).join();
+
+ JsonObject resultObj = result.getAsJsonObject("result");
+ if (resultObj.has("value")) {
+ return resultObj.get("value").getAsBoolean();
+ }
+
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public boolean isNotObscured(By locator) {
+ try {
+ int nodeId = findNodeId(locator);
+ if (nodeId == 0)
+ return false;
+
+ JsonObject resolved = domDomain.resolveNode(nodeId).join();
+ String objectId = resolved.getAsJsonObject("object").get("objectId").getAsString();
+
+ String script = "const rect = this.getBoundingClientRect();\n"
+ + "if (rect.width === 0 || rect.height === 0) return false;\n"
+ + "const x = rect.left + rect.width / 2;\n" + "const y = rect.top + rect.height / 2;\n"
+ + "if (x < 0 || y < 0) return false;\n" + "const el = document.elementFromPoint(x, y);\n"
+ + "return el === this || this.contains(el);";
+
+ JsonObject result = runtimeDomain.callFunctionOn(objectId, "function() { " + script + " }", null).join();
+ runtimeDomain.releaseObject(objectId).join();
+
+ JsonObject resultObj = result.getAsJsonObject("result");
+ if (resultObj.has("value")) {
+ return resultObj.get("value").getAsBoolean();
+ }
+
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public boolean isClickable(By locator) {
+ try {
+ if (!isVisible(locator))
+ return false;
+ if (!isStable(locator))
+ return false;
+ if (!isNotObscured(locator))
+ return false;
+
+ int nodeId = findNodeId(locator);
+ JsonObject resolved = domDomain.resolveNode(nodeId).join();
+ String objectId = resolved.getAsJsonObject("object").get("objectId").getAsString();
+
+ String script = "!this.disabled && this.offsetParent !== null";
+ JsonObject result = runtimeDomain.callFunctionOn(objectId, "function() { return " + script + "; }", null)
+ .join();
+ runtimeDomain.releaseObject(objectId).join();
+
+ JsonObject resultObj = result.getAsJsonObject("result");
+ if (resultObj.has("value")) {
+ return resultObj.get("value").getAsBoolean();
+ }
+
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public boolean isEditable(By locator) {
+ try {
+ if (!isVisible(locator))
+ return false;
+
+ int nodeId = findNodeId(locator);
+ JsonObject resolved = domDomain.resolveNode(nodeId).join();
+ String objectId = resolved.getAsJsonObject("object").get("objectId").getAsString();
+
+ final String script = "const tagName = this.tagName.toLowerCase();"
+ + "const isInput = (tagName === 'input' || tagName === 'textarea');"
+ + "const isContentEditable = this.isContentEditable;" + "const isNotReadonly = !this.readOnly;"
+ + "const isNotDisabled = !this.disabled;"
+ + "return (isInput || isContentEditable) && isNotReadonly && isNotDisabled;";
+
+ JsonObject result = runtimeDomain.callFunctionOn(objectId, "function() { " + script + " }", null).join();
+ runtimeDomain.releaseObject(objectId).join();
+
+ JsonObject resultObj = result.getAsJsonObject("result");
+ if (resultObj.has("value")) {
+ return resultObj.get("value").getAsBoolean();
+ }
+
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private int findNodeId(By locator) {
+ try {
+ JsonObject docResult = domDomain.getDocument().join();
+ JsonObject root = docResult.getAsJsonObject("root");
+ int documentNodeId = root.get("nodeId").getAsInt();
+
+ String cssSelector = locator.toCssSelector();
+
+ if (cssSelector != null) {
+ JsonObject result = domDomain.querySelector(documentNodeId, cssSelector).join();
+ return result.get("nodeId").getAsInt();
+ } else if (locator.isXPath()) {
+ return findNodeIdByXPath(locator.getSelector());
+ } else {
+ throw new UnsupportedOperationException("Unsupported locator type");
+ }
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ private int findNodeIdByXPath(String xpath) {
+ try {
+ String escapedXPath = xpath.replace("'", "\\'");
+ String script = String.format(
+ "document.evaluate('%s', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue",
+ escapedXPath);
+
+ JsonObject result = runtimeDomain.evaluate(script, false).join();
+ JsonObject resultObj = result.getAsJsonObject("result");
+
+ if (resultObj.get("type").getAsString().equals("object") && resultObj.has("objectId")) {
+ String objectId = resultObj.get("objectId").getAsString();
+ JsonObject nodeResult = domDomain.requestNode(objectId).join();
+ int nodeId = nodeResult.get("nodeId").getAsInt();
+ runtimeDomain.releaseObject(objectId).join();
+ return nodeId;
+ }
+ return 0;
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ private String getCssProperty(JsonArray computedStyle, String propertyName) {
+ for (JsonElement element : computedStyle) {
+ JsonObject prop = element.getAsJsonObject();
+ if (prop.get("name").getAsString().equals(propertyName)) {
+ return prop.get("value").getAsString();
+ }
+ }
+ return "";
+ }
+}
diff --git a/nihonium/src/main/java/example/wait/WaitConfig.java b/nihonium/src/main/java/example/wait/WaitConfig.java
new file mode 100644
index 000000000..2cbecc9e8
--- /dev/null
+++ b/nihonium/src/main/java/example/wait/WaitConfig.java
@@ -0,0 +1,128 @@
+package example.wait;
+
+public final class WaitConfig {
+
+ public static final long DEFAULT_TIMEOUT_MILLIS = 10_000L;
+
+ public static final long DEFAULT_POLLING_INTERVAL_MILLIS = 100L;
+
+ public static final int DEFAULT_NETWORK_IDLE_MAX_CONNECTIONS = 0;
+
+ public static final long DEFAULT_NETWORK_IDLE_DURATION_MILLIS = 500L;
+
+ private final long timeoutMillis;
+ private final long pollingIntervalMillis;
+ private final boolean waitForVisibility;
+ private final boolean waitForClickability;
+ private final boolean waitForNetworkIdle;
+ private final boolean waitForAnimations;
+ private final int networkIdleMaxConnections;
+ private final long networkIdleDurationMillis;
+
+ private WaitConfig(Builder builder) {
+ this.timeoutMillis = builder.timeoutMillis;
+ this.pollingIntervalMillis = builder.pollingIntervalMillis;
+ this.waitForVisibility = builder.waitForVisibility;
+ this.waitForClickability = builder.waitForClickability;
+ this.waitForNetworkIdle = builder.waitForNetworkIdle;
+ this.waitForAnimations = builder.waitForAnimations;
+ this.networkIdleMaxConnections = builder.networkIdleMaxConnections;
+ this.networkIdleDurationMillis = builder.networkIdleDurationMillis;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static WaitConfig defaultConfig() {
+ return builder().build();
+ }
+
+ public long getTimeoutMillis() {
+ return timeoutMillis;
+ }
+
+ public long getPollingIntervalMillis() {
+ return pollingIntervalMillis;
+ }
+
+ public boolean isWaitForVisibility() {
+ return waitForVisibility;
+ }
+
+ public boolean isWaitForClickability() {
+ return waitForClickability;
+ }
+
+ public boolean isWaitForNetworkIdle() {
+ return waitForNetworkIdle;
+ }
+
+ public boolean isWaitForAnimations() {
+ return waitForAnimations;
+ }
+
+ public int getNetworkIdleMaxConnections() {
+ return networkIdleMaxConnections;
+ }
+
+ public long getNetworkIdleDurationMillis() {
+ return networkIdleDurationMillis;
+ }
+
+ public static final class Builder {
+
+ private long timeoutMillis = DEFAULT_TIMEOUT_MILLIS;
+ private long pollingIntervalMillis = DEFAULT_POLLING_INTERVAL_MILLIS;
+ private boolean waitForVisibility = true;
+ private boolean waitForClickability = true;
+ private boolean waitForNetworkIdle = false;
+ private boolean waitForAnimations = false;
+ private int networkIdleMaxConnections = DEFAULT_NETWORK_IDLE_MAX_CONNECTIONS;
+ private long networkIdleDurationMillis = DEFAULT_NETWORK_IDLE_DURATION_MILLIS;
+
+ public Builder timeout(long millis) {
+ this.timeoutMillis = millis;
+ return this;
+ }
+
+ public Builder pollingInterval(long millis) {
+ this.pollingIntervalMillis = millis;
+ return this;
+ }
+
+ public Builder waitForVisibility(boolean wait) {
+ this.waitForVisibility = wait;
+ return this;
+ }
+
+ public Builder waitForClickability(boolean wait) {
+ this.waitForClickability = wait;
+ return this;
+ }
+
+ public Builder waitForNetworkIdle(boolean wait) {
+ this.waitForNetworkIdle = wait;
+ return this;
+ }
+
+ public Builder waitForAnimations(boolean wait) {
+ this.waitForAnimations = wait;
+ return this;
+ }
+
+ public Builder networkIdleMaxConnections(int maxConnections) {
+ this.networkIdleMaxConnections = maxConnections;
+ return this;
+ }
+
+ public Builder networkIdleDuration(long millis) {
+ this.networkIdleDurationMillis = millis;
+ return this;
+ }
+
+ public WaitConfig build() {
+ return new WaitConfig(this);
+ }
+ }
+}
diff --git a/nihonium/src/main/java/example/websocket/NihoniumWebSocketClient.java b/nihonium/src/main/java/example/websocket/NihoniumWebSocketClient.java
new file mode 100644
index 000000000..9ca5807c4
--- /dev/null
+++ b/nihonium/src/main/java/example/websocket/NihoniumWebSocketClient.java
@@ -0,0 +1,135 @@
+package example.websocket;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+import example.cdp.CDPCommandManager;
+import example.exception.CDPException;
+
+import org.java_websocket.client.WebSocketClient;
+import org.java_websocket.handshake.ServerHandshake;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+public class NihoniumWebSocketClient extends WebSocketClient {
+
+ private static final Logger log = LoggerFactory.getLogger(NihoniumWebSocketClient.class);
+
+ private final CDPCommandManager commandManager;
+ private final Gson gson;
+ private final CountDownLatch connectionLatch;
+
+ private volatile boolean connected;
+ private volatile Exception connectionError;
+
+ private static final String KEY_ID = "id";
+ private static final String KEY_METHOD = "method";
+ private static final String KEY_PARAMS = "params";
+
+ public NihoniumWebSocketClient(URI serverUri) {
+ this(serverUri, new CDPCommandManager());
+ }
+
+ public NihoniumWebSocketClient(URI serverUri, CDPCommandManager commandManager) {
+ super(serverUri);
+ this.commandManager = commandManager;
+ this.gson = new Gson();
+ this.connectionLatch = new CountDownLatch(1);
+ this.connected = false;
+ }
+
+ @Override
+ public void onOpen(ServerHandshake handshake) {
+ connected = true;
+ connectionLatch.countDown();
+ log.info("CDP WebSocket connection established: {}", getURI());
+ }
+
+ @Override
+ public void onMessage(String message) {
+ try {
+ JsonObject json = gson.fromJson(message, JsonObject.class);
+ commandManager.handleMessage(json);
+ } catch (Exception e) {
+ log.error("Failed to handle CDP message: {}", e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void onClose(int code, String reason, boolean remote) {
+ connected = false;
+ log.info("CDP WebSocket closed by {} — code: {}, reason: {}", remote ? "remote" : "local", code, reason);
+ commandManager.clear();
+ }
+
+ @Override
+ public void onError(Exception ex) {
+ log.error("CDP WebSocket error: {}", ex.getMessage(), ex);
+ connectionError = ex;
+ connectionLatch.countDown();
+ }
+
+ public boolean awaitConnection(long timeout, TimeUnit unit) throws InterruptedException {
+ boolean achieved = connectionLatch.await(timeout, unit);
+ if (connectionError != null) {
+ throw new CDPException("CDP WebSocket connection failed", connectionError);
+ }
+ return achieved && connected;
+ }
+
+ public CompletableFuture sendCommand(String method, JsonObject params) {
+ if (!connected) {
+ return CompletableFuture.failedFuture(new CDPException("Cannot send command — WebSocket is not connected"));
+ }
+
+ long commandId = commandManager.nextCommandId();
+ CompletableFuture future = commandManager.registerCommand(commandId);
+
+ JsonObject command = new JsonObject();
+ command.addProperty(KEY_ID, commandId);
+ command.addProperty(KEY_METHOD, method);
+ if (params != null) {
+ command.add(KEY_PARAMS, params);
+ }
+
+ log.trace("→ CDP {} (id={})", method, commandId);
+ send(gson.toJson(command));
+ return future;
+ }
+
+ public CompletableFuture sendCommand(String method) {
+ return sendCommand(method, null);
+ }
+
+ public void subscribeToEvent(String eventName, Consumer handler) {
+ commandManager.subscribe(eventName, handler);
+ }
+
+ public void unsubscribeFromEvent(String eventName, Consumer handler) {
+ commandManager.unsubscribe(eventName, handler);
+ }
+
+ public void unsubscribeAllFromEvent(String eventName) {
+ commandManager.unsubscribeAll(eventName);
+ }
+
+ public boolean isConnected() {
+ return connected && !isClosed();
+ }
+
+ public CDPCommandManager getCommandManager() {
+ return commandManager;
+ }
+
+ @Override
+ public void close() {
+ commandManager.clear();
+ super.close();
+ }
+}
diff --git a/nihonium/src/test/java/example/ByTest.java b/nihonium/src/test/java/example/ByTest.java
new file mode 100644
index 000000000..bdf0838b9
--- /dev/null
+++ b/nihonium/src/test/java/example/ByTest.java
@@ -0,0 +1,183 @@
+package example;
+
+import org.junit.jupiter.api.Test;
+
+import example.By;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ByTest {
+
+ @Test
+ void cssSelector_returnsCssSelector() {
+ By by = By.cssSelector(".foo > span");
+ assertEquals(".foo > span", by.toCssSelector());
+ assertFalse(by.isXPath());
+ }
+
+ @Test
+ void id_buildsPoundSelector() {
+ By by = By.id("submit-btn");
+ assertEquals("#submit-btn", by.toCssSelector());
+ assertFalse(by.isXPath());
+ }
+
+ @Test
+ void className_buildsDotSelector() {
+ By by = By.className("nav-item");
+ assertEquals(".nav-item", by.toCssSelector());
+ assertFalse(by.isXPath());
+ }
+
+ @Test
+ void tagName_returnsTagAsSelector() {
+ By by = By.tagName("input");
+ assertEquals("input", by.toCssSelector());
+ assertFalse(by.isXPath());
+ }
+
+ @Test
+ void name_buildsAttributeSelector() {
+ By by = By.name("username");
+ assertEquals("[name=\"username\"]", by.toCssSelector());
+ assertFalse(by.isXPath());
+ }
+
+ @Test
+ void xpath_isXPathAndNullCss() {
+ By by = By.xpath("//input[@type='text']");
+ assertNull(by.toCssSelector());
+ assertTrue(by.isXPath());
+ assertEquals("//input[@type='text']", by.getSelector());
+ }
+
+ @Test
+ void linkText_isXPath() {
+ By by = By.linkText("Sign in");
+ assertNull(by.toCssSelector());
+ assertTrue(by.isXPath());
+ assertTrue(by.getSelector().contains("normalize-space"));
+ }
+
+ @Test
+ void partialLinkText_isXPath() {
+ By by = By.partialLinkText("Sign");
+ assertNull(by.toCssSelector());
+ assertTrue(by.isXPath());
+ assertTrue(by.getSelector().contains("contains"));
+ }
+
+ @Test
+ void chained_allCss_combinesWithSpace() {
+ By chain = By.chained(By.id("form"), By.cssSelector("input.email"));
+ assertEquals("#form input.email", chain.toCssSelector());
+ assertFalse(chain.isXPath());
+ }
+
+ @Test
+ void chained_threeAllCss_combinesAll() {
+ By chain = By.chained(By.id("page"), By.className("section"), By.tagName("p"));
+ assertEquals("#page .section p", chain.toCssSelector());
+ }
+
+ @Test
+ void chained_singleLocator_returnsItsCss() {
+ By chain = By.chained(By.id("root"));
+ assertEquals("#root", chain.toCssSelector());
+ }
+
+ @Test
+ void chained_withXPath_combinedCssIsNull() {
+ By chain = By.chained(By.id("form"), By.xpath(".//input"));
+ assertNull(chain.toCssSelector());
+ assertFalse(chain.isXPath());
+ }
+
+ @Test
+ void chained_getBys_returnsOriginalLocators() {
+ By first = By.id("parent");
+ By second = By.cssSelector("span.label");
+ By.ByChained chain = (By.ByChained) By.chained(first, second);
+
+ By[] bys = chain.getBys();
+ assertEquals(2, bys.length);
+ assertEquals(first.toCssSelector(), bys[0].toCssSelector());
+ assertEquals(second.toCssSelector(), bys[1].toCssSelector());
+ }
+
+ @Test
+ void chained_emptyArgs_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class, () -> By.chained());
+ }
+
+ @Test
+ void chained_nullArgs_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class, () -> By.chained((By[]) null));
+ }
+
+ @Test
+ void chained_toString_containsChainRepresentation() {
+ By chain = By.chained(By.id("a"), By.cssSelector("b"));
+ assertTrue(chain.toString().toLowerCase().contains("chained"));
+ }
+
+ @Test
+ void index_storesParentAndIndex() {
+ By parent = By.cssSelector(".item");
+ By.ByIndex idx = (By.ByIndex) By.index(parent, 2);
+
+ assertEquals(parent.toCssSelector(), idx.getParent().toCssSelector());
+ assertEquals(2, idx.getIndex());
+ }
+
+ @Test
+ void index_toCssSelectorIsNull() {
+ // ByIndex never exposes a CSS selector (resolution requires querySelectorAll)
+ By idx = By.index(By.cssSelector(".row"), 0);
+ assertNull(idx.toCssSelector());
+ }
+
+ @Test
+ void index_isXPathIsFalse() {
+ By idx = By.index(By.xpath("//li"), 3);
+ assertFalse(idx.isXPath());
+ }
+
+ @Test
+ void index_toString_containsIndexValue() {
+ By idx = By.index(By.id("list"), 5);
+ assertTrue(idx.toString().contains("5"));
+ }
+
+ @Test
+ void index_xpathParent_preservesXPathSelector() {
+ By parent = By.xpath("//li[@class='item']");
+ By.ByIndex idx = (By.ByIndex) By.index(parent, 0);
+ assertTrue(idx.getParent().isXPath());
+ assertEquals("//li[@class='item']", idx.getParent().getSelector());
+ }
+
+ @Test
+ void xpathStringLiteral_simpleString_wrapsInSingleQuotes() {
+ assertEquals("'hello'", By.xpathStringLiteral("hello"));
+ }
+
+ @Test
+ void xpathStringLiteral_withSingleQuote_usesConcatFunction() {
+ String result = By.xpathStringLiteral("it's");
+ assertTrue(result.startsWith("concat("), "Should use concat() for strings with '");
+ assertTrue(result.contains("\"'\""), "Should escape single quote as double-quoted string");
+ }
+
+ @Test
+ void xpathStringLiteral_empty_returnsEmptyLiteral() {
+ assertEquals("''", By.xpathStringLiteral(""));
+ }
+
+ @Test
+ void toString_includesClassName() {
+ assertTrue(By.id("x").toString().toLowerCase().contains("id"));
+ assertTrue(By.cssSelector(".x").toString().toLowerCase().contains("css"));
+ assertTrue(By.xpath("//x").toString().toLowerCase().contains("xpath"));
+ }
+}
diff --git a/nihonium/src/test/java/example/chrome/nihonium/RealWorldScenariosTest.java b/nihonium/src/test/java/example/chrome/nihonium/RealWorldScenariosTest.java
new file mode 100644
index 000000000..55b196e5c
--- /dev/null
+++ b/nihonium/src/test/java/example/chrome/nihonium/RealWorldScenariosTest.java
@@ -0,0 +1,156 @@
+package example.chrome.nihonium;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import example.By;
+import example.WebDriver;
+import example.WebElement;
+import example.chrome.ChromeDriver;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Real-World End-to-End Scenarios")
+class RealWorldScenariosTest {
+
+ WebDriver driver;
+
+ @BeforeEach
+ void setUp() {
+ driver = new ChromeDriver();
+ driver.manage().window().maximize();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (driver != null)
+ driver.quit();
+ }
+
+ @Test
+ @DisplayName("Homepage loads → title and heading are visible")
+ void testHomepageTitleAndHeading() {
+ driver.get("https://asynccodinghub.in/");
+
+ assertFalse(driver.getTitle().isEmpty(), "Page title should not be empty");
+
+ WebElement heading = driver.findElement(By.tagName("h1"));
+ assertFalse(heading.getText().isEmpty(), "H1 heading should be visible on the homepage");
+ }
+
+ @Test
+ @DisplayName("findElements returns multiple navigation links")
+ void testFindElementsReturnsMultipleLinks() {
+ driver.get("https://asynccodinghub.in/");
+
+ List links = driver.findElements(By.tagName("a"));
+ assertTrue(links.size() > 5, "Homepage should contain multiple anchor links, found: " + links.size());
+ }
+
+ @Test
+ @DisplayName("String locator — find element using raw CSS selector")
+ void testFindElementByStringLocator() {
+ driver.get("https://asynccodinghub.in/");
+
+ WebElement heading = driver.findElement("h1");
+ assertFalse(heading.getText().isEmpty(), "H1 found via raw CSS string should have text");
+ }
+
+ @Test
+ @DisplayName("By.chained — find child element scoped within parent")
+ void testByChainedLocator() {
+ driver.get("https://asynccodinghub.in/");
+
+ // Find an anchor nested inside a nav or header section
+ WebElement link = driver.findElement(By.chained(By.tagName("nav"), By.tagName("a")));
+ assertNotNull(link, "Should find an anchor inside nav via By.chained");
+ assertFalse(link.getText().isEmpty(), "Chained locator should resolve to a link with text");
+ }
+
+ @Test
+ @DisplayName("By.index — access nth element from findElements result")
+ void testByIndexLocator() {
+ driver.get("https://asynccodinghub.in/");
+
+ WebElement firstLink = driver.findElement(By.index(By.tagName("a"), 0));
+ WebElement secondLink = driver.findElement(By.index(By.tagName("a"), 1));
+
+ assertNotNull(firstLink, "First link via By.index(0) should be found");
+ assertNotNull(secondLink, "Second link via By.index(1) should be found");
+ assertNotEquals(firstLink.getAttribute("href"), secondLink.getAttribute("href"),
+ "Indexed elements should resolve to distinct links");
+ }
+
+ @Test
+ @DisplayName("Child element scoping — findElements on parent returns only its children")
+ void testChildElementScoping() {
+ driver.get("https://asynccodinghub.in/");
+
+ WebElement nav = driver.findElement(By.tagName("nav"));
+ List navLinks = nav.findElements(By.tagName("a"));
+
+ List allLinks = driver.findElements(By.tagName("a"));
+ assertTrue(navLinks.size() < allLinks.size(), "Nav-scoped links should be fewer than all links on the page");
+ }
+
+ @Test
+ @DisplayName("Click Start Learning → navigates to roadmap page")
+ void testNavigationToRoadmap() {
+ driver.get("https://asynccodinghub.in/");
+
+ driver.findElement(By.xpath("//a[contains(normalize-space(),'Start Learning')]")).click();
+
+ assertTrue(driver.getCurrentUrl().contains("roadmap"),
+ "URL should contain 'roadmap' after clicking Start Learning");
+ }
+
+ @Test
+ @DisplayName("Back navigation — returns to homepage after visiting roadmap")
+ void testBackNavigation() {
+ driver.get("https://asynccodinghub.in/");
+ String homeUrl = driver.getCurrentUrl();
+
+ driver.findElement(By.xpath("//a[contains(normalize-space(),'Start Learning')]")).click();
+
+ driver.navigate().back();
+ assertEquals(homeUrl, driver.getCurrentUrl(), "Back navigation should return to the homepage");
+ }
+
+ @Test
+ @DisplayName("XPath findElements — find all h2 headings on homepage")
+ void testFindElementsByXPath() {
+ driver.get("https://asynccodinghub.in/");
+
+ List headings = driver.findElements(By.xpath("//h2"));
+
+ assertFalse(headings.isEmpty(), "Should find at least one h2 heading via XPath");
+ headings.forEach(h -> assertNotNull(h.getText(), "Each h2 should return text"));
+ }
+
+ @Test
+ @DisplayName("Element attributes — anchor hrefs are non-empty")
+ void testElementAttributes() {
+ driver.get("https://asynccodinghub.in/");
+
+ List links = driver.findElements(By.tagName("a"));
+ assertTrue(links.size() > 0, "Page should have links");
+
+ // Each indexed element should independently resolve and return its href
+ WebElement firstLink = driver.findElement(By.index(By.tagName("a"), 0));
+ assertNotNull(firstLink.getAttribute("href"), "First anchor should have a non-null href attribute");
+ }
+
+ @Test
+ @DisplayName("Page source — contains expected site content")
+ void testGetPageSource() {
+ driver.get("https://asynccodinghub.in/");
+
+ String source = driver.getPageSource();
+ assertFalse(source.isBlank(), "Page source should not be blank");
+ assertTrue(source.contains("= initialSize.getWidth(),
+ "Width should be greater or equal after maximizing");
+ Assertions.assertTrue(maximizedSize.getHeight() >= initialSize.getHeight(),
+ "Height should be greater or equal after maximizing");
+ }
+
+ @Test
+ void testFindElement() {
+ webDriver.get("https://asynccodinghub.in/");
+
+ WebElement heading = webDriver.findElement(By.tagName("h1"));
+ Assertions.assertNotNull(heading, "Heading element should not be null");
+ Assertions.assertFalse(heading.getText().isEmpty(), "Heading text should not be empty");
+ }
+
+ @Test
+ void testSendKeys() {
+ webDriver.get("https://www.google.com");
+
+ WebElement searchBox = webDriver.findElement(By.name("q"));
+ searchBox.sendKeys("Nihonium browser automation");
+
+ String value = searchBox.getAttribute("value");
+ Assertions.assertEquals("Nihonium browser automation", value, "Search box value should match the input");
+ }
+
+ @Test
+ void testClickElement() {
+ webDriver.get("https://asynccodinghub.in/");
+
+ WebElement aboutLink = webDriver.findElement(By.xpath("//a[contains(normalize-space(),'Start Learning')]"));
+ aboutLink.click();
+
+ String currentUrl = webDriver.getCurrentUrl();
+ Assertions.assertEquals("https://asynccodinghub.in/roadmap.html", currentUrl,
+ "URL should match after clicking the link");
+ }
+
+ // ── String locator overloads ──────────────────────────────────────────────
+
+ @Test
+ void testFindElementByRawCssString() {
+ webDriver.get("https://www.google.com");
+ // Pass a CSS selector string directly — no By wrapper needed
+ WebElement searchBox = webDriver.findElement("input[name='q']");
+ assertNotNull(searchBox, "Search box should be found via raw CSS string");
+ searchBox.sendKeys("Nihonium");
+ assertEquals("Nihonium", searchBox.getAttribute("value"), "Typed value should match");
+ }
+
+ @Test
+ void testFindElementsByRawCssString() {
+ webDriver.get("https://asynccodinghub.in/");
+ List links = webDriver.findElements("a");
+ assertTrue(links.size() > 1, "Raw CSS string 'a' should return multiple anchor elements");
+ }
+
+ @Test
+ void testChildFindElementByRawCssString() {
+ webDriver.get("https://the-internet.herokuapp.com/login");
+ WebElement form = webDriver.findElement(By.id("login"));
+ // Child find using raw CSS string
+ WebElement usernameInput = form.findElement("#username");
+ assertNotNull(usernameInput, "Username input found via child string locator");
+ usernameInput.sendKeys("tomsmith");
+ assertEquals("tomsmith", usernameInput.getAttribute("value"));
+ }
+
+ // ── findElements returns ALL matches ─────────────────────────────────────
+
+ @Test
+ void testFindElementsReturnsManyElements() {
+ webDriver.get("https://asynccodinghub.in/");
+ List links = webDriver.findElements(By.tagName("a"));
+ assertTrue(links.size() > 1, "findElements should return more than one element, got: " + links.size());
+ }
+
+ @Test
+ void testFindElementsEachElementIsInteractable() {
+ webDriver.get("https://asynccodinghub.in/");
+ List headings = webDriver.findElements(By.tagName("h2"));
+ assertFalse(headings.isEmpty(), "Page should contain at least one ");
+ // Each element should be individually resolvable
+ for (WebElement h : headings) {
+ assertNotNull(h.getText(), "Each heading element should return text");
+ }
+ }
+
+ @Test
+ void testFindElementsByXPathReturnsMultiple() {
+ webDriver.get("https://asynccodinghub.in/");
+ List links = webDriver.findElements(By.xpath("//a"));
+ assertTrue(links.size() > 1, "XPath findElements should return multiple elements");
+ }
+
+ // ── By.chained ────────────────────────────────────────────────────────────
+
+ @Test
+ void testByChainedAllCssLocator() {
+ webDriver.get("https://the-internet.herokuapp.com/login");
+ // CSS chain: #login → input#username
+ // Combined selector: "#login input#username"
+ WebElement usernameInput = webDriver.findElement(By.chained(By.id("login"), By.cssSelector("input#username")));
+ assertNotNull(usernameInput, "Chained CSS locator should find username input");
+ usernameInput.sendKeys("tomsmith");
+ assertEquals("tomsmith", usernameInput.getAttribute("value"));
+ }
+
+ @Test
+ void testByChainedMixedCssAndXPath() {
+ webDriver.get("https://the-internet.herokuapp.com/login");
+ // Mixed chain: CSS parent + XPath child → step-by-step resolution
+ WebElement passwordField = webDriver
+ .findElement(By.chained(By.id("login"), By.xpath(".//input[@type='password']")));
+ assertNotNull(passwordField, "Mixed chained locator should find password field");
+ assertTrue(passwordField.isEnabled(), "Password field should be enabled");
+ }
+
+ @Test
+ void testByChainedThreeLevels() {
+ webDriver.get("https://the-internet.herokuapp.com/login");
+ // Three-level CSS chain
+ WebElement input = webDriver
+ .findElement(By.chained(By.id("login"), By.className("row"), By.cssSelector("input[name='username']")));
+ assertNotNull(input, "Three-level chained locator should resolve correctly");
+ }
+
+ // ── Child element scoping ─────────────────────────────────────────────────
+
+ @Test
+ void testChildFindElementScopedToParent() {
+ webDriver.get("https://the-internet.herokuapp.com/login");
+ WebElement form = webDriver.findElement(By.id("login"));
+
+ // findElement on the parent element should scope the search inside it
+ WebElement username = form.findElement(By.id("username"));
+ WebElement password = form.findElement(By.id("password"));
+
+ assertNotNull(username, "Username field should be found within form");
+ assertNotNull(password, "Password field should be found within form");
+
+ username.sendKeys("tomsmith");
+ password.sendKeys("SuperSecretPassword!");
+
+ assertEquals("tomsmith", username.getAttribute("value"));
+ assertEquals("SuperSecretPassword!", password.getAttribute("value"));
+ }
+
+ @Test
+ void testChildFindElementsByTagNameScopedToParent() {
+ webDriver.get("https://the-internet.herokuapp.com/login");
+ WebElement form = webDriver.findElement(By.id("login"));
+
+ List inputs = form.findElements(By.tagName("input"));
+ // Login form has exactly two elements: username and password
+ assertEquals(2, inputs.size(), "findElements on form element should return only inputs within the form");
+ }
+
+ @Test
+ void testChildFindElementsByXPathScopedToParent() {
+ webDriver.get("https://the-internet.herokuapp.com/login");
+ WebElement form = webDriver.findElement(By.id("login"));
+
+ List inputs = form.findElements(By.xpath(".//input"));
+ assertEquals(2, inputs.size(), "XPath findElements scoped to form should return 2 inputs");
+ }
+
+ @Test
+ void testChildFindElementsByRawCssString() {
+ webDriver.get("https://the-internet.herokuapp.com/login");
+ WebElement form = webDriver.findElement(By.id("login"));
+ List