001 // Copyright 2006, 2007, 2008, 2009, 2010, 2011 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 private 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(path.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 }