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}