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