diff --git a/build.gradle b/build.gradle
index aeae50994f..7459e7cb76 100644
--- a/build.gradle
+++ b/build.gradle
@@ -44,6 +44,7 @@ configure(allprojects) { project ->
ext.httpclientVersion = "4.5"
ext.httpasyncVersion = "4.1"
ext.jackson2Version = "2.6.0"
+ ext.htmlunitVersion = "2.17"
ext.jasperreportsVersion = "6.1.0"
ext.javamailVersion = "1.5.4"
ext.jettyVersion = "9.3.1.v20150714"
@@ -56,6 +57,7 @@ configure(allprojects) { project ->
ext.poiVersion = "3.12"
ext.protobufVersion = "2.6.1"
ext.reactorVersion = "2.0.4.RELEASE"
+ ext.seleniumVersion = "2.46.0"
ext.slf4jVersion = "1.7.12"
ext.snakeyamlVersion = "1.15"
ext.snifferVersion = "1.14"
@@ -1009,6 +1011,8 @@ project("spring-test") {
optional("com.jayway.jsonpath:json-path:2.0.0")
optional("org.skyscreamer:jsonassert:1.2.3")
optional("xmlunit:xmlunit:${xmlunitVersion}")
+ optional("net.sourceforge.htmlunit:htmlunit:$htmlunitVersion")
+ optional("org.seleniumhq.selenium:selenium-htmlunit-driver:$seleniumVersion")
testCompile(project(":spring-context-support"))
testCompile(project(":spring-oxm"))
testCompile("javax.mail:javax.mail-api:${javamailVersion}")
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnection.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnection.java
new file mode 100644
index 0000000000..cd03c96786
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnection.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2002-2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.springframework.test.web.servlet.htmlunit;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+import com.gargoylesoftware.htmlunit.WebConnection;
+import com.gargoylesoftware.htmlunit.WebRequest;
+import com.gargoylesoftware.htmlunit.WebResponse;
+
+import org.springframework.util.Assert;
+
+/**
+ *
+ * Implementation of WebConnection that allows delegating to various WebConnection implementations. For example, if
+ * you host your JavaScript on the domain code.jquery.com, you might want to use the following:
+ *
+ * WebClient webClient = new WebClient();
+ *
+ * MockMvc mockMvc = ...
+ * MockMvcWebConnection mockConnection = new MockMvcWebConnection(mockMvc);
+ * mockConnection.setWebClient(webClient);
+ *
+ * WebRequestMatcher cdnMatcher = new UrlRegexRequestMatcher(".*?//code.jquery.com/.*");
+ * WebConnection httpConnection = new HttpWebConnection(webClient);
+ * WebConnection webConnection = new DelegatingWebConnection(mockConnection, new DelegateWebConnection(cdnMatcher, httpConnection));
+ *
+ * webClient.setWebConnection(webConnection);
+ *
+ * WebClient webClient = new WebClient();
+ * webClient.setWebConnection(webConnection);
+ *
+ * @author Rob Winch
+ * @since 4.2
+ */
+public final class DelegatingWebConnection implements WebConnection {
+ private final List connections;
+ private final WebConnection defaultConnection;
+
+ public DelegatingWebConnection(WebConnection defaultConnection, List connections) {
+ Assert.notNull(defaultConnection, "defaultConnection cannot be null");
+ Assert.notEmpty(connections, "connections cannot be empty");
+ this.connections = connections;
+ this.defaultConnection = defaultConnection;
+ }
+
+ public DelegatingWebConnection(WebConnection defaultConnection,DelegateWebConnection... connections) {
+ this(defaultConnection, Arrays.asList(connections));
+ }
+
+ @Override
+ public WebResponse getResponse(WebRequest request) throws IOException {
+ for(DelegateWebConnection connection : connections) {
+ if(connection.getMatcher().matches(request)) {
+ return connection.getDelegate().getResponse(request);
+ }
+ }
+ return defaultConnection.getResponse(request);
+ }
+
+ public final static class DelegateWebConnection {
+ private final WebRequestMatcher matcher;
+ private final WebConnection delegate;
+
+ public DelegateWebConnection(WebRequestMatcher matcher, WebConnection delegate) {
+ this.matcher = matcher;
+ this.delegate = delegate;
+ }
+
+ private WebRequestMatcher getMatcher() {
+ return matcher;
+ }
+
+ private WebConnection getDelegate() {
+ return delegate;
+ }
+ }
+}
\ No newline at end of file
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/ForwardRequestPostProcessor.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/ForwardRequestPostProcessor.java
new file mode 100644
index 0000000000..93a5f5d75c
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/ForwardRequestPostProcessor.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2002-2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.springframework.test.web.servlet.htmlunit;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+import org.springframework.util.Assert;
+
+/**
+ * @author Rob Winch
+ * @since 4.2
+ */
+final class ForwardRequestPostProcessor implements RequestPostProcessor {
+ private final String forwardUrl;
+
+ public ForwardRequestPostProcessor(String url) {
+ Assert.hasText(url, "Forward url must have text");
+ forwardUrl = url;
+ }
+
+ @Override
+ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) {
+ request.setServletPath(forwardUrl);
+ return request;
+ }
+}
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HostRequestMatcher.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HostRequestMatcher.java
new file mode 100644
index 0000000000..9899555038
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HostRequestMatcher.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2002-2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.springframework.test.web.servlet.htmlunit;
+
+import java.net.URL;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import com.gargoylesoftware.htmlunit.WebRequest;
+
+/**
+ *
+ * An implementation of WebRequestMatcher that allows matching on the host and optionally
+ * the port of WebRequest#getUrl(). For example, the following would match any request to
+ * the host "code.jquery.com" without regard for the port:
+ *
+ *
+ *
+ * WebRequestMatcher cdnMatcher = new HostMatcher("code.jquery.com");
+ *
+ *
+ * Multiple hosts can also be passed in. For example, the following would match an request
+ * to the host "code.jquery.com" or the host "cdn.com" without regard for the port:
+ *
+ *
+ * WebRequestMatcher cdnMatcher = new HostMatcher("code.jquery.com", "cdn.com");
+ *
+ *
+ *
+ * Alternatively, one can also specify the port. For example, the following would match
+ * any request to the host "code.jquery.com" with the port of 80.
+ *
+ *
+ *
+ * WebRequestMatcher cdnMatcher = new HostMatcher("code.jquery.com:80");
+ *
+ *
+ *
+ * The above cdnMatcher would match: "http://code.jquery.com/jquery.js" (default port of
+ * 80) and "http://code.jquery.com:80/jquery.js". However, it would not match
+ * "https://code.jquery.com/jquery.js" (default port of 443).
+ *
+ *
+ * @author Rob Winch
+ * @since 4.2
+ * @see UrlRegexRequestMatcher
+ * @see org.springframework.test.web.servlet.htmlunit.DelegatingWebConnection
+ */
+public final class HostRequestMatcher implements WebRequestMatcher {
+ private final Set hosts = new HashSet();
+
+ /**
+ * Creates a new instance
+ *
+ * @param hosts the hosts to match on (i.e. "localhost", "example.com:443")
+ */
+ public HostRequestMatcher(String... hosts) {
+ this.hosts.addAll(Arrays.asList(hosts));
+ }
+
+ @Override
+ public boolean matches(WebRequest request) {
+ URL url = request.getUrl();
+ String host = url.getHost();
+
+ if(hosts.contains(host)) {
+ return true;
+ }
+
+ int port = url.getPort();
+ if(port == -1) {
+ port = url.getDefaultPort();
+ }
+ String hostAndPort = host + ":" + port;
+
+ return hosts.contains(hostAndPort);
+ }
+}
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilder.java
new file mode 100644
index 0000000000..e8e48af01f
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilder.java
@@ -0,0 +1,523 @@
+/*
+ * Copyright 2002-2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.springframework.test.web.servlet.htmlunit;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.StringTokenizer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.ServletContext;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+
+import com.gargoylesoftware.htmlunit.CookieManager;
+import com.gargoylesoftware.htmlunit.WebClient;
+import com.gargoylesoftware.htmlunit.WebRequest;
+import com.gargoylesoftware.htmlunit.util.NameValuePair;
+
+import org.springframework.beans.Mergeable;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.test.web.servlet.RequestBuilder;
+import org.springframework.test.web.servlet.SmartRequestBuilder;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+import org.springframework.util.Assert;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ *
+ * Internal class used to allow a {@link WebRequest} into a {@link MockHttpServletRequest} using Spring MVC Test's
+ * {@link RequestBuilder}.
+ *
+ *
+ * By default the first path segment of the URL is used as the contextPath. To override this default see
+ * {@link #setContextPath(String)}.
+ *
+ *
+ * @author Rob Winch
+ * @since 4.2
+ * @see MockMvcWebConnection
+ */
+final class HtmlUnitRequestBuilder implements RequestBuilder, Mergeable {
+ private final Map sessions;
+
+ private final WebClient webClient;
+
+ private final WebRequest webRequest;
+
+ private String contextPath;
+
+ private RequestBuilder parentBuilder;
+
+ private SmartRequestBuilder parentPostProcessor;
+
+ private RequestPostProcessor forwardPostProcessor;
+
+ /**
+ *
+ * @param sessions A {@link Map} of the {@link HttpSession#getId()} to currently managed {@link HttpSession}
+ * objects. Cannot be null.
+ * @param webClient the WebClient for retrieving cookies
+ * @param webRequest The {@link WebRequest} to transform into a {@link MockHttpServletRequest}. Cannot be null.
+ */
+ public HtmlUnitRequestBuilder(Map sessions, WebClient webClient,
+ WebRequest webRequest) {
+ Assert.notNull(sessions, "sessions cannot be null");
+ Assert.notNull(webClient, "webClient cannot be null");
+ Assert.notNull(webRequest, "webRequest cannot be null");
+
+ this.sessions = sessions;
+ this.webClient = webClient;
+ this.webRequest = webRequest;
+ }
+
+ public MockHttpServletRequest buildRequest(ServletContext servletContext) {
+ String charset = getCharset();
+ String httpMethod = webRequest.getHttpMethod().name();
+ UriComponents uriComponents = uriComponents();
+
+ MockHttpServletRequest result = new HtmlUnitMockHttpServletRequest(servletContext, httpMethod,
+ uriComponents.getPath());
+ parent(result, parentBuilder);
+ result.setServerName(uriComponents.getHost()); // needs to be first for additional headers
+ authType(result);
+ result.setCharacterEncoding(charset);
+ content(result, charset);
+ contextPath(result, uriComponents);
+ contentType(result);
+ cookies(result);
+ headers(result);
+ locales(result);
+ servletPath(uriComponents, result);
+ params(result, uriComponents);
+ ports(uriComponents, result);
+ result.setProtocol("HTTP/1.1");
+ result.setQueryString(uriComponents.getQuery());
+ result.setScheme(uriComponents.getScheme());
+ pathInfo(uriComponents,result);
+
+ return postProcess(result);
+ }
+
+ private MockHttpServletRequest postProcess(MockHttpServletRequest request) {
+ if(parentPostProcessor != null) {
+ request = parentPostProcessor.postProcessRequest(request);
+ }
+ if(forwardPostProcessor != null) {
+ request = forwardPostProcessor.postProcessRequest(request);
+ }
+
+ return request;
+ }
+
+ private void parent(MockHttpServletRequest result, RequestBuilder parent) {
+ if(parent == null) {
+ return;
+ }
+ MockHttpServletRequest parentRequest = parent.buildRequest(result.getServletContext());
+
+ // session
+ HttpSession parentSession = parentRequest.getSession(false);
+ if(parentSession != null) {
+ Enumeration attrNames = parentSession.getAttributeNames();
+ while(attrNames.hasMoreElements()) {
+ String attrName = attrNames.nextElement();
+ Object attrValue = parentSession.getAttribute(attrName);
+ result.getSession().setAttribute(attrName, attrValue);
+ }
+ }
+
+ // header
+ Enumeration headerNames = parentRequest.getHeaderNames();
+ while(headerNames.hasMoreElements()) {
+ String attrName = headerNames.nextElement();
+ Enumeration attrValues = parentRequest.getHeaders(attrName);
+ while(attrValues.hasMoreElements()) {
+ String attrValue = attrValues.nextElement();
+ result.addHeader(attrName, attrValue);
+ }
+ }
+
+ // parameter
+ Map parentParams = parentRequest.getParameterMap();
+ for(Map.Entry parentParam : parentParams.entrySet()) {
+ String paramName = parentParam.getKey();
+ String[] paramValues = parentParam.getValue();
+ result.addParameter(paramName, paramValues);
+ }
+
+ // cookie
+ Cookie[] parentCookies = parentRequest.getCookies();
+ if(parentCookies != null) {
+ result.setCookies(parentCookies);
+ }
+
+ // request attribute
+ Enumeration parentAttrNames = parentRequest.getAttributeNames();
+ while(parentAttrNames.hasMoreElements()) {
+ String parentAttrName = parentAttrNames.nextElement();
+ result.setAttribute(parentAttrName, parentRequest.getAttribute(parentAttrName));
+ }
+ }
+
+ /**
+ * Sets the contextPath to be used. The value may be null in which case the first path segment of the URL is turned
+ * into the contextPath. Otherwise it must conform to {@link HttpServletRequest#getContextPath()} which states it
+ * can be empty string or it must start with a "/" and not end in a "/".
+ *
+ * @param contextPath A valid contextPath
+ * @throws IllegalArgumentException if contextPath is not a valid {@link HttpServletRequest#getContextPath()}.
+ */
+ public void setContextPath(String contextPath) {
+ if (contextPath == null || "".equals(contextPath)) {
+ this.contextPath = contextPath;
+ return;
+ }
+ if (contextPath.endsWith("/")) {
+ throw new IllegalArgumentException("contextPath cannot end with /. Got '" + contextPath + "'");
+ }
+ if (!contextPath.startsWith("/")) {
+ throw new IllegalArgumentException("contextPath must start with /. Got '" + contextPath + "'");
+ }
+ this.contextPath = contextPath;
+ }
+
+ public void setForwardPostProcessor(RequestPostProcessor postProcessor) {
+ this.forwardPostProcessor = postProcessor;
+ }
+
+ private void authType(MockHttpServletRequest request) {
+ String authorization = header("Authorization");
+ if (authorization != null) {
+ String[] authzParts = authorization.split(": ");
+ request.setAuthType(authzParts[0]);
+ }
+ }
+
+ private void content(MockHttpServletRequest result, String charset) {
+ String requestBody = webRequest.getRequestBody();
+ if (requestBody == null) {
+ return;
+ }
+ try {
+ result.setContent(requestBody.getBytes(charset));
+ }
+ catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void contentType(MockHttpServletRequest result) {
+ String contentType = header("Content-Type");
+ result.setContentType(contentType == null ? MediaType.ALL_VALUE.toString() : contentType);
+ }
+
+ private void contextPath(MockHttpServletRequest result, UriComponents uriComponents) {
+ if (contextPath == null) {
+ List pathSegments = uriComponents.getPathSegments();
+ if (pathSegments.isEmpty()) {
+ result.setContextPath("");
+ }
+ else {
+ result.setContextPath("/" + pathSegments.get(0));
+ }
+ }
+ else {
+ if (!uriComponents.getPath().startsWith(contextPath)) {
+ throw new IllegalArgumentException(uriComponents.getPath() + " should start with contextPath "
+ + contextPath);
+ }
+ result.setContextPath(contextPath);
+ }
+ }
+
+ private void cookies(MockHttpServletRequest result) {
+ String cookieHeaderValue = header("Cookie");
+ Cookie[] parentCookies = result.getCookies();
+ List cookies = new ArrayList();
+ if (cookieHeaderValue != null) {
+ StringTokenizer tokens = new StringTokenizer(cookieHeaderValue, "=;");
+ while (tokens.hasMoreTokens()) {
+ String cookieName = tokens.nextToken().trim();
+ if (!tokens.hasMoreTokens()) {
+ throw new IllegalArgumentException("Expected value for cookie name " + cookieName
+ + ". Full cookie was " + cookieHeaderValue);
+ }
+ String cookieValue = tokens.nextToken().trim();
+ processCookie(result, cookies, new Cookie(cookieName, cookieValue));
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ Set managedCookies = webClient.getCookies(webRequest.getUrl());
+ for (com.gargoylesoftware.htmlunit.util.Cookie cookie : managedCookies) {
+ processCookie(result, cookies, new Cookie(cookie.getName(), cookie.getValue()));
+ }
+ if(parentCookies != null) {
+ for(Cookie cookie : parentCookies) {
+ cookies.add(cookie);
+ }
+ }
+ if (!cookies.isEmpty()) {
+ result.setCookies(cookies.toArray(new Cookie[0]));
+ }
+ }
+
+ private void processCookie(MockHttpServletRequest result, List cookies, Cookie cookie) {
+ cookies.add(cookie);
+ if ("JSESSIONID".equals(cookie.getName())) {
+ result.setRequestedSessionId(cookie.getValue());
+ result.setSession(httpSession(result, cookie.getValue()));
+ }
+ }
+
+ private String getCharset() {
+ String charset = webRequest.getCharset();
+ if (charset == null) {
+ return "ISO-8859-1";
+ }
+ return charset;
+ }
+
+ private String header(String headerName) {
+ return webRequest.getAdditionalHeaders().get(headerName);
+ }
+
+ private void headers(MockHttpServletRequest result) {
+ for (Entry header : webRequest.getAdditionalHeaders().entrySet()) {
+ result.addHeader(header.getKey(), header.getValue());
+ }
+ }
+
+ private MockHttpSession httpSession(MockHttpServletRequest request, final String sessionid) {
+ MockHttpSession session;
+ synchronized (sessions) {
+ session = sessions.get(sessionid);
+ if (session == null) {
+ session = new HtmlUnitMockHttpSession(request, sessionid);
+ session.setNew(true);
+ synchronized (sessions) {
+ sessions.put(sessionid, session);
+ }
+ addSessionCookie(request, sessionid);
+ }
+ else {
+ session.setNew(false);
+ }
+ }
+ return session;
+ }
+
+ private void addSessionCookie(MockHttpServletRequest request, String sessionid) {
+ getCookieManager().addCookie(createCookie(request, sessionid));
+ }
+
+ private void removeSessionCookie(MockHttpServletRequest request, String sessionid) {
+ getCookieManager().removeCookie(createCookie(request, sessionid));
+ }
+
+ private com.gargoylesoftware.htmlunit.util.Cookie createCookie(MockHttpServletRequest request, String sessionid) {
+ return new com.gargoylesoftware.htmlunit.util.Cookie(request.getServerName(), "JSESSIONID", sessionid,
+ request.getContextPath() + "/", null, request.isSecure(), true);
+ }
+
+ private void locales(MockHttpServletRequest result) {
+ String locale = header("Accept-Language");
+ if (locale == null) {
+ result.addPreferredLocale(Locale.getDefault());
+ }
+ else {
+ String[] locales = locale.split(", ");
+ for (int i = locales.length - 1; i >= 0; i--) {
+ result.addPreferredLocale(parseLocale(locales[i]));
+ }
+ }
+ }
+
+ private void params(MockHttpServletRequest result, UriComponents uriComponents) {
+ for (Entry> values : uriComponents.getQueryParams().entrySet()) {
+ String name = values.getKey();
+ for (String value : values.getValue()) {
+ try {
+ result.addParameter(name, URLDecoder.decode(value, "UTF-8"));
+ }
+ catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+ for (NameValuePair param : webRequest.getRequestParameters()) {
+ result.addParameter(param.getName(), param.getValue());
+ }
+ }
+
+ private Locale parseLocale(String locale) {
+ Matcher matcher = LOCALE_PATTERN.matcher(locale);
+ if (!matcher.matches()) {
+ throw new IllegalArgumentException("Invalid locale " + locale);
+ }
+ String language = matcher.group(1);
+ String country = matcher.group(2);
+ if (country == null) {
+ country = "";
+ }
+ String qualifier = matcher.group(3);
+ if (qualifier == null) {
+ qualifier = "";
+ }
+ return new Locale(language, country, qualifier);
+ }
+
+ private void pathInfo(UriComponents uriComponents, MockHttpServletRequest result) {
+ result.setPathInfo(null);
+ }
+
+ private void servletPath(MockHttpServletRequest result, String requestPath) {
+ String servletPath = requestPath.substring(result.getContextPath().length());
+ if ("".equals(servletPath)) {
+ servletPath = null;
+ }
+ result.setServletPath(servletPath);
+ }
+
+ private void servletPath(UriComponents uriComponents, MockHttpServletRequest result) {
+ if ("".equals(result.getPathInfo())) {
+ result.setPathInfo(null);
+ }
+ servletPath(result, uriComponents.getPath());
+ }
+
+ private void ports(UriComponents uriComponents, MockHttpServletRequest result) {
+ int serverPort = uriComponents.getPort();
+ result.setServerPort(serverPort);
+ if (serverPort == -1) {
+ int portConnection = webRequest.getUrl().getDefaultPort();
+ result.setLocalPort(serverPort);
+ result.setRemotePort(portConnection);
+ }
+ else {
+ result.setRemotePort(serverPort);
+ }
+ }
+
+ private UriComponents uriComponents() {
+ URL url = webRequest.getUrl();
+ UriComponentsBuilder uriBldr = UriComponentsBuilder.fromUriString(url.toExternalForm());
+ return uriBldr.build();
+ }
+
+ @Override
+ public boolean isMergeEnabled() {
+ return true;
+ }
+
+ @Override
+ public Object merge(Object parent) {
+ if (parent == null) {
+ return this;
+ }
+ if(parent instanceof RequestBuilder) {
+ this.parentBuilder = (RequestBuilder) parent;
+ }
+ if (parent instanceof SmartRequestBuilder) {
+ this.parentPostProcessor = (SmartRequestBuilder) parent;
+ }
+
+ return this;
+ }
+
+ /**
+ * An extension to {@link MockHttpServletRequest} that ensures that when a new {@link HttpSession} is created, it is
+ * added to the managed sessions.
+ *
+ * @author Rob Winch
+ */
+ private final class HtmlUnitMockHttpServletRequest extends MockHttpServletRequest {
+ private HtmlUnitMockHttpServletRequest(ServletContext servletContext, String method, String requestURI) {
+ super(servletContext, method, requestURI);
+ }
+
+ public HttpSession getSession(boolean create) {
+ HttpSession result = super.getSession(false);
+ if (result == null && create) {
+ HtmlUnitMockHttpSession newSession = new HtmlUnitMockHttpSession(this);
+ setSession(newSession);
+ newSession.setNew(true);
+ String sessionid = newSession.getId();
+ synchronized (sessions) {
+ sessions.put(sessionid, newSession);
+ }
+ addSessionCookie(this, sessionid);
+ result = newSession;
+ }
+ return result;
+ }
+
+ public HttpSession getSession() {
+ return super.getSession();
+ }
+
+ public void setSession(HttpSession session) {
+ super.setSession(session);
+ }
+ }
+
+ /**
+ * An extension to {@link MockHttpSession} that ensures when {@link #invalidate()} is called that the
+ * {@link HttpSession} is removed from the managed sessions.
+ *
+ * @author Rob Winch
+ */
+ private final class HtmlUnitMockHttpSession extends MockHttpSession {
+ private final MockHttpServletRequest request;
+
+ private HtmlUnitMockHttpSession(MockHttpServletRequest request) {
+ super(request.getServletContext());
+ this.request = request;
+ }
+
+ private HtmlUnitMockHttpSession(MockHttpServletRequest request, String id) {
+ super(request.getServletContext(), id);
+ this.request = request;
+ }
+
+ public void invalidate() {
+ super.invalidate();
+ synchronized (sessions) {
+ sessions.remove(getId());
+ }
+ removeSessionCookie(request, getId());
+ }
+ }
+
+ private CookieManager getCookieManager() {
+ return webClient.getCookieManager();
+ }
+
+ private static final Pattern LOCALE_PATTERN = Pattern.compile("^\\s*(\\w{2})(?:-(\\w{2}))?(?:;q=(\\d+\\.\\d+))?$");
+}
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilder.java
new file mode 100644
index 0000000000..e9236d31a8
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilder.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2002-2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.springframework.test.web.servlet.htmlunit;
+
+import com.gargoylesoftware.htmlunit.WebClient;
+
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcConfigurer;
+import org.springframework.web.context.WebApplicationContext;
+
+/**
+ * Simplifies creating a WebClient that delegates to a MockMvc instance.
+ *
+ * @author Rob Winch
+ * @since 4.2
+ */
+public class MockMvcWebClientBuilder extends MockMvcWebConnectionBuilderSupport {
+
+ protected MockMvcWebClientBuilder(MockMvc mockMvc) {
+ super(mockMvc);
+ }
+
+ protected MockMvcWebClientBuilder(WebApplicationContext context) {
+ super(context);
+ }
+
+ protected MockMvcWebClientBuilder(WebApplicationContext context, MockMvcConfigurer configurer) {
+ super(context, configurer);
+ }
+
+ /**
+ * Creates a new instance with a WebApplicationContext.
+ *
+ * @param context the WebApplicationContext to use. Cannot be null.
+ * @return the MockMvcWebClientBuilder to customize
+ */
+ public static MockMvcWebClientBuilder webAppContextSetup(WebApplicationContext context) {
+ return new MockMvcWebClientBuilder(context);
+ }
+
+ /**
+ * Creates a new instance using a WebApplicationContext
+ * @param context the WebApplicationContext to create a MockMvc instance from.
+ * @param configurer the MockMvcConfigurer to apply
+ * Cannot be null.
+ * @return the MockMvcWebClientBuilder to use
+ */
+ public static MockMvcWebClientBuilder webAppContextSetup(WebApplicationContext context, MockMvcConfigurer configurer) {
+ return new MockMvcWebClientBuilder(context, configurer);
+ }
+
+ /**
+ * Creates a new instance with a MockMvc instance.
+ *
+ * @param mockMvc the MockMvc to use. Cannot be null.
+ * @return the MockMvcWebClientBuilder to customize
+ */
+ public static MockMvcWebClientBuilder mockMvcSetup(MockMvc mockMvc) {
+ return new MockMvcWebClientBuilder(mockMvc);
+ }
+
+ /**
+ * Creates a WebClient that uses the provided MockMvc for any matching requests and a
+ * WebClient with all the default settings for any other request.
+ *
+ * @return the WebClient to use
+ */
+ public WebClient createWebClient() {
+ return configureWebClient(new WebClient());
+ }
+
+ /**
+ * Creates a WebClient that uses the provided MockMvc for any matching requests and the
+ * provided WebClient for any other request.
+ *
+ * @param webClient The WebClient to delegate to for requests that do not match. Cannot be null.
+ *
+ * @return the WebClient to use
+ */
+ public WebClient configureWebClient(WebClient webClient) {
+ webClient.setWebConnection(createConnection(webClient.getWebConnection()));
+ return webClient;
+ }
+}
\ No newline at end of file
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnection.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnection.java
new file mode 100644
index 0000000000..1ab9494dc0
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnection.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2002-2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.springframework.test.web.servlet.htmlunit;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+
+import com.gargoylesoftware.htmlunit.CookieManager;
+import com.gargoylesoftware.htmlunit.WebClient;
+import com.gargoylesoftware.htmlunit.WebConnection;
+import com.gargoylesoftware.htmlunit.WebRequest;
+import com.gargoylesoftware.htmlunit.WebResponse;
+
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.RequestBuilder;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.htmlunit.webdriver.WebConnectionHtmlUnitDriver;
+import org.springframework.util.Assert;
+
+/**
+ *
+ * Allows {@link MockMvc} to transform a {@link WebRequest} into a {@link WebResponse}. This is the core integration
+ * with HTML Unit.
+ *
+ *
+ * Example usage can be seen below:
+ *
+ *
+ *
+ * WebClient webClient = new WebClient();
+ * MockMvc mockMvc = ...
+ * MockMvcWebConnection webConnection = new MockMvcWebConnection(mockMvc);
+ * mockConnection.setWebClient(webClient);
+ * webClient.setWebConnection(webConnection);
+ *
+ * ... use webClient as normal ...
+ *
+ *
+ * @author Rob Winch
+ * @since 4.2
+ * @see WebConnectionHtmlUnitDriver
+ */
+public final class MockMvcWebConnection implements WebConnection {
+ private WebClient webClient;
+
+ private final Map sessions = new HashMap();
+
+ private final MockMvc mockMvc;
+
+ private final String contextPath;
+
+ /**
+ * Creates a new instance that assumes the context root of the application is "". For example,
+ * the URL http://localhost/test/this would use "" as the context root.
+ *
+ * @param mockMvc the MockMvc instance to use
+ */
+ public MockMvcWebConnection(MockMvc mockMvc) {
+ this(mockMvc, "");
+ }
+
+ /**
+ * Creates a new instance with a specified context root.
+ *
+ * @param mockMvc the MockMvc instance to use
+ * @param contextPath the contextPath to use. The value may be null in which case the first path segment of the URL is turned
+ * into the contextPath. Otherwise it must conform to {@link HttpServletRequest#getContextPath()} which states it
+ * can be empty string or it must start with a "/" and not end in a "/".
+ */
+ public MockMvcWebConnection(MockMvc mockMvc, String contextPath) {
+ Assert.notNull(mockMvc, "mockMvc cannot be null");
+ validateContextPath(contextPath);
+
+ this.webClient = new WebClient();
+ this.mockMvc = mockMvc;
+ this.contextPath = contextPath;
+ }
+
+ public WebResponse getResponse(WebRequest webRequest) throws IOException {
+ long startTime = System.currentTimeMillis();
+ HtmlUnitRequestBuilder requestBuilder = new HtmlUnitRequestBuilder(sessions, webClient, webRequest);
+ requestBuilder.setContextPath(contextPath);
+
+ MockHttpServletResponse httpServletResponse = getResponse(requestBuilder);
+
+ String forwardedUrl = httpServletResponse.getForwardedUrl();
+ while(forwardedUrl != null) {
+ requestBuilder.setForwardPostProcessor(new ForwardRequestPostProcessor(forwardedUrl));
+ httpServletResponse = getResponse(requestBuilder);
+ forwardedUrl = httpServletResponse.getForwardedUrl();
+ }
+
+ return new MockWebResponseBuilder(startTime, webRequest, httpServletResponse).build();
+ }
+
+ public void setWebClient(WebClient webClient) {
+ Assert.notNull(webClient, "webClient cannot be null");
+ this.webClient = webClient;
+ }
+
+ private CookieManager getCookieManager() {
+ return webClient.getCookieManager();
+ }
+
+ private MockHttpServletResponse getResponse(RequestBuilder requestBuilder) throws IOException {
+ ResultActions resultActions;
+ try {
+ resultActions = mockMvc.perform(requestBuilder);
+ }
+ catch (Exception e) {
+ throw (IOException) new IOException(e.getMessage()).initCause(e);
+ }
+
+ return resultActions.andReturn().getResponse();
+ }
+
+ /**
+ * Performs validation on the contextPath
+ *
+ * @param contextPath the contextPath to validate
+ */
+ private static void validateContextPath(String contextPath) {
+ if (contextPath == null || "".equals(contextPath)) {
+ return;
+ }
+ if (contextPath.endsWith("/")) {
+ throw new IllegalArgumentException("contextPath cannot end with /. Got '" + contextPath + "'");
+ }
+ if (!contextPath.startsWith("/")) {
+ throw new IllegalArgumentException("contextPath must start with /. Got '" + contextPath + "'");
+ }
+ }
+}
\ No newline at end of file
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionBuilderSupport.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionBuilderSupport.java
new file mode 100644
index 0000000000..df2055fd45
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionBuilderSupport.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2002-2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.springframework.test.web.servlet.htmlunit;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.gargoylesoftware.htmlunit.WebConnection;
+
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.test.web.servlet.setup.MockMvcConfigurer;
+import org.springframework.util.Assert;
+import org.springframework.web.context.WebApplicationContext;
+
+/**
+ * Makes it easy to create a WebConnection that uses MockMvc and optionally delegates to
+ * a real WebConnection for specific requests. The default is to use MockMvc for any host
+ * that is "localhost" and otherwise use a real WebConnection.
+ *
+ * @author Rob Winch
+ * @since 4.2
+ */
+public abstract class MockMvcWebConnectionBuilderSupport> {
+ private String contextPath = "";
+
+ private final MockMvc mockMvc;
+
+ private List mockMvcRequestMatchers = new ArrayList();
+
+ private boolean alwaysUseMockMvc;
+
+ /**
+ * Creates a new instance using a MockMvc instance
+ *
+ * @param mockMvc the MockMvc instance to use. Cannot be null.
+ */
+ protected MockMvcWebConnectionBuilderSupport(MockMvc mockMvc) {
+ Assert.notNull(mockMvc, "mockMvc cannot be null");
+ this.mockMvc = mockMvc;
+ this.mockMvcRequestMatchers.add(new HostRequestMatcher("localhost"));
+ }
+
+ /**
+ * Creates a new instance using a WebApplicationContext
+ * @param context the WebApplicationContext to create a MockMvc instance from.
+ * Cannot be null.
+ */
+ protected MockMvcWebConnectionBuilderSupport(WebApplicationContext context) {
+ this(MockMvcBuilders.webAppContextSetup(context).build());
+ }
+
+ /**
+ * Creates a new instance using a WebApplicationContext
+ * @param context the WebApplicationContext to create a MockMvc instance from.
+ * @param configurer the MockMvcConfigurer to apply
+ * Cannot be null.
+ */
+ protected MockMvcWebConnectionBuilderSupport(WebApplicationContext context, MockMvcConfigurer configurer) {
+ this(MockMvcBuilders.webAppContextSetup(context).apply(configurer).build());
+ }
+
+ /**
+ * The context path to use. Default is "". If the value is null, then the first path
+ * segment of the request URL is assumed to be the context path.
+ *
+ * @param contextPath the context path to use.
+ * @return the builder for further customization
+ */
+ @SuppressWarnings("unchecked")
+ public T contextPath(String contextPath) {
+ this.contextPath = contextPath;
+ return (T) this;
+ }
+
+ /**
+ * Always use MockMvc no matter what the request looks like.
+ *
+ * @return the builder for further customization
+ */
+ @SuppressWarnings("unchecked")
+ public T alwaysUseMockMvc() {
+ this.alwaysUseMockMvc = true;
+ return (T) this;
+ }
+
+ /**
+ * Add additional WebRequestMatcher instances that if return true will ensure MockMvc
+ * is used.
+ *
+ * @param matchers the WebRequestMatcher instances that if true will ensure MockMvc
+ * processes the request.
+ * @return the builder for further customization
+ */
+ @SuppressWarnings("unchecked")
+ public T useMockMvc(WebRequestMatcher... matchers) {
+ for(WebRequestMatcher matcher : matchers) {
+ this.mockMvcRequestMatchers.add(matcher);
+ }
+ return (T) this;
+ }
+
+ /**
+ * Add additional WebRequestMatcher instances that will return true if the host matches.
+ *
+ * @param hosts the additional hosts that will ensure MockMvc gets invoked (i.e. example.com or example.com:8080).
+ * @return the builder for further customization
+ */
+ @SuppressWarnings("unchecked")
+ public T useMockMvcForHosts(String... hosts) {
+ this.mockMvcRequestMatchers.add(new HostRequestMatcher(hosts));
+ return (T) this;
+ }
+
+ /**
+ * Creates a new WebConnection that will use a MockMvc instance if one of the
+ * specified WebRequestMatcher matches.
+ *
+ * @param defaultConnection the default WebConnection to use if none of the specified
+ * WebRequestMatcher instances match. Cannot be null.
+ * @return a new WebConnection that will use a MockMvc instance if one of the
+ * specified WebRequestMatcher matches.
+ *
+ * @see #alwaysUseMockMvc
+ * @see #useMockMvc(WebRequestMatcher...)
+ * @see #useMockMvcForHosts(String...)
+ */
+ protected final WebConnection createConnection(WebConnection defaultConnection) {
+ Assert.notNull(defaultConnection, "defaultConnection cannot be null");
+ MockMvcWebConnection mockMvcWebConnection = new MockMvcWebConnection(mockMvc, contextPath);
+
+ if(alwaysUseMockMvc) {
+ return mockMvcWebConnection;
+ }
+
+ List delegates = new ArrayList(mockMvcRequestMatchers.size());
+ for(WebRequestMatcher matcher : mockMvcRequestMatchers) {
+ delegates.add(new DelegatingWebConnection.DelegateWebConnection(matcher, mockMvcWebConnection));
+ }
+
+ return new DelegatingWebConnection(defaultConnection, delegates);
+ }
+}
\ No newline at end of file
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilder.java
new file mode 100644
index 0000000000..f619641bb6
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilder.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2002-2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.springframework.test.web.servlet.htmlunit;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import com.gargoylesoftware.htmlunit.WebRequest;
+import com.gargoylesoftware.htmlunit.WebResponse;
+import com.gargoylesoftware.htmlunit.WebResponseData;
+import com.gargoylesoftware.htmlunit.util.NameValuePair;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.util.Assert;
+
+/**
+ * @author Rob Winch
+ * @since 4.2
+ */
+final class MockWebResponseBuilder {
+ private final long startTime;
+
+ private final WebRequest webRequest;
+
+ private final MockHttpServletResponse response;
+
+ public MockWebResponseBuilder(long startTime, WebRequest webRequest, MockHttpServletResponse httpServletResponse) {
+ Assert.notNull(webRequest, "webRequest");
+ Assert.notNull(httpServletResponse, "httpServletResponse cannot be null");
+ this.startTime = startTime;
+ this.webRequest = webRequest;
+ this.response = httpServletResponse;
+ }
+
+ public WebResponse build() throws IOException {
+ WebResponseData webResponseData = webResponseData();
+ long endTime = System.currentTimeMillis();
+ return new WebResponse(webResponseData, webRequest, endTime - startTime);
+ }
+
+ private WebResponseData webResponseData() throws IOException {
+ List responseHeaders = responseHeaders();
+ int statusCode = response.getRedirectedUrl() == null ? response.getStatus() : 301;
+ String statusMessage = statusMessage(statusCode);
+ return new WebResponseData(response.getContentAsByteArray(), statusCode, statusMessage, responseHeaders);
+ }
+
+ private String statusMessage(int statusCode) {
+ String errorMessage = response.getErrorMessage();
+ if (errorMessage != null) {
+ return errorMessage;
+ }
+ try {
+ return HttpStatus.valueOf(statusCode).getReasonPhrase();
+ }
+ catch (IllegalArgumentException useDefault) {
+ }
+ ;
+ return "N/A";
+ }
+
+ private List responseHeaders() {
+ Collection headerNames = response.getHeaderNames();
+ List responseHeaders = new ArrayList(headerNames.size());
+ for (String headerName : headerNames) {
+ List