001 // Copyright 2006, 2007, 2008, 2009, 2010, 2011, 2012 The Apache Software Foundation 002 // 003 // Licensed under the Apache License, Version 2.0 (the "License"); 004 // you may not use this file except in compliance with the License. 005 // You may obtain a copy of the License at 006 // 007 // http://www.apache.org/licenses/LICENSE-2.0 008 // 009 // Unless required by applicable law or agreed to in writing, software 010 // distributed under the License is distributed on an "AS IS" BASIS, 011 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 012 // See the License for the specific language governing permissions and 013 // limitations under the License. 014 015 package org.apache.tapestry5.test; 016 017 import org.apache.tapestry5.Link; 018 import org.apache.tapestry5.dom.Document; 019 import org.apache.tapestry5.dom.Element; 020 import org.apache.tapestry5.dom.Visitor; 021 import org.apache.tapestry5.internal.InternalConstants; 022 import org.apache.tapestry5.internal.SingleKeySymbolProvider; 023 import org.apache.tapestry5.internal.TapestryAppInitializer; 024 import org.apache.tapestry5.internal.test.PageTesterContext; 025 import org.apache.tapestry5.internal.test.PageTesterModule; 026 import org.apache.tapestry5.internal.test.TestableRequest; 027 import org.apache.tapestry5.internal.test.TestableResponse; 028 import org.apache.tapestry5.ioc.Registry; 029 import org.apache.tapestry5.ioc.def.ModuleDef; 030 import org.apache.tapestry5.ioc.internal.util.InternalUtils; 031 import org.apache.tapestry5.ioc.services.SymbolProvider; 032 import org.apache.tapestry5.services.ApplicationGlobals; 033 import org.apache.tapestry5.services.RequestHandler; 034 import org.slf4j.Logger; 035 import org.slf4j.LoggerFactory; 036 037 import java.io.IOException; 038 import java.util.Locale; 039 import java.util.Map; 040 041 /** 042 * This class is used to run a Tapestry app in a single-threaded, in-process testing environment. 043 * You can ask it to 044 * render a certain page and check the DOM object created. You can also ask it to click on a link 045 * element in the DOM 046 * object to get the next page. Because no servlet container is required, it is very fast and you 047 * can directly debug 048 * into your code in your IDE. 049 */ 050 @SuppressWarnings("all") 051 public class PageTester 052 { 053 private final Logger logger = LoggerFactory.getLogger(PageTester.class); 054 055 private final Registry registry; 056 057 private final TestableRequest request; 058 059 private final TestableResponse response; 060 061 private final RequestHandler requestHandler; 062 063 public static final String DEFAULT_CONTEXT_PATH = "src/main/webapp"; 064 065 private static final String DEFAULT_SUBMIT_VALUE_ATTRIBUTE = "Submit Query"; 066 067 /** 068 * Initializes a PageTester without overriding any services and assuming that the context root 069 * is in 070 * src/main/webapp. 071 * 072 * @see #PageTester(String, String, String, Class[]) 073 */ 074 public PageTester(String appPackage, String appName) 075 { 076 this(appPackage, appName, DEFAULT_CONTEXT_PATH); 077 } 078 079 /** 080 * Initializes a PageTester that acts as a browser and a servlet container to test drive your 081 * Tapestry pages. 082 * 083 * @param appPackage The same value you would specify using the tapestry.app-package context parameter. 084 * As this 085 * testing environment is not run in a servlet container, you need to specify it. 086 * @param appName The same value you would specify as the filter name. It is used to form the name 087 * of the 088 * module class for your app. If you don't have one, pass an empty string. 089 * @param contextPath The path to the context root so that Tapestry can find the templates (if they're 090 * put 091 * there). 092 * @param moduleClasses Classes of additional modules to load 093 */ 094 public PageTester(String appPackage, String appName, String contextPath, Class... moduleClasses) 095 { 096 assert InternalUtils.isNonBlank(appPackage); 097 assert appName != null; 098 assert InternalUtils.isNonBlank(contextPath); 099 100 SymbolProvider provider = new SingleKeySymbolProvider(InternalConstants.TAPESTRY_APP_PACKAGE_PARAM, appPackage); 101 102 TapestryAppInitializer initializer = new TapestryAppInitializer(logger, provider, appName, 103 null); 104 105 initializer.addModules(PageTesterModule.class); 106 initializer.addModules(moduleClasses); 107 initializer.addModules(provideExtraModuleDefs()); 108 109 registry = initializer.createRegistry(); 110 111 request = registry.getService(TestableRequest.class); 112 response = registry.getService(TestableResponse.class); 113 114 ApplicationGlobals globals = registry.getObject(ApplicationGlobals.class, null); 115 116 globals.storeContext(new PageTesterContext(contextPath)); 117 118 registry.performRegistryStartup(); 119 120 requestHandler = registry.getService("RequestHandler", RequestHandler.class); 121 122 request.setLocale(Locale.ENGLISH); 123 initializer.announceStartup(); 124 } 125 126 /** 127 * Overridden in subclasses to provide additional module definitions beyond those normally 128 * located. This 129 * implementation returns an empty array. 130 */ 131 protected ModuleDef[] provideExtraModuleDefs() 132 { 133 return new ModuleDef[0]; 134 } 135 136 /** 137 * Invoke this method when done using the PageTester; it shuts down the internal 138 * {@link org.apache.tapestry5.ioc.Registry} used by the tester. 139 */ 140 public void shutdown() 141 { 142 registry.cleanupThread(); 143 144 registry.shutdown(); 145 } 146 147 /** 148 * Returns the Registry that was created for the application. 149 */ 150 public Registry getRegistry() 151 { 152 return registry; 153 } 154 155 /** 156 * Allows a service to be retrieved via its service interface. Use {@link #getRegistry()} for 157 * more complicated 158 * queries. 159 * 160 * @param serviceInterface used to select the service 161 */ 162 public <T> T getService(Class<T> serviceInterface) 163 { 164 return registry.getService(serviceInterface); 165 } 166 167 /** 168 * Renders a page specified by its name. 169 * 170 * @param pageName The name of the page to be rendered. 171 * @return The DOM created. Typically you will assert against it. 172 */ 173 public Document renderPage(String pageName) 174 { 175 176 renderPageAndReturnResponse(pageName); 177 178 Document result = response.getRenderedDocument(); 179 180 if (result == null) 181 throw new RuntimeException(String.format("Render of page '%s' did not result in a Document.", 182 pageName)); 183 184 return result; 185 186 } 187 188 /** 189 * Renders a page specified by its name and returns the response. 190 * 191 * @param pageName The name of the page to be rendered. 192 * @return The response object to assert against 193 * @since 5.2.3 194 */ 195 public TestableResponse renderPageAndReturnResponse(String pageName) 196 { 197 request.clear().setPath("/" + pageName); 198 199 while (true) 200 { 201 try 202 { 203 response.clear(); 204 205 boolean handled = requestHandler.service(request, response); 206 207 if (!handled) 208 { 209 throw new RuntimeException(String.format( 210 "Request was not handled: '%s' may not be a valid page name.", pageName)); 211 } 212 213 Link link = response.getRedirectLink(); 214 215 if (link != null) 216 { 217 setupRequestFromLink(link); 218 continue; 219 } 220 221 return response; 222 223 } catch (IOException ex) 224 { 225 throw new RuntimeException(ex); 226 } finally 227 { 228 registry.cleanupThread(); 229 } 230 } 231 232 } 233 234 /** 235 * Simulates a click on a link. 236 * 237 * @param linkElement The Link object to be "clicked" on. 238 * @return The DOM created. Typically you will assert against it. 239 */ 240 public Document clickLink(Element linkElement) 241 { 242 clickLinkAndReturnResponse(linkElement); 243 244 return getDocumentFromResponse(); 245 } 246 247 /** 248 * Simulates a click on a link. 249 * 250 * @param linkElement The Link object to be "clicked" on. 251 * @return The response object to assert against 252 * @since 5.2.3 253 */ 254 public TestableResponse clickLinkAndReturnResponse(Element linkElement) 255 { 256 assert linkElement != null; 257 258 validateElementName(linkElement, "a"); 259 260 String href = extractNonBlank(linkElement, "href"); 261 262 setupRequestFromURI(href); 263 264 return runComponentEventRequest(); 265 } 266 267 private String extractNonBlank(Element element, String attributeName) 268 { 269 String result = element.getAttribute(attributeName); 270 271 if (InternalUtils.isBlank(result)) 272 throw new RuntimeException(String.format("The %s attribute of the <%s> element was blank or missing.", 273 attributeName, element.getName())); 274 275 return result; 276 } 277 278 private void validateElementName(Element element, String expectedElementName) 279 { 280 if (!element.getName().equalsIgnoreCase(expectedElementName)) 281 throw new RuntimeException(String.format("The element must be type '%s', not '%s'.", expectedElementName, 282 element.getName())); 283 } 284 285 private Document getDocumentFromResponse() 286 { 287 Document result = response.getRenderedDocument(); 288 289 if (result == null) 290 throw new RuntimeException(String.format("Render request '%s' did not result in a Document.", request.getPath())); 291 292 return result; 293 } 294 295 private TestableResponse runComponentEventRequest() 296 { 297 while (true) 298 { 299 response.clear(); 300 301 try 302 { 303 boolean handled = requestHandler.service(request, response); 304 305 if (!handled) 306 throw new RuntimeException(String.format("Request for path '%s' was not handled by Tapestry.", 307 request.getPath())); 308 309 Link link = response.getRedirectLink(); 310 311 if (link != null) 312 { 313 setupRequestFromLink(link); 314 continue; 315 } 316 317 return response; 318 } catch (IOException ex) 319 { 320 throw new RuntimeException(ex); 321 } finally 322 { 323 registry.cleanupThread(); 324 } 325 } 326 327 } 328 329 private void setupRequestFromLink(Link link) 330 { 331 setupRequestFromURI(link.toRedirectURI()); 332 } 333 334 void setupRequestFromURI(String URI) 335 { 336 String linkPath = stripContextFromPath(URI); 337 338 int comma = linkPath.indexOf('?'); 339 340 String path = comma < 0 ? linkPath : linkPath.substring(0, comma); 341 342 request.clear().setPath(path); 343 344 if (comma > 0) 345 decodeParametersIntoRequest(linkPath.substring(comma + 1)); 346 } 347 348 private void decodeParametersIntoRequest(String queryString) 349 { 350 if (InternalUtils.isBlank(queryString)) 351 return; 352 353 for (String term : queryString.split("&")) 354 { 355 int eqx = term.indexOf("="); 356 357 String key = term.substring(0, eqx).trim(); 358 String value = term.substring(eqx + 1).trim(); 359 360 request.loadParameter(key, value); 361 } 362 } 363 364 private String stripContextFromPath(String path) 365 { 366 String contextPath = request.getContextPath(); 367 368 if (contextPath.equals("")) 369 return path; 370 371 if (!path.startsWith(contextPath)) 372 throw new RuntimeException(String.format("Path '%s' does not start with context path '%s'.", path, 373 contextPath)); 374 375 return path.substring(contextPath.length()); 376 } 377 378 /** 379 * Simulates a submission of the form specified. The caller can specify values for the form 380 * fields, which act as 381 * overrides on the values stored inside the elements. 382 * 383 * @param form the form to be submitted. 384 * @param parameters the query parameter name/value pairs 385 * @return The DOM created. Typically you will assert against it. 386 */ 387 public Document submitForm(Element form, Map<String, String> parameters) 388 { 389 submitFormAndReturnResponse(form, parameters); 390 391 return getDocumentFromResponse(); 392 } 393 394 /** 395 * Simulates a submission of the form specified. The caller can specify values for the form 396 * fields, which act as 397 * overrides on the values stored inside the elements. 398 * 399 * @param form the form to be submitted. 400 * @param parameters the query parameter name/value pairs 401 * @return The response object to assert against. 402 * @since 5.2.3 403 */ 404 public TestableResponse submitFormAndReturnResponse(Element form, Map<String, String> parameters) 405 { 406 assert form != null; 407 408 validateElementName(form, "form"); 409 410 request.clear().setPath(stripContextFromPath(extractNonBlank(form, "action"))); 411 412 pushFieldValuesIntoRequest(form); 413 414 overrideParameters(parameters); 415 416 // addHiddenFormFields(form); 417 418 // ComponentInvocation invocation = getInvocation(form); 419 420 return runComponentEventRequest(); 421 } 422 423 private void overrideParameters(Map<String, String> fieldValues) 424 { 425 for (Map.Entry<String, String> e : fieldValues.entrySet()) 426 { 427 request.overrideParameter(e.getKey(), e.getValue()); 428 } 429 } 430 431 private void pushFieldValuesIntoRequest(Element form) 432 { 433 Visitor visitor = new Visitor() 434 { 435 public void visit(Element element) 436 { 437 if (InternalUtils.isNonBlank(element.getAttribute("disabled"))) 438 return; 439 440 String name = element.getName(); 441 442 if (name.equals("input")) 443 { 444 String type = extractNonBlank(element, "type"); 445 446 if (type.equals("radio") || type.equals("checkbox")) 447 { 448 if (InternalUtils.isBlank(element.getAttribute("checked"))) 449 return; 450 } 451 452 // Assume that, if the element is a button/submit, it wasn't clicked, 453 // and therefore, is not part of the submission. 454 455 if (type.equals("button") || type.equals("submit")) 456 return; 457 458 // Handle radio, checkbox, text, radio, hidden 459 String value = element.getAttribute("value"); 460 461 if (InternalUtils.isNonBlank(value)) 462 request.loadParameter(extractNonBlank(element, "name"), value); 463 464 return; 465 } 466 467 if (name.equals("option")) 468 { 469 String value = element.getAttribute("value"); 470 471 // TODO: If value is blank do we use the content, or is the content only the 472 // label? 473 474 if (InternalUtils.isNonBlank(element.getAttribute("selected"))) 475 { 476 String selectName = extractNonBlank(findAncestor(element, "select"), "name"); 477 478 request.loadParameter(selectName, value); 479 } 480 481 return; 482 } 483 484 if (name.equals("textarea")) 485 { 486 String content = element.getChildMarkup(); 487 488 if (InternalUtils.isNonBlank(content)) 489 request.loadParameter(extractNonBlank(element, "name"), content); 490 491 return; 492 } 493 } 494 }; 495 496 form.visit(visitor); 497 } 498 499 /** 500 * Simulates a submission of the form by clicking the specified submit button. The caller can 501 * specify values for the 502 * form fields. 503 * 504 * @param submitButton the submit button to be clicked. 505 * @param fieldValues the field values keyed on field names. 506 * @return The DOM created. Typically you will assert against it. 507 */ 508 public Document clickSubmit(Element submitButton, Map<String, String> fieldValues) 509 { 510 clickSubmitAndReturnResponse(submitButton, fieldValues); 511 512 return getDocumentFromResponse(); 513 } 514 515 /** 516 * Simulates a submission of the form by clicking the specified submit button. The caller can 517 * specify values for the 518 * form fields. 519 * 520 * @param submitButton the submit button to be clicked. 521 * @param fieldValues the field values keyed on field names. 522 * @return The response object to assert against. 523 * @since 5.2.3 524 */ 525 public TestableResponse clickSubmitAndReturnResponse(Element submitButton, Map<String, String> fieldValues) 526 { 527 assert submitButton != null; 528 529 assertIsSubmit(submitButton); 530 531 Element form = getFormAncestor(submitButton); 532 533 request.clear().setPath(stripContextFromPath(extractNonBlank(form, "action"))); 534 535 pushFieldValuesIntoRequest(form); 536 537 overrideParameters(fieldValues); 538 539 String value = submitButton.getAttribute("value"); 540 541 if (value == null) 542 value = DEFAULT_SUBMIT_VALUE_ATTRIBUTE; 543 544 request.overrideParameter(extractNonBlank(submitButton, "name"), value); 545 546 return runComponentEventRequest(); 547 } 548 549 private void assertIsSubmit(Element element) 550 { 551 if (element.getName().equals("input")) 552 { 553 String type = element.getAttribute("type"); 554 555 if ("submit".equals(type)) 556 return; 557 } 558 559 throw new IllegalArgumentException("The specified element is not a submit button."); 560 } 561 562 private Element getFormAncestor(Element element) 563 { 564 return findAncestor(element, "form"); 565 } 566 567 private Element findAncestor(Element element, String ancestorName) 568 { 569 Element e = element; 570 571 while (e != null) 572 { 573 if (e.getName().equalsIgnoreCase(ancestorName)) 574 return e; 575 576 e = e.getContainer(); 577 } 578 579 throw new RuntimeException(String.format("Could not locate an ancestor element of type '%s'.", ancestorName)); 580 581 } 582 583 /** 584 * Sets the simulated browser's preferred language, i.e., the value returned from 585 * {@link org.apache.tapestry5.services.Request#getLocale()}. 586 * 587 * @param preferedLanguage preferred language setting 588 */ 589 public void setPreferedLanguage(Locale preferedLanguage) 590 { 591 request.setLocale(preferedLanguage); 592 } 593 }