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    }