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}