com.intuit.karate.driver.playwright.PlaywrightDriver Maven / Gradle / Ivy
The newest version!
/*
* The MIT License
*
* Copyright 2022 Karate Labs Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.intuit.karate.driver.playwright;
import com.intuit.karate.Logger;
import com.intuit.karate.StringUtils;
import com.intuit.karate.Json;
import com.intuit.karate.JsonUtils;
import com.intuit.karate.core.ScenarioRuntime;
import com.intuit.karate.driver.Driver;
import com.intuit.karate.driver.DriverElement;
import com.intuit.karate.driver.DriverOptions;
import com.intuit.karate.driver.Element;
import com.intuit.karate.driver.Input;
import com.intuit.karate.driver.Keys;
import com.intuit.karate.http.ResourceType;
import com.intuit.karate.http.WebSocketClient;
import com.intuit.karate.http.WebSocketOptions;
import com.intuit.karate.shell.Command;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
/**
*
* @author pthomas3
*/
public class PlaywrightDriver implements Driver {
public static final String DRIVER_TYPE = "playwright";
private final DriverOptions options;
private final Command command;
private final WebSocketClient client;
private final PlaywrightWait wait;
private final Logger logger;
private boolean submit;
private boolean initialized;
private boolean terminated;
private String browserGuid;
private String browserContextGuid;
private final Object LOCK = new Object();
private void lockAndWait() {
synchronized (LOCK) {
try {
LOCK.wait();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
protected void unlockAndProceed() {
initialized = true;
synchronized (LOCK) {
LOCK.notify();
}
}
private int nextId;
public int nextId() {
return ++nextId;
}
public void waitSync() {
client.waitSync();
}
public static PlaywrightDriver start(Map map, ScenarioRuntime sr) {
DriverOptions options = new DriverOptions(map, sr, 4444, "playwright");
String playwrightUrl;
Command command;
if (options.start) {
Map pwOptions = options.playwrightOptions == null ? Collections.EMPTY_MAP : options.playwrightOptions;
options.arg(options.port + "");
String browserType = (String) pwOptions.get("browserType");
if (browserType == null) {
browserType = "chromium";
}
options.arg(browserType);
if (options.headless) {
options.arg("true");
}
CompletableFuture future = new CompletableFuture();
command = options.startProcess(s -> {
int pos = s.indexOf("ws://");
if (pos != -1) {
s = s.substring(pos).trim();
pos = s.indexOf(' ');
if (pos != -1) {
s = s.substring(0, pos);
}
future.complete(s);
}
});
try {
playwrightUrl = future.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
options.processLogger.debug("playwright server url ready: {}", playwrightUrl);
} else {
command = null;
playwrightUrl = options.playwrightUrl;
if (playwrightUrl == null) {
throw new RuntimeException("playwrightUrl is mandatory if start == false");
}
}
return new PlaywrightDriver(options, command, playwrightUrl);
}
public PlaywrightDriver(DriverOptions options, Command command, String webSocketUrl) {
this.options = options;
logger = options.driverLogger;
this.command = command;
wait = new PlaywrightWait(this, options);
WebSocketOptions wsOptions = new WebSocketOptions(webSocketUrl);
wsOptions.setMaxPayloadSize(options.maxPayloadSize);
wsOptions.setTextConsumer(text -> {
if (logger.isTraceEnabled()) {
logger.trace("<< {}", text);
} else {
// to avoid swamping the console when large base64 encoded binary responses happen
logger.debug("<< {}", StringUtils.truncate(text, 1024, true));
}
Map map = Json.of(text).value();
PlaywrightMessage pwm = new PlaywrightMessage(this, map);
receive(pwm);
});
client = new WebSocketClient(wsOptions, logger);
lockAndWait();
logger.debug("contexts ready, frame: {}, page: {}, browser-context: {}, browser: {}",
currentFrame, currentPage, browserContextGuid, browserGuid);
}
private PlaywrightMessage method(String method, String guid) {
return new PlaywrightMessage(this, method, guid);
}
public void send(PlaywrightMessage pwm) {
String json = JsonUtils.toJson(pwm.toMap());
logger.debug(">> {}", json);
client.send(json);
}
private String currentDialog;
private String currentDialogText;
private String currentDialogType;
private boolean dialogAccept = true;
private String dialogInput = "";
private String currentFrame;
private String currentPage;
private final Map> pageFrames = new LinkedHashMap();
private final Map frameInfo = new HashMap();
private PlaywrightMessage page(String method) {
return method(method, currentPage);
}
private PlaywrightMessage frame(String method) {
return method(method, currentFrame);
}
private static class Frame {
final String frameGuid;
final String url;
final String name;
Frame(String frameGuid, String url, String name) {
this.frameGuid = frameGuid;
this.url = url;
this.name = name;
}
}
public void receive(PlaywrightMessage pwm) {
if (pwm.methodIs("frameAttached")) {
String pageGuid = pwm.getGuid();
String frameGuid = pwm.getParam("frame.guid");
Set frames = pageFrames.get(pageGuid);
if (frames == null) {
frames = new LinkedHashSet(); // order important !!
pageFrames.put(pageGuid, frames);
}
frames.add(frameGuid);
} else if (pwm.methodIs("frameDetached")) {
String pageGuid = pwm.getGuid();
String frameGuid = pwm.getParam("frame.guid");
frameInfo.remove(frameGuid);
Set frames = pageFrames.get(pageGuid);
frames.remove(frameGuid);
} else if (pwm.methodIs("navigated")) {
String frameGuid = pwm.getGuid();
String url = pwm.getParam("url");
String name = pwm.getParam("name");
frameInfo.put(frameGuid, new Frame(frameGuid, url, name));
} else if (pwm.methodIs("__create__")) {
if (pwm.paramHas("type", "Page")) {
String pageGuid = pwm.getParam("guid");
String frameGuid = pwm.getParam("initializer.mainFrame.guid");
Set frames = pageFrames.get(pageGuid);
if (frames == null) {
frames = new LinkedHashSet(); // order important !!
pageFrames.put(pageGuid, frames);
}
frames.add(frameGuid);
if (!initialized) {
currentPage = pageGuid;
currentFrame = frameGuid;
unlockAndProceed();
}
} else if (pwm.paramHas("type", "Dialog")) {
currentDialog = pwm.getParam("guid");
currentDialogText = pwm.getParam("initializer.message");
currentDialogType = pwm.getParam("initializer.type");
if ("alert".equals(currentDialogType)) {
method("dismiss", currentDialog).sendWithoutWaiting();
} else {
if (dialogInput == null) {
dialogInput = "";
}
method(dialogAccept ? "accept" : "dismiss", currentDialog)
.param("promptText", dialogInput).sendWithoutWaiting();
}
} else if (pwm.paramHas("type", "Browser")) {
browserGuid = pwm.getParam("guid");
Map map = new HashMap();
map.put("sdkLanguage", "javascript");
if (!options.headless) {
map.put("noDefaultViewport", false);
}
if (options.playwrightOptions != null) {
Map temp = (Map) options.playwrightOptions.get("context");
if (temp != null) {
map.putAll(temp);
}
}
method("newContext", browserGuid).params(map).sendWithoutWaiting();
} else if (pwm.paramHas("type", "BrowserContext")) {
browserContextGuid = pwm.getParam("guid");
method("newPage", browserContextGuid).sendWithoutWaiting();
} else {
logger.trace("ignoring __create__: {}", pwm);
}
} else {
wait.receive(pwm);
}
}
public PlaywrightMessage sendAndWait(PlaywrightMessage pwm, Predicate condition) {
boolean wasSubmit = submit;
if (condition == null && submit) {
submit = false;
condition = PlaywrightWait.DOM_CONTENT_LOADED;
}
// do stuff inside wait to avoid missing messages
PlaywrightMessage result = wait.send(pwm, condition);
if (result == null && !wasSubmit) {
throw new RuntimeException("failed to get reply for: " + pwm);
}
return result;
}
@Override
public DriverOptions getOptions() {
return options;
}
@Override
public Driver timeout(Integer millis) {
options.setTimeout(millis);
return this;
}
@Override
public Driver timeout() {
return timeout(null);
}
private static final Map NO_ARGS = Json.of("{ value: { v: 'undefined' }, handles: [] }").value();
private PlaywrightMessage evalOnce(String expression, boolean quickly, boolean fireAndForget) {
PlaywrightMessage toSend = frame("evaluateExpression")
.param("expression", expression)
.param("isFunction", false)
.param("arg", NO_ARGS);
if (quickly) {
toSend.setTimeout(options.getRetryInterval());
}
if (fireAndForget) {
toSend.sendWithoutWaiting();
return null;
}
return toSend.send();
}
private PlaywrightMessage eval(String expression) {
return eval(expression, false);
}
private PlaywrightMessage eval(String expression, boolean quickly) {
PlaywrightMessage pwm = evalOnce(expression, quickly, false);
if (pwm.isError()) {
String message = "js eval failed once:" + expression
+ ", error: " + pwm.getResult();
logger.warn(message);
options.sleep();
pwm = evalOnce(expression, quickly, false); // no wait condition for the re-try
if (pwm.isError()) {
message = "js eval failed twice:" + expression
+ ", error: " + pwm.getResult();
logger.error(message);
throw new RuntimeException(message);
}
}
return pwm;
}
@Override
public Object script(String expression) {
return eval(expression).getResultValue();
}
@Override
public String elementId(String locator) {
return frame("querySelector").param("selector", locator).send().getResult("element.guid");
}
@Override
public List elementIds(String locator) {
throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates.
}
private void retryIfEnabled(String locator) {
if (options.isRetryEnabled()) {
waitFor(locator); // will throw exception if not found
}
if (options.highlight) {
// highlight(locator, options.highlightDuration); // instead of this
String highlightJs = options.highlight(locator, options.highlightDuration);
evalOnce(highlightJs, true, true); // do it safely, i.e. fire and forget
}
}
@Override
public void setUrl(String url) {
frame("goto").param("url", url).param("waitUntil", "load").send();
}
@Override
public void activate() {
page("bringToFront").send();
}
@Override
public void refresh() {
page("reload").param("waitUntil", "load").send();
}
@Override
public void reload() {
refresh(); // TODO ignore cache ?
}
@Override
public void back() {
page("goBack").param("waitUntil", "load").send();
}
@Override
public void forward() {
page("goForward").param("waitUntil", "load").send();
}
@Override
public void maximize() {
// https://github.com/microsoft/playwright/issues/1086
}
@Override
public void minimize() {
// see maximize()
}
@Override
public void fullscreen() {
// TODO JS
}
@Override
public void close() {
page("close").send();
}
@Override
public void quit() {
if (terminated) {
return;
}
terminated = true;
method("close", browserGuid).sendWithoutWaiting();
client.close();
if (command != null) {
// cannot force else node process does not terminate gracefully
command.close(false);
}
}
@Override
public String property(String id, String name) {
retryIfEnabled(id);
return eval(DriverOptions.selector(id) + "['" + name + "']").getResultValue();
}
@Override
public String html(String id) {
return property(id, "outerHTML");
}
@Override
public String text(String id) {
return property(id, "textContent");
}
@Override
public String value(String locator) {
return property(locator, "value");
}
@Override
public String getUrl() {
return eval("document.location.href").getResultValue();
}
@Override
public void setDimensions(Map map) {
// todo
}
@Override
public String getTitle() {
return eval("document.title").getResultValue();
}
@Override
public Element click(String locator) {
retryIfEnabled(locator);
eval(DriverOptions.selector(locator) + ".click()");
return DriverElement.locatorExists(this, locator);
}
@Override
public Element value(String locator, String value) {
retryIfEnabled(locator);
eval(DriverOptions.selector(locator) + ".value = '" + value + "'");
return DriverElement.locatorExists(this, locator);
}
@Override
public String attribute(String id, String name) {
retryIfEnabled(id);
return eval(DriverOptions.selector(id) + ".getAttribute('" + name + "')").getResultValue();
}
@Override
public boolean enabled(String id) {
retryIfEnabled(id);
PlaywrightMessage pwm = eval(DriverOptions.selector(id) + ".disabled");
Boolean disabled = pwm.getResultValue();
return !disabled;
}
@Override
public boolean waitUntil(String expression) {
return options.retry(() -> {
try {
return eval(expression, true).getResultValue();
} catch (Exception e) {
logger.warn("waitUntil evaluate failed: {}", e.getMessage());
return false;
}
}, b -> b, "waitUntil (js)", true);
}
@Override
public Driver submit() {
submit = true;
return this;
}
@Override
public Element focus(String locator) {
retryIfEnabled(locator);
eval(options.focusJs(locator));
return DriverElement.locatorExists(this, locator);
}
@Override
public Element clear(String locator) {
eval(DriverOptions.selector(locator) + ".value = ''");
return DriverElement.locatorExists(this, locator);
}
@Override
public Map position(String locator) {
return position(locator, false);
}
@Override
public Map position(String locator, boolean relative) {
boolean submitTemp = submit; // in case we are prepping for a submit().mouse(locator).click()
submit = false;
retryIfEnabled(locator);
Map map = eval(relative ? DriverOptions.getRelativePositionJs(locator) : DriverOptions.getPositionJs(locator)).getResultValue();
submit = submitTemp;
return map;
}
private PlaywrightMessage evalFrame(String frameGuid, String expression) {
return method("evaluateExpression", frameGuid)
.param("expression", expression)
.param("isFunction", false)
.param("arg", NO_ARGS).send();
}
@Override
public void switchPage(String titleOrUrl) {
if (titleOrUrl == null) {
return;
}
for (Map.Entry> entry : pageFrames.entrySet()) {
String pageGuid = entry.getKey();
String frameGuid = entry.getValue().iterator().next();
String title = evalFrame(frameGuid, "document.title").getResultValue();
if (title != null && title.contains(titleOrUrl)) {
currentPage = pageGuid;
currentFrame = frameGuid;
activate();
return;
}
String url = evalFrame(frameGuid, "document.location.href").getResultValue();
if (url != null && url.contains(titleOrUrl)) {
currentPage = pageGuid;
currentFrame = frameGuid;
activate();
return;
}
}
logger.warn("failed to find page by title / url: {}", titleOrUrl);
}
@Override
public void switchPage(int index) {
if (index == -1 || index >= pageFrames.size()) {
logger.warn("not switching page for size {}: {}", pageFrames.size(), index);
return;
}
List temp = getPages();
currentPage = temp.get(index);
currentFrame = pageFrames.get(currentPage).iterator().next();
activate();
}
private void waitForFrame(String previousFrame) {
String previousFrameUrl = frameInfo.get(previousFrame).url;
logger.debug("waiting for frame url to switch from: {} - {}", previousFrame, previousFrameUrl);
Integer retryInterval = options.getRetryInterval();
options.setRetryInterval(1000); // reduce retry interval for this special case
options.retry(() -> evalFrame(currentFrame, "document.location.href"),
pwm -> !pwm.isError() && !pwm.getResultValue().equals(previousFrameUrl), "waiting for frame context", false);
options.setRetryInterval(retryInterval); // restore
}
@Override
public void switchFrame(int index) {
String previousFrame = currentFrame;
List temp = new ArrayList(pageFrames.get(currentPage));
index = index + 1; // the root frame is always zero, api here is consistent with webdriver etc
if (index < temp.size()) {
currentFrame = temp.get(index);
logger.debug("switched to frame: {} - pages: {}", currentFrame, pageFrames);
waitForFrame(previousFrame);
} else {
logger.warn("not switching frame for size {}: {}", temp.size(), index);
}
}
@Override
public void switchFrame(String locator) {
String previousFrame = currentFrame;
if (locator == null) {
switchFrame(-1);
} else {
if (locator.startsWith("#")) { // TODO get reference to frame element via locator
locator = locator.substring(1);
}
for (Frame frame : frameInfo.values()) {
if (frame.url.contains(locator) || frame.name.contains(locator)) {
currentFrame = frame.frameGuid;
logger.debug("switched to frame: {} - pages: {}", currentFrame, pageFrames);
waitForFrame(previousFrame);
return;
}
}
}
}
@Override
public Map getDimensions() {
logger.warn("getDimensions() not supported");
return Collections.EMPTY_MAP;
}
@Override
public List getPages() {
return new ArrayList(pageFrames.keySet());
}
@Override
public String getDialogText() {
return currentDialogText;
}
@Override
public byte[] screenshot(boolean embed) {
return screenshot(null, embed);
}
@Override
public Map cookie(String name) {
List