View Javadoc
1   /*
2    * Copyright (c) 2002-2025 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit;
16  
17  import static java.nio.charset.StandardCharsets.ISO_8859_1;
18  
19  import java.io.IOException;
20  import java.net.BindException;
21  import java.net.URL;
22  import java.nio.charset.Charset;
23  import java.time.Duration;
24  import java.util.List;
25  import java.util.Map;
26  
27  import javax.servlet.Servlet;
28  
29  import org.eclipse.jetty.http.MimeTypes;
30  import org.eclipse.jetty.security.ConstraintMapping;
31  import org.eclipse.jetty.security.ConstraintSecurityHandler;
32  import org.eclipse.jetty.security.HashLoginService;
33  import org.eclipse.jetty.server.Connector;
34  import org.eclipse.jetty.server.Handler;
35  import org.eclipse.jetty.server.HttpConfiguration;
36  import org.eclipse.jetty.server.HttpConnectionFactory;
37  import org.eclipse.jetty.server.SecureRequestCustomizer;
38  import org.eclipse.jetty.server.Server;
39  import org.eclipse.jetty.server.ServerConnector;
40  import org.eclipse.jetty.server.SslConnectionFactory;
41  import org.eclipse.jetty.server.handler.HandlerList;
42  import org.eclipse.jetty.server.handler.HandlerWrapper;
43  import org.eclipse.jetty.server.handler.ResourceHandler;
44  import org.eclipse.jetty.servlet.DefaultServlet;
45  import org.eclipse.jetty.util.security.Constraint;
46  import org.eclipse.jetty.util.thread.QueuedThreadPool;
47  import org.eclipse.jetty.webapp.WebAppClassLoader;
48  import org.eclipse.jetty.webapp.WebAppContext;
49  import org.htmlunit.WebDriverTestCase.MockWebConnectionServlet;
50  import org.htmlunit.html.HtmlPage;
51  import org.htmlunit.util.MimeType;
52  import org.junit.After;
53  
54  /**
55   * A WebTestCase which starts a local server, and doens't use WebDriver.
56   *
57   * <b>Note that {@link WebDriverTestCase} should be used unless HtmlUnit-specific feature
58   * is needed and Selenium does not support it.</b>
59   *
60   * @author Ahmed Ashour
61   * @author Marc Guillemot
62   * @author Ronald Brill
63   */
64  public abstract class WebServerTestCase extends WebTestCase {
65  
66      /** Timeout used when waiting for successful bind. */
67      public static final int BIND_TIMEOUT = 1000;
68  
69      private Server server_;
70      private static Server STATIC_SERVER_;
71      private WebClient webClient_;
72      private CollectingAlertHandler alertHandler_ = new CollectingAlertHandler();
73  
74      /**
75       * Starts the web server on the default {@link #PORT}.
76       * The given resourceBase is used to be the ROOT directory that serves the default context.
77       * <p><b>Don't forget to stop the returned HttpServer after the test</b>
78       *
79       * @param resourceBase the base of resources for the default context
80       * @throws Exception if the test fails
81       */
82      protected void startWebServer(final String resourceBase) throws Exception {
83          if (server_ != null) {
84              throw new IllegalStateException("startWebServer() can not be called twice");
85          }
86          final Server server = buildServer(PORT);
87  
88          final WebAppContext context = new WebAppContext();
89          context.setContextPath("/");
90          context.setResourceBase(resourceBase);
91  
92          final ResourceHandler resourceHandler = new ResourceHandler();
93          resourceHandler.setResourceBase(resourceBase);
94          final MimeTypes mimeTypes = new MimeTypes();
95          mimeTypes.addMimeMapping("js", MimeType.TEXT_JAVASCRIPT);
96          resourceHandler.setMimeTypes(mimeTypes);
97  
98          final HandlerList handlers = new HandlerList();
99          handlers.setHandlers(new Handler[]{resourceHandler, context});
100         server.setHandler(handlers);
101         server.setHandler(resourceHandler);
102 
103         tryStart(PORT, server);
104         server_ = server;
105     }
106 
107     /**
108      * Starts the web server on the default {@link #PORT}.
109      * The given resourceBase is used to be the ROOT directory that serves the default context.
110      * <p><b>Don't forget to stop the returned HttpServer after the test</b>
111      *
112      * @param resourceBase the base of resources for the default context
113      * @param classpath additional classpath entries to add (may be null)
114      * @throws Exception if the test fails
115      */
116     protected void startWebServer(final String resourceBase, final String[] classpath) throws Exception {
117         if (server_ != null) {
118             throw new IllegalStateException("startWebServer() can not be called twice");
119         }
120         server_ = createWebServer(resourceBase, classpath);
121     }
122 
123     /**
124      * This is usually needed if you want to have a running server during many tests invocation.
125      *
126      * Creates and starts a web server on the default {@link #PORT}.
127      * The given resourceBase is used to be the ROOT directory that serves the default context.
128      * <p><b>Don't forget to stop the returned Server after the test</b>
129      *
130      * @param resourceBase the base of resources for the default context
131      * @param classpath additional classpath entries to add (may be null)
132      * @return the newly created server
133      * @throws Exception if an error occurs
134      */
135     public static Server createWebServer(final String resourceBase, final String[] classpath) throws Exception {
136         return createWebServer(PORT, resourceBase, classpath, null, null);
137     }
138 
139     /**
140      * This is usually needed if you want to have a running server during many tests invocation.
141      *
142      * Creates and starts a web server on the default {@link #PORT}.
143      * The given resourceBase is used to be the ROOT directory that serves the default context.
144      * <p><b>Don't forget to stop the returned Server after the test</b>
145      *
146      * @param port the port to which the server is bound
147      * @param resourceBase the base of resources for the default context
148      * @param classpath additional classpath entries to add (may be null)
149      * @param servlets map of {String, Class} pairs: String is the path spec, while class is the class
150      * @param handler wrapper for handler (can be null)
151      * @return the newly created server
152      * @throws Exception if an error occurs
153      */
154     public static Server createWebServer(final int port, final String resourceBase, final String[] classpath,
155             final Map<String, Class<? extends Servlet>> servlets, final HandlerWrapper handler) throws Exception {
156 
157         final Server server = buildServer(port);
158 
159         final WebAppContext context = new WebAppContext();
160         context.setContextPath("/");
161         context.setResourceBase(resourceBase);
162 
163         if (servlets != null) {
164             for (final Map.Entry<String, Class<? extends Servlet>> entry : servlets.entrySet()) {
165                 final String pathSpec = entry.getKey();
166                 final Class<? extends Servlet> servlet = entry.getValue();
167                 context.addServlet(servlet, pathSpec);
168 
169                 // disable defaults if someone likes to register his own root servlet
170                 if ("/".equals(pathSpec)) {
171                     context.setDefaultsDescriptor(null);
172                     context.addServlet(DefaultServlet.class, "/favicon.ico");
173                 }
174             }
175         }
176 
177         final WebAppClassLoader loader = new WebAppClassLoader(context);
178         if (classpath != null) {
179             for (final String path : classpath) {
180                 loader.addClassPath(path);
181             }
182         }
183         context.setClassLoader(loader);
184         if (handler != null) {
185             handler.setHandler(context);
186             server.setHandler(handler);
187         }
188         else {
189             server.setHandler(context);
190         }
191 
192         tryStart(port, server);
193         return server;
194     }
195 
196     /**
197      * Starts the web server on the default {@link #PORT}.
198      * The given resourceBase is used to be the ROOT directory that serves the default context.
199      * <p><b>Don't forget to stop the returned HttpServer after the test</b>
200      *
201      * @param resourceBase the base of resources for the default context
202      * @param classpath additional classpath entries to add (may be null)
203      * @param servlets map of {String, Class} pairs: String is the path spec, while class is the class
204      * @throws Exception if the test fails
205      */
206     protected void startWebServer(final String resourceBase, final String[] classpath,
207             final Map<String, Class<? extends Servlet>> servlets) throws Exception {
208         if (server_ != null) {
209             throw new IllegalStateException("startWebServer() can not be called twice");
210         }
211         final Server server = buildServer(PORT);
212 
213         final WebAppContext context = new WebAppContext();
214         context.setContextPath("/");
215         context.setResourceBase(resourceBase);
216 
217         for (final Map.Entry<String, Class<? extends Servlet>> entry : servlets.entrySet()) {
218             final String pathSpec = entry.getKey();
219             final Class<? extends Servlet> servlet = entry.getValue();
220             context.addServlet(servlet, pathSpec);
221         }
222         final WebAppClassLoader loader = new WebAppClassLoader(context);
223         if (classpath != null) {
224             for (final String path : classpath) {
225                 loader.addClassPath(path);
226             }
227         }
228         context.setClassLoader(loader);
229         server.setHandler(context);
230 
231         tryStart(PORT, server);
232         server_ = server;
233     }
234 
235     /**
236      * Performs post-test deconstruction.
237      * @throws Exception if an error occurs
238      */
239     @After
240     public void tearDown() throws Exception {
241         if (server_ != null) {
242             server_.stop();
243             server_.destroy();
244             server_ = null;
245         }
246 
247         stopWebServer();
248     }
249 
250     /**
251      * Defines the provided string as response for the provided URL and loads it using the currently
252      * configured browser version. Finally it extracts the captured alerts and verifies them.
253      * @param html the HTML to use
254      * @param url the URL to use to load the page
255      * @return the page
256      * @throws Exception if something goes wrong
257      */
258     protected final HtmlPage loadPageWithAlerts(final String html, final URL url)
259         throws Exception {
260         return loadPageWithAlerts(html, url, Duration.ofSeconds(0));
261     }
262 
263     /**
264      * Same as {@link #loadPageWithAlerts(String, URL)}, but configuring the max wait time.
265      * @param html the HTML to use
266      * @param url the URL to use to load the page
267      * @param maxWaitTime to wait to get the alerts (in ms)
268      * @return the page
269      * @throws Exception if something goes wrong
270      */
271     protected final HtmlPage loadPageWithAlerts(final String html, final URL url, final Duration maxWaitTime)
272         throws Exception {
273         alertHandler_.clear();
274         expandExpectedAlertsVariables(URL_FIRST);
275 
276         final String[] expectedAlerts = getExpectedAlerts();
277         final HtmlPage page = loadPage(html, url);
278 
279         List<String> actualAlerts = getCollectedAlerts(page);
280         final long maxWait = System.currentTimeMillis() + maxWaitTime.toMillis();
281         while (actualAlerts.size() < expectedAlerts.length && System.currentTimeMillis() < maxWait) {
282             Thread.sleep(30);
283             actualAlerts = getCollectedAlerts(page);
284         }
285 
286         assertEquals(expectedAlerts, getCollectedAlerts(page));
287         return page;
288     }
289 
290     /**
291      * Defines the provided string as response for the default URL and loads it using the currently
292      * configured browser version.
293      * @param html the HTML to use
294      * @return the page
295      * @throws Exception if something goes wrong
296      */
297     protected final HtmlPage loadPage(final String html) throws Exception {
298         return loadPage(html, URL_FIRST);
299     }
300 
301     /**
302      * Same as {@link #loadPage(String)}... but defining the default URL.
303      * @param html the HTML to use
304      * @param url the url to use to load the page
305      * @return the page
306      * @throws Exception if something goes wrong
307      */
308     protected final HtmlPage loadPage(final String html, final URL url) throws Exception {
309         return loadPage(html, url, MimeType.TEXT_HTML, ISO_8859_1);
310     }
311 
312     /**
313      * Same as {@link #loadPage(String, URL)}... but defining content type and charset as well.
314      * @param html the HTML to use
315      * @param url the url to use to load the page
316      * @param contentType the content type to return
317      * @param charset the charset
318      * @return the page
319      * @throws Exception if something goes wrong
320      */
321     private HtmlPage loadPage(final String html, final URL url,
322             final String contentType, final Charset charset) throws Exception {
323         final MockWebConnection mockWebConnection = getMockWebConnection();
324         mockWebConnection.setResponse(url, html, contentType, charset);
325         startWebServer(mockWebConnection);
326 
327         return getWebClient().getPage(url);
328     }
329 
330     /**
331      * Starts the web server delivering response from the provided connection.
332      * @param mockConnection the sources for responses
333      * @throws Exception if a problem occurs
334      */
335     protected void startWebServer(final MockWebConnection mockConnection) throws Exception {
336         if (STATIC_SERVER_ == null) {
337             final Server server = buildServer(PORT);
338 
339             final WebAppContext context = new WebAppContext();
340             context.setContextPath("/");
341             context.setResourceBase("./");
342 
343             if (isBasicAuthentication()) {
344                 final Constraint constraint = new Constraint();
345                 constraint.setName(Constraint.__BASIC_AUTH);
346                 constraint.setRoles(new String[]{"user"});
347                 constraint.setAuthenticate(true);
348 
349                 final ConstraintMapping constraintMapping = new ConstraintMapping();
350                 constraintMapping.setConstraint(constraint);
351                 constraintMapping.setPathSpec("/*");
352 
353                 final ConstraintSecurityHandler handler = (ConstraintSecurityHandler) context.getSecurityHandler();
354                 handler.setLoginService(new HashLoginService("MyRealm", "./src/test/resources/realm.properties"));
355                 handler.setAuthMethod(Constraint.__BASIC_AUTH);
356                 handler.setConstraintMappings(new ConstraintMapping[]{constraintMapping});
357             }
358 
359             context.addServlet(MockWebConnectionServlet.class, "/*");
360             server.setHandler(context);
361 
362             if (isHttps()) {
363                 final SslConnectionFactory sslConnectionFactory = getSslConnectionFactory();
364 
365                 final HttpConfiguration sslConfiguration = new HttpConfiguration();
366                 sslConfiguration.addCustomizer(new SecureRequestCustomizer());
367                 final HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(sslConfiguration);
368 
369                 final ServerConnector connector =
370                         new ServerConnector(server, sslConnectionFactory, httpConnectionFactory);
371                 connector.setPort(PORT2);
372                 server.addConnector(connector);
373             }
374 
375             tryStart(PORT, server);
376             STATIC_SERVER_ = server;
377         }
378         MockWebConnectionServlet.setMockconnection(mockConnection);
379     }
380 
381     /**
382      * Starts the server; handles BindExceptions and retries.
383      * @param port the port only used for the error message
384      * @param server the server to start
385      * @throws Exception in case of error
386      */
387     public static void tryStart(final int port, final Server server) throws Exception {
388         final long maxWait = System.currentTimeMillis() + BIND_TIMEOUT;
389 
390         while (true) {
391             try {
392                 server.start();
393                 return;
394             }
395             catch (final BindException e) {
396                 if (System.currentTimeMillis() > maxWait) {
397                     // destroy the server to free all associated resources
398                     server.stop();
399                     server.destroy();
400 
401                     throw (BindException) new BindException("Port " + port + " is already in use").initCause(e);
402                 }
403                 Thread.sleep(200);
404             }
405             catch (final IOException e) {
406                 // looks like newer jetty already catches the bind exception
407                 final Throwable cause = e.getCause();
408                 if (cause != null && cause instanceof BindException) {
409                     if (System.currentTimeMillis() > maxWait) {
410                         // destroy the server to free all associated resources
411                         server.stop();
412                         server.destroy();
413 
414                         throw (BindException) new BindException("Port " + port + " is already in use").initCause(e);
415                     }
416                     Thread.sleep(200);
417                 }
418                 else {
419                     // destroy the server to free all associated resources
420                     server.stop();
421                     server.destroy();
422 
423                     throw e;
424                 }
425             }
426         }
427     }
428 
429     /**
430      * Stops the WebServer.
431      * @throws Exception if it fails
432      */
433     protected static void stopWebServer() throws Exception {
434         if (STATIC_SERVER_ != null) {
435             STATIC_SERVER_.stop();
436             STATIC_SERVER_.destroy();
437             STATIC_SERVER_ = null;
438         }
439     }
440 
441     /**
442      * Loads the provided URL serving responses from {@link #getMockWebConnection()}
443      * and verifies that the captured alerts are correct.
444      * @param url the URL to use to load the page
445      * @return the web driver
446      * @throws Exception if something goes wrong
447      */
448     protected final HtmlPage loadPageWithAlerts(final URL url) throws Exception {
449         alertHandler_.clear();
450         expandExpectedAlertsVariables(url);
451         final String[] expectedAlerts = getExpectedAlerts();
452 
453         startWebServer(getMockWebConnection());
454 
455         final HtmlPage page = getWebClient().getPage(url);
456 
457         assertEquals(expectedAlerts, getCollectedAlerts(page));
458         return page;
459     }
460 
461     /**
462      * Returns the collected alerts.
463      * @param page the page
464      * @return the alerts
465      */
466     protected List<String> getCollectedAlerts(final HtmlPage page) {
467         return alertHandler_.getCollectedAlerts();
468     }
469 
470     /**
471      * Returns whether to use basic authentication for all resources or not.
472      * The default implementation returns false.
473      * @return whether to use basic authentication or not
474      */
475     protected boolean isBasicAuthentication() {
476         return false;
477     }
478 
479     /**
480      * @return whether to support https also
481      */
482     protected boolean isHttps() {
483         return false;
484     }
485 
486     /**
487      * @return SslConnectionFactory for https
488      */
489     protected SslConnectionFactory getSslConnectionFactory() {
490         return null;
491     }
492 
493     /**
494      * Returns the WebClient instance for the current test with the current {@link BrowserVersion}.
495      * @return a WebClient with the current {@link BrowserVersion}
496      */
497     protected WebClient getWebClient() {
498         if (webClient_ == null) {
499             webClient_ = new WebClient(getBrowserVersion());
500             webClient_.setAlertHandler(alertHandler_);
501         }
502         return webClient_;
503     }
504 
505     /**
506      * Cleanup after a test.
507      */
508     @Override
509     @After
510     public void releaseResources() {
511         super.releaseResources();
512         if (webClient_ != null) {
513             webClient_.close();
514             webClient_.getCookieManager().clearCookies();
515         }
516         webClient_ = null;
517         alertHandler_ = null;
518     }
519 
520     private static Server buildServer(final int port) {
521         final QueuedThreadPool threadPool = new QueuedThreadPool(10, 2);
522 
523         final Server server = new Server(threadPool);
524 
525         final ServerConnector connector = new ServerConnector(server, 1, -1, new HttpConnectionFactory());
526         connector.setPort(port);
527         server.setConnectors(new Connector[] {connector});
528 
529         return server;
530     }
531 }