org.openqa.selenium.remote.RemoteWebDriverBuilder Maven / Gradle / Ivy
Show all versions of selenium-remote-driver Show documentation
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package org.openqa.selenium.remote;
import static java.util.logging.Level.WARNING;
import static org.openqa.selenium.internal.Debug.getDebugLogLevel;
import static org.openqa.selenium.remote.DriverCommand.QUIT;
import static org.openqa.selenium.remote.http.HttpMethod.DELETE;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.openqa.selenium.Beta;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.Credentials;
import org.openqa.selenium.ImmutableCapabilities;
import org.openqa.selenium.SessionNotCreatedException;
import org.openqa.selenium.UsernameAndPassword;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebDriverInfo;
import org.openqa.selenium.internal.Either;
import org.openqa.selenium.internal.Require;
import org.openqa.selenium.remote.http.ClientConfig;
import org.openqa.selenium.remote.http.DumpHttpExchangeFilter;
import org.openqa.selenium.remote.http.Filter;
import org.openqa.selenium.remote.http.HttpClient;
import org.openqa.selenium.remote.http.HttpHandler;
import org.openqa.selenium.remote.http.HttpRequest;
import org.openqa.selenium.remote.http.HttpResponse;
import org.openqa.selenium.remote.service.DriverService;
/**
* Create a new Selenium session using the W3C WebDriver protocol. This class will not generate any
* data expected by the original JSON Wire Protocol, so will fail to create sessions as expected if
* used against a server that only implements that protocol.
*
* Expected usage is something like:
*
*
* WebDriver driver = RemoteWebDriver.builder()
* .addAlternative(new FirefoxOptions())
* .addAlternative(new ChromeOptions())
* .addMetadata("cloud:key", "hunter2")
* .setCapability("proxy", new Proxy())
* .build();
*
*
* In this example, we ask for a session where the browser will be either Firefox or Chrome (we
* don't care which), but where either browser will use the given {@link org.openqa.selenium.Proxy}.
* In addition, we've added some metadata to the session, setting the "{@code cloud.key}" to be the
* secret passphrase of our account with the cloud "Selenium as a Service" provider.
*
* If no call to {@link #withDriverService(DriverService)} or {@link #address(URI)} is made, the
* builder will use {@link ServiceLoader} to find all instances of {@link WebDriverInfo} and will
* call {@link WebDriverInfo#createDriver(Capabilities)} for the first supported set of
* capabilities.
*/
@Beta
public class RemoteWebDriverBuilder {
private static final Logger LOG = Logger.getLogger(RemoteWebDriverBuilder.class.getName());
private static final Set ILLEGAL_METADATA_KEYS =
ImmutableSet.of("alwaysMatch", "capabilities", "desiredCapabilities", "firstMatch");
private final List requestedCapabilities = new ArrayList<>();
private final Map additionalCapabilities = new TreeMap<>();
private final Map metadata = new TreeMap<>();
private Function handlerFactory =
config -> {
HttpClient.Factory factory = HttpClient.Factory.createDefault();
HttpClient client = factory.createClient(config);
return client.with(new CloseHttpClientFilter(factory, client));
};
private ClientConfig clientConfig = ClientConfig.defaultConfig();
private URI remoteHost = null;
private DriverService driverService;
private Credentials credentials = null;
private boolean useCustomConfig;
private Augmenter augmenter = new Augmenter();
RemoteWebDriverBuilder() {
// Access through RemoteWebDriver.builder
}
/**
* Clears the current set of alternative browsers and instead sets the list of possible choices to
* the arguments given to this method.
*/
public RemoteWebDriverBuilder oneOf(Capabilities maybeThis, Capabilities... orOneOfThese) {
Require.nonNull("Capabilities to use", maybeThis);
if (!requestedCapabilities.isEmpty()) {
LOG.log(
getDebugLogLevel(), "Removing existing requested capabilities: " + requestedCapabilities);
requestedCapabilities.clear();
}
addAlternative(maybeThis);
for (Capabilities caps : orOneOfThese) {
Require.nonNull("Capabilities to use", caps);
addAlternative(caps);
}
return this;
}
/**
* Add to the list of possible configurations that might be asked for. It is possible to ask for
* more than one type of browser per session. For example, perhaps you have an extension that is
* available for two different kinds of browser, and you'd like to test it).
*/
public RemoteWebDriverBuilder addAlternative(Capabilities options) {
Require.nonNull("Capabilities to use", options);
requestedCapabilities.add(new ImmutableCapabilities(options));
return this;
}
/**
* Adds metadata to the outgoing new session request, which can be used by intermediary of end
* nodes for any purpose they choose (commonly, this is used to request additional features from
* cloud providers, such as video recordings or to set the timezone or screen size). Neither
* parameter can be {@code null}.
*/
public RemoteWebDriverBuilder addMetadata(String key, Object value) {
Require.nonNull("Metadata key", key);
Require.nonNull("Metadata value", value);
if (ILLEGAL_METADATA_KEYS.contains(key)) {
throw new IllegalArgumentException(String.format("Cannot add %s as metadata key", key));
}
Object previous = metadata.put(key, value);
if (previous != null) {
LOG.log(
getDebugLogLevel(),
String.format(
"Overwriting metadata %s. Previous value %s, new value %s", key, previous, value));
}
return this;
}
/**
* Sets a capability for every single alternative when the session is created. These capabilities
* are only set once the session is created, so this will be set on capabilities added via {@link
* #addAlternative(Capabilities)} or {@link #oneOf(Capabilities, Capabilities...)} even after this
* method call.
*/
public RemoteWebDriverBuilder setCapability(String capabilityName, Object value) {
Require.nonNull("Capability name", capabilityName);
Require.nonNull("Capability value", value);
Object previous = additionalCapabilities.put(capabilityName, value);
if (previous != null) {
LOG.log(
getDebugLogLevel(),
() ->
String.format(
"Overwriting capability %s. Previous value %s, new value %s",
capabilityName, previous, value));
}
return this;
}
/**
* @see #address(URI)
*/
public RemoteWebDriverBuilder address(String uri) {
Require.nonNull("Address", uri);
try {
return address(new URI(uri));
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Unable to create URI from " + uri);
}
}
/**
* @see #address(URI)
*/
public RemoteWebDriverBuilder address(URL url) {
Require.nonNull("Address", url);
try {
return address(url.toURI());
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Unable to create URI from " + url);
}
}
/**
* Set the URI of the remote server. If this URI is not set, then it assumed that a local running
* remote webdriver session is needed. It is an error to call this method and also {@link
* #withDriverService(DriverService)}.
*/
public RemoteWebDriverBuilder address(URI uri) {
Require.nonNull("URI", uri);
if (driverService != null
|| (clientConfig.baseUri() != null && !clientConfig.baseUri().equals(uri))) {
throw new IllegalArgumentException(
"Attempted to set the base uri on both this builder and the http client config. "
+ "Please set in only one place. "
+ uri);
}
remoteHost = uri;
return this;
}
public RemoteWebDriverBuilder authenticateAs(UsernameAndPassword usernameAndPassword) {
Require.nonNull("User name and password", usernameAndPassword);
this.credentials = usernameAndPassword;
return this;
}
/**
* Allows precise control of the {@link ClientConfig} to use with remote instances. If {@link
* ClientConfig#baseUri(URI)} has been called, then that will be used as the base URI for the
* session.
*/
public RemoteWebDriverBuilder config(ClientConfig config) {
Require.nonNull("HTTP client config", config);
if (config.baseUri() != null) {
if (remoteHost != null || driverService != null) {
throw new IllegalArgumentException(
"Base URI has already been set. Cannot also set it via client config");
}
}
this.clientConfig = config;
this.useCustomConfig = true;
return this;
}
/**
* Use the given {@link DriverService} to set up the webdriver instance. It is an error to set
* both this and also call {@link #address(URI)}.
*/
public RemoteWebDriverBuilder withDriverService(DriverService service) {
Require.nonNull("Driver service", service);
if (clientConfig.baseUri() != null || remoteHost != null) {
throw new IllegalArgumentException(
"Base URI has already been set. Cannot also set driver service.");
}
this.driverService = service;
return this;
}
@VisibleForTesting
RemoteWebDriverBuilder connectingWith(Function handlerFactory) {
Require.nonNull("Handler factory", handlerFactory);
this.handlerFactory = handlerFactory;
return this;
}
/**
* @param augmenter The {@link Augmenter} to use when creating the {@link WebDriver} instance.
*/
public RemoteWebDriverBuilder augmentUsing(Augmenter augmenter) {
Require.nonNull("Augmenter", augmenter);
this.augmenter = augmenter;
return this;
}
@VisibleForTesting
WebDriver getLocalDriver() {
if (remoteHost != null || clientConfig.baseUri() != null || driverService != null) {
return null;
}
Set infos =
StreamSupport.stream(ServiceLoader.load(WebDriverInfo.class).spliterator(), false)
.filter(WebDriverInfo::isAvailable)
.collect(Collectors.toSet());
Capabilities additional = new ImmutableCapabilities(additionalCapabilities);
Optional> first =
requestedCapabilities.stream()
.map(caps -> caps.merge(additional))
.flatMap(
caps ->
infos.stream()
.filter(WebDriverInfo::isAvailable)
.filter(info -> info.isSupporting(caps))
.map(
info ->
(Supplier)
() ->
info.createDriver(caps)
.orElseThrow(
() ->
new SessionNotCreatedException(
"Unable to create session with " + caps))))
.findFirst();
if (!first.isPresent()) {
throw new SessionNotCreatedException("Unable to find matching driver for capabilities");
}
WebDriver localDriver = first.get().get();
if (localDriver != null && this.useCustomConfig) {
localDriver.quit();
throw new IllegalArgumentException("ClientConfig instances do not work for Local Drivers");
}
return localDriver;
}
/**
* Actually create a new WebDriver session. The returned webdriver is not guaranteed to be a
* {@link RemoteWebDriver}.
*/
public WebDriver build() {
if (requestedCapabilities.isEmpty() && additionalCapabilities.isEmpty()) {
throw new SessionNotCreatedException("One set of browser options must be specified");
}
Set clobberedCapabilities = getClobberedCapabilities();
if (!clobberedCapabilities.isEmpty()) {
throw new IllegalArgumentException(
String.format(
"Unable to create session. Additional capabilities %s overwrite capabilities in"
+ " requested options",
clobberedCapabilities));
}
WebDriver driver = getLocalDriver();
if (driver == null) {
driver = getRemoteDriver();
}
return augmenter.augment(driver);
}
private WebDriver getRemoteDriver() {
startDriverServiceIfNecessary();
ClientConfig driverClientConfig = clientConfig;
URI baseUri = getBaseUri();
if (baseUri != null) {
driverClientConfig = driverClientConfig.baseUri(baseUri);
}
if (credentials != null) {
driverClientConfig = driverClientConfig.authenticateAs(credentials);
}
HttpHandler client = handlerFactory.apply(driverClientConfig);
HttpHandler handler =
Require.nonNull("Http handler", client)
.with(
new AddWebDriverSpecHeaders()
.andThen(new ErrorFilter())
.andThen(new DumpHttpExchangeFilter()));
Either result;
try {
result = new ProtocolHandshake().createSession(handler, getPayload());
} catch (IOException e) {
throw new SessionNotCreatedException("Unable to create new remote session.", e);
}
if (result.isRight()) {
CommandExecutor executor = result.map(res -> createExecutor(handler, res));
return new RemoteWebDriver(executor, new ImmutableCapabilities());
} else {
throw result.left();
}
}
private URI getBaseUri() {
if (remoteHost != null) {
return remoteHost;
}
if (driverService != null && driverService.isRunning()) {
try {
return driverService.getUrl().toURI();
} catch (URISyntaxException e) {
throw new IllegalStateException("Unable to get driver service URI", e);
}
}
return clientConfig.baseUri();
}
private DriverService startDriverServiceIfNecessary() {
if (driverService == null) {
return null;
}
try {
driverService.start();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return driverService;
}
private CommandExecutor createExecutor(HttpHandler handler, ProtocolHandshake.Result result) {
Dialect dialect = result.getDialect();
Function commandEncoder = dialect.getCommandCodec()::encode;
Function responseDecoder = dialect.getResponseCodec()::decode;
Response newSessionResponse = result.createResponse();
String id = newSessionResponse.getSessionId();
CommandExecutor baseExecutor =
cmd -> commandEncoder.andThen(handler::execute).andThen(responseDecoder).apply(cmd);
CommandExecutor handleNewSession =
cmd -> {
if (DriverCommand.NEW_SESSION.equals(cmd.getName())) {
return newSessionResponse;
}
return baseExecutor.execute(cmd);
};
CommandExecutor addSessionId =
cmd -> {
Response res = handleNewSession.execute(cmd);
if (res.getSessionId() == null) {
res.setSessionId(id);
}
return res;
};
CommandExecutor stopService =
cmd -> {
try {
return addSessionId.execute(cmd);
} finally {
if (driverService != null && QUIT.equals(cmd.getName())) {
try {
driverService.stop();
} catch (Exception e) {
// Fall through.
}
}
}
};
return stopService;
}
private Set getClobberedCapabilities() {
Set names = additionalCapabilities.keySet();
return requestedCapabilities.stream()
.map(Capabilities::getCapabilityNames)
.flatMap(Collection::stream)
.filter(names::contains)
.collect(Collectors.toSet());
}
private NewSessionPayload getPayload() {
Map roughPayload = new TreeMap<>(metadata);
Map w3cCaps = new TreeMap<>();
w3cCaps.put("alwaysMatch", additionalCapabilities);
if (!requestedCapabilities.isEmpty()) {
w3cCaps.put("firstMatch", requestedCapabilities);
}
roughPayload.put("capabilities", w3cCaps);
return NewSessionPayload.create(roughPayload);
}
private static class CloseHttpClientFilter implements Filter {
private final HttpClient.Factory factory;
private final HttpClient client;
CloseHttpClientFilter(HttpClient.Factory factory, HttpClient client) {
this.factory = Require.nonNull("Http client factory", factory);
this.client = Require.nonNull("Http client", client);
}
@Override
public HttpHandler apply(HttpHandler next) {
return req -> {
try {
return next.execute(req);
} finally {
if (req.getMethod() == DELETE) {
HttpSessionId.getSessionId(req.getUri())
.ifPresent(
id -> {
if (("/session/" + id).equals(req.getUri())) {
try {
client.close();
} catch (Exception e) {
LOG.log(WARNING, "Exception swallowed while closing http client", e);
}
factory.cleanupIdleClients();
}
});
}
}
};
}
}
}