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