| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF 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 freemarker.test.servlet; |
| |
| import static org.junit.Assert.*; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.net.HttpURLConnection; |
| import java.net.URI; |
| import java.net.URL; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.regex.Pattern; |
| |
| import org.apache.commons.io.FileUtils; |
| import org.apache.commons.io.IOUtils; |
| import org.eclipse.jetty.annotations.ServletContainerInitializersStarter; |
| import org.eclipse.jetty.apache.jsp.JettyJasperInitializer; |
| import org.eclipse.jetty.plus.annotation.ContainerInitializer; |
| import org.eclipse.jetty.server.NetworkConnector; |
| import org.eclipse.jetty.server.Server; |
| import org.eclipse.jetty.server.handler.ContextHandlerCollection; |
| import org.eclipse.jetty.webapp.WebAppContext; |
| import org.junit.AfterClass; |
| import org.junit.BeforeClass; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; |
| import freemarker.test.ResourcesExtractor; |
| import freemarker.test.utility.TestUtil; |
| |
| public class WebAppTestCase { |
| |
| public static final String IGNORED_MASK = "[IGNORED]"; |
| |
| private static final Logger LOG = LoggerFactory.getLogger(WebAppTestCase.class); |
| |
| private static final String ATTR_JETTY_CONTAINER_INCLUDE_JAR_PATTERN |
| = "org.eclipse.jetty.server.webapp.ContainerIncludeJarPattern"; |
| |
| private static final String EXPECTED_DIR = "WEB-INF/expected/"; |
| |
| private static Server server; |
| private static ContextHandlerCollection contextHandlers; |
| private static Map<String, WebAppContext> deployedWebApps = new HashMap<>(); |
| private static volatile File testTempDirectory; |
| |
| @BeforeClass |
| public static void beforeClass() throws Exception { |
| // Work around Java 5 bug(?) that causes Jasper to fail with "zip file closed" when it reads the JSTL jar: |
| org.eclipse.jetty.util.resource.Resource.setDefaultUseCaches(false); |
| |
| LOG.info("Starting embedded Jetty..."); |
| |
| server = new Server(0); |
| |
| contextHandlers = new ContextHandlerCollection(); |
| server.setHandler(contextHandlers); |
| |
| server.start(); |
| } |
| |
| @AfterClass |
| public static void afterClass() throws Exception { |
| LOG.info("Stopping embedded Jetty..."); |
| server.stop(); |
| server.join(); |
| LOG.info("Jetty stopped."); |
| deleteTemporaryDirectories(); |
| } |
| |
| protected final String getResponseContent(String webAppName, String webAppRelURL) throws Exception { |
| HTTPResponse resp = getHTTPResponse(webAppName, webAppRelURL); |
| if (resp.getStatusCode() != HttpURLConnection.HTTP_OK) { |
| fail("Expected HTTP status " + HttpURLConnection.HTTP_OK + ", but got " |
| + resp.getStatusCode() + " (message: " + resp.getStatusMessage() + ") for URI " |
| + resp.getURI()); |
| } |
| return resp.getContent(); |
| } |
| |
| protected final int getResponseStatusCode(String webAppName, String webAppRelURL) throws Exception { |
| HTTPResponse resp = getHTTPResponse(webAppName, webAppRelURL); |
| return resp.getStatusCode(); |
| } |
| |
| protected final HTTPResponse getHTTPResponse(String webAppName, String webAppRelURL) throws Exception { |
| if (webAppName.startsWith("/") || webAppName.endsWith("/")) { |
| throw new IllegalArgumentException("\"webAppName\" can't start or end with \"/\": " + webAppName); |
| } |
| if (webAppRelURL.startsWith("/") || webAppRelURL.endsWith("/")) { |
| throw new IllegalArgumentException("\"webappRelURL\" can't start or end with \"/\": " + webAppRelURL); |
| } |
| |
| ensureWebAppIsDeployed(webAppName); |
| |
| final URI uri = new URI("http://localhost:" + ((NetworkConnector) server.getConnectors()[0]).getLocalPort() |
| + "/" + webAppName + "/" + webAppRelURL); |
| |
| final HttpURLConnection httpCon = (HttpURLConnection) uri.toURL().openConnection(); |
| httpCon.connect(); |
| try { |
| LOG.debug("HTTP GET: {}", uri); |
| |
| final int responseCode = httpCon.getResponseCode(); |
| |
| final String content; |
| if (responseCode == 200) { |
| try (InputStream in = httpCon.getInputStream()) { |
| content = IOUtils.toString(in, "UTF-8"); |
| } |
| } else { |
| content = null; |
| } |
| |
| return new HTTPResponse( |
| responseCode, httpCon.getResponseMessage(), |
| content, |
| uri); |
| } finally { |
| httpCon.disconnect(); |
| } |
| } |
| |
| /** |
| * Compares the output of the JSP and the FTL version of the same page, ignoring some of the whitespace differences. |
| * |
| * @param webAppRelURLWithoutExt |
| * something like {@code "tester?view=foo"}, which will be extended to {@code "tester?view=foo.jsp"} and |
| * {@code "tester?view=foo.ftl"}, and then the output of these extended URL-s will be compared. |
| */ |
| protected void assertJSPAndFTLOutputEquals(String webAppName, String webAppRelURLWithoutExt) throws Exception { |
| assertOutputsEqual(webAppName, webAppRelURLWithoutExt + ".jsp", webAppRelURLWithoutExt + ".ftl"); |
| } |
| |
| protected void assertOutputsEqual(String webAppName, String webAppRelURL1, final String webAppRelURL2) |
| throws Exception { |
| String jspOutput = normalizeWS(getResponseContent(webAppName, webAppRelURL1), true); |
| String ftlOutput = normalizeWS(getResponseContent(webAppName, webAppRelURL2), true); |
| assertEquals(jspOutput, ftlOutput); |
| } |
| |
| protected void assertExpectedEqualsOutput(String webAppName, String expectedFileName, String webAppRelURL) |
| throws Exception { |
| assertExpectedEqualsOutput(webAppName, expectedFileName, webAppRelURL, true); |
| } |
| |
| protected void assertExpectedEqualsOutput(String webAppName, String expectedFileName, String webAppRelURL, |
| boolean compressWS) throws Exception { |
| assertExpectedEqualsOutput(webAppName, expectedFileName, webAppRelURL, compressWS, null); |
| } |
| |
| /** |
| * @param expectedFileName |
| * The name of the file that stores the expected content, relatively to |
| * {@code servketContext:/WEB-INF/expected}. |
| * @param ignoredParts |
| * Parts that will be search-and-replaced with {@value #IGNORED_MASK} with both in the expected and |
| * actual outputs. |
| */ |
| protected void assertExpectedEqualsOutput(String webAppName, String expectedFileName, String webAppRelURL, |
| boolean compressWS, List<Pattern> ignoredParts) throws Exception { |
| final String actual = normalizeWS(getResponseContent(webAppName, webAppRelURL), compressWS); |
| final String expected; |
| { |
| ClassPathResource cpResource = findWebAppDirectoryResource(webAppName); |
| try (InputStream in = cpResource.resolverClass.getResourceAsStream( |
| cpResource.path + EXPECTED_DIR + expectedFileName)) { |
| expected = TestUtil.removeTxtCopyrightComment(normalizeWS(IOUtils.toString(in, "utf-8"), compressWS)); |
| } |
| } |
| assertEquals(maskIgnored(expected, ignoredParts), maskIgnored(actual, ignoredParts)); |
| } |
| |
| private String maskIgnored(String s, List<Pattern> ignoredParts) { |
| if (ignoredParts == null) return s; |
| |
| for (Pattern ignoredPart : ignoredParts) { |
| s = ignoredPart.matcher(s).replaceAll(IGNORED_MASK); |
| } |
| return s; |
| } |
| |
| protected synchronized void restartWebAppIfStarted(String webAppName) throws Exception { |
| WebAppContext context = deployedWebApps.get(webAppName); |
| if (context != null) { |
| context.stop(); |
| context.start(); |
| } |
| } |
| |
| private Pattern BR = Pattern.compile("\r\n|\r"); |
| private Pattern MULTI_LINE_WS = Pattern.compile("[\t ]*[\r\n][\t \r\n]*", Pattern.DOTALL); |
| private Pattern SAME_LINE_WS = Pattern.compile("[\t ]+", Pattern.DOTALL); |
| |
| private String normalizeWS(String s, boolean compressWS) { |
| if (compressWS) { |
| return SAME_LINE_WS.matcher( |
| MULTI_LINE_WS.matcher(s).replaceAll("\n")) |
| .replaceAll(" ") |
| .trim(); |
| } else { |
| return BR.matcher(s).replaceAll("\n"); |
| } |
| } |
| |
| private synchronized void ensureWebAppIsDeployed(String webAppName) throws Exception { |
| if (deployedWebApps.containsKey(webAppName)) { |
| return; |
| } |
| |
| final String webAppDirURL = createWebAppDirAndGetURI(webAppName); |
| |
| WebAppContext context = new WebAppContext(webAppDirURL, "/" + webAppName); |
| |
| // Pattern of jar file names scanned for META-INF/*.tld: |
| context.setAttribute( |
| ATTR_JETTY_CONTAINER_INCLUDE_JAR_PATTERN, |
| ".*taglib.*\\.jar$"); |
| |
| addJasperInitializer(context); |
| |
| contextHandlers.addHandler(context); |
| // As we add this after the Server was started, it has to be started manually: |
| context.start(); |
| |
| deployedWebApps.put(webAppName, context); |
| LOG.info("Deployed web app.: {}", webAppName); |
| } |
| |
| /** |
| * Without this, we will have this error when loading a taglib: |
| * NullPointerException: Cannot invoke "org.apache.jasper.compiler.TldCache.getTldResourcePath(String)" because the |
| * return value of "org.apache.jasper.Options.getTldCache()" is null |
| */ |
| private static void addJasperInitializer(WebAppContext context) { |
| JettyJasperInitializer jettyJasperInitializer = new JettyJasperInitializer(); |
| ServletContainerInitializersStarter servletContainerInitializersStarter |
| = new ServletContainerInitializersStarter(context); |
| ContainerInitializer containerInitializer = new ContainerInitializer(jettyJasperInitializer, null); |
| context.setAttribute("org.eclipse.jetty.containerInitializers", List.of(containerInitializer)); |
| context.addBean(servletContainerInitializersStarter, true); |
| } |
| |
| private static void deleteTemporaryDirectories() throws IOException { |
| if (testTempDirectory.getParentFile() == null) { |
| throw new IOException("Won't delete the root directory"); |
| } |
| try { |
| FileUtils.forceDelete(testTempDirectory); |
| } catch (IOException e) { |
| LOG.warn("Failed to delete temporary file or directory; will re-try on JVM shutdown: " |
| + testTempDirectory); |
| FileUtils.forceDeleteOnExit(testTempDirectory); |
| } |
| } |
| |
| @SuppressFBWarnings(value = "UI_INHERITANCE_UNSAFE_GETRESOURCE", justification = "By design relative to subclass") |
| private String createWebAppDirAndGetURI(String webAppName) throws IOException { |
| ClassPathResource resourceDir = findWebAppDirectoryResource(webAppName); |
| File temporaryDir = ResourcesExtractor.extract( |
| resourceDir.resolverClass, resourceDir.path, new File(getTestTempDirectory(), webAppName)); |
| return temporaryDir.toURI().toString(); |
| } |
| |
| private ClassPathResource findWebAppDirectoryResource(String webAppName) throws IOException { |
| final String appRelResPath = "webapps/" + webAppName + "/"; |
| final String relResPath = appRelResPath + "WEB-INF/web.xml"; |
| |
| Class<?> baseClass = this.getClass(); |
| do { |
| URL r = baseClass.getResource(relResPath); |
| if (r != null) { |
| return new ClassPathResource(baseClass, appRelResPath); |
| } |
| |
| baseClass = baseClass.getSuperclass(); |
| if (!WebAppTestCase.class.isAssignableFrom(baseClass)) { |
| throw new IOException("Can't find test class relative resource: " + relResPath); |
| } |
| } while (true); |
| } |
| |
| private File getTestTempDirectory() throws IOException { |
| if (testTempDirectory == null) { |
| // As at least on Windows we have problem deleting the directories once Jetty has used them (the file |
| // handles of the jars remain open), we always use the same name, so that at most one will remain there. |
| File d = new File( |
| new File(System.getProperty("java.io.tmpdir")), |
| "freemarker-jetty-junit-tests-(delete-it)"); |
| if (d.exists()) { |
| FileUtils.deleteDirectory(d); |
| } |
| if (!d.mkdirs()) { |
| throw new IOException("Failed to create Jetty temp directory: " + d); |
| } |
| testTempDirectory = d; |
| } |
| return testTempDirectory; |
| } |
| |
| private static class ClassPathResource { |
| |
| private final Class<?> resolverClass; |
| private final String path; |
| |
| public ClassPathResource(Class<?> resolverClass, String path) { |
| this.resolverClass = resolverClass; |
| this.path = path; |
| } |
| |
| } |
| |
| private static class HTTPResponse { |
| |
| private final int statusCode; |
| private final String content; |
| private final String statusMessage; |
| private final URI uri; |
| |
| public HTTPResponse(int statusCode, String statusMessage, String content, URI uri) { |
| this.statusCode = statusCode; |
| this.content = content; |
| this.statusMessage = statusMessage; |
| this.uri = uri; |
| } |
| |
| public String getStatusMessage() { |
| return statusMessage; |
| } |
| |
| public int getStatusCode() { |
| return statusCode; |
| } |
| |
| public String getContent() { |
| return content; |
| } |
| |
| public URI getURI() { |
| return uri; |
| } |
| |
| } |
| |
| } |