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
015package org.apache.tapestry5.test;
016
017import org.apache.tapestry5.Link;
018import org.apache.tapestry5.dom.Document;
019import org.apache.tapestry5.dom.Element;
020import org.apache.tapestry5.dom.Visitor;
021import org.apache.tapestry5.internal.InternalConstants;
022import org.apache.tapestry5.internal.SingleKeySymbolProvider;
023import org.apache.tapestry5.internal.TapestryAppInitializer;
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.services.ApplicationGlobals;
033import org.apache.tapestry5.services.RequestHandler;
034import org.slf4j.Logger;
035import org.slf4j.LoggerFactory;
036
037import java.io.IOException;
038import java.util.Locale;
039import 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")
051public 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}