Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

First commit of refactoring work

  • Loading branch information...
commit 6af28f0c595db62e4f72891f7cd0817d940e56c1 0 parents
Marc Palmer marcpalmer authored
Showing with 3,661 additions and 0 deletions.
  1. +61 −0 FunctionalTestGrailsPlugin.groovy
  2. +14 −0 LICENSE.txt
  3. +427 −0 README.txt
  4. +6 −0 application.properties
  5. +32 −0 grails-app/conf/BuildConfig.groovy
  6. +27 −0 grails-app/conf/DataSource.groovy
  7. +11 −0 grails-app/conf/FunctionalTestUrlMappings.groovy
  8. +10 −0 grails-app/conf/UrlMappings.groovy
  9. +49 −0 grails-app/controllers/com/grailsrocks/functionaltest/controllers/FunctionalTestDataAccessController.groovy
  10. 0  grails-app/i18n/messages.properties
  11. BIN  lib/commons-codec-1.4.jar
  12. BIN  lib/commons-httpclient-3.1.jar
  13. BIN  lib/cssparser-0.9.5.jar
  14. BIN  lib/htmlunit-2.7.jar
  15. BIN  lib/htmlunit-core-js-2.7.jar
  16. BIN  lib/nekohtml-1.9.14.jar
  17. BIN  lib/sac-1.3.jar
  18. BIN  lib/serializer-2.7.1.jar
  19. BIN  lib/xalan-2.7.1.jar
  20. BIN  lib/xercesImpl-2.9.1.jar
  21. +36 −0 scripts/CreateFunctionalTest.groovy
  22. +427 −0 scripts/FunctionalTests.groovy
  23. +13 −0 scripts/_Events.groovy
  24. +32 −0 scripts/_Install.groovy
  25. +30 −0 scripts/_Upgrade.groovy
  26. +63 −0 src/groovy/com/grailsrocks/functionaltest/APITestCase.groovy
  27. +294 −0 src/groovy/com/grailsrocks/functionaltest/BrowserTestCase.groovy
  28. +61 −0 src/groovy/com/grailsrocks/functionaltest/FunctionalTestException.groovy
  29. +5 −0 src/groovy/com/grailsrocks/functionaltest/HybridTestCase.groovy
  30. +438 −0 src/groovy/com/grailsrocks/functionaltest/TestCaseBase.groovy
  31. 0  src/groovy/com/grailsrocks/functionaltest/client/APIClient.groovy
  32. +305 −0 src/groovy/com/grailsrocks/functionaltest/client/BrowserClient.groovy
  33. +21 −0 src/groovy/com/grailsrocks/functionaltest/client/Client.groovy
  34. +5 −0 src/groovy/com/grailsrocks/functionaltest/client/ClientListener.groovy
  35. +9 −0 src/groovy/com/grailsrocks/functionaltest/client/ContentChangedEvent.groovy
  36. +25 −0 src/groovy/com/grailsrocks/functionaltest/client/InterceptingPageCreator.groovy
  37. +42 −0 src/groovy/com/grailsrocks/functionaltest/client/htmlunit/FieldsWrapper.groovy
  38. +226 −0 src/groovy/com/grailsrocks/functionaltest/client/htmlunit/FormWrapper.groovy
  39. +49 −0 src/groovy/com/grailsrocks/functionaltest/client/htmlunit/FormsWrapper.groovy
  40. +33 −0 src/groovy/com/grailsrocks/functionaltest/client/htmlunit/RadioButtonsWrapper.groovy
  41. +45 −0 src/groovy/com/grailsrocks/functionaltest/client/htmlunit/RadioGroupWrapper.groovy
  42. +37 −0 src/groovy/com/grailsrocks/functionaltest/client/htmlunit/SelectsWrapper.groovy
  43. +83 −0 src/groovy/com/grailsrocks/functionaltest/dsl/RequestBuilder.groovy
  44. +7 −0 src/groovy/com/grailsrocks/functionaltest/util/HTTPUtils.groovy
  45. +30 −0 src/groovy/functionaltestplugin/FunctionalTestCase.groovy
  46. +11 −0 src/groovy/functionaltestplugin/TestingUtil.groovy
  47. +12 −0 src/templates/artifacts/FunctionalTest.groovy
  48. +60 −0 test/functional/TestingTests.groovy
  49. +6 −0 test/resources/a.html
  50. +6 −0 test/resources/b.html
  51. +11 −0 test/resources/bgjs.html
  52. +11 −0 test/resources/domtests.html
  53. +6 −0 test/resources/formtests.html
  54. +19 −0 test/resources/jquery-1.3.1.min.js
  55. +14 −0 test/resources/jquerytest.html
  56. +552 −0 test/unit/SimpleHttpTestCaseTests.groovy
61 FunctionalTestGrailsPlugin.groovy
@@ -0,0 +1,61 @@
+/* Copyright 2004-2007 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * The original code of this plugin was developed by Historic Futures Ltd.
+ * (www.historicfutures.com) and open sourced.
+ */
+
+ class FunctionalTestGrailsPlugin {
+ def version = "2.0.SNAPSHOT"
+ def dependsOn = [:]
+ def loadAfter = ['greenmail', 'fixtures']
+ def scopes = [ includes: "functional_test" ]
+
+ def author = "Marc Palmer"
+ def authorEmail = "marc@anyware.co.uk"
+ def title = "Functional Testing"
+ def description = '''\
+Simple 'pure grails' functional testing for your web applications
+'''
+
+ // URL to the plugin's documentation
+ def documentation = "http://grails.org/plugin/functional-test"
+
+ def doWithSpring = {
+ // TODO Implement runtime spring config (optional)
+ }
+
+ def doWithApplicationContext = { applicationContext ->
+ // TODO Implement post initialization spring config (optional)
+ }
+
+ def doWithWebDescriptor = { xml ->
+ // TODO Implement additions to web.xml (optional)
+ }
+
+ def doWithDynamicMethods = { ctx ->
+ // TODO Implement registering dynamic methods to classes (optional)
+ }
+
+ def onChange = { event ->
+ // TODO Implement code that is executed when any artefact that this plugin is
+ // watching is modified and reloaded. The event contains: event.source,
+ // event.application, event.manager, event.ctx, and event.plugin.
+ }
+
+ def onConfigChange = { event ->
+ // TODO Implement code that is executed when the project configuration changes.
+ // The event is the same as for 'onChange'.
+ }
+}
14 LICENSE.txt
@@ -0,0 +1,14 @@
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+(c) 2009 Marc Palmer / AnyWare Ltd. www.grailsrocks.com
427 README.txt
@@ -0,0 +1,427 @@
+h1. Functional Testing Plugin
+
+Author: Marc Palmer [http://www.anyware.co.uk]
+
+These docs cover version 1.1
+
+{note}
+This plugin is licensed under the Apache License, Version 2.0.
+The original code of this plugin was developed by Historic Futures Ltd. [http://www.historicfutures.com] and open sourced.
+{note}
+
+h2. Overview
+
+This plugin provides really easy functional web testing within the existing
+framework of JUnit testing under grails.
+
+It is lightweight and leverages the HtmlUnit engine for simulating the client
+browser - without requiring any specific browser installed.
+
+This means you can:
+
+* Do easy REST functional testing just by issuing GET/POST etc calls and then inspect the result
+* Do stateful functional testing of websites, including DOM inspection
+* Do any of these against your application that is automatically run locally from WAR under Jetty, or against any other URL for testing production sites
+
+h2. Installation
+{code}
+Run: grails install-plugin functional-test
+{code}
+Done!
+
+h2. Usage
+
+1) Run: grails create-functional-test HelloWorld
+
+2) Edit the generated <project>/test/functional/HelloWorldTests.groovy file
+
+3) Add code to the test methods (see reference below)
+
+Here's an example script:
+{code}
+class TwitterTests extends functionaltestplugin.FunctionalTestCase {
+ void testSearch() {
+ get('http://www.twitter.com')
+
+ click "Search"
+
+ assertStatus 200
+ assertContentContains "search"
+
+ form('searchForm') {
+ q = "#grails"
+ click "Search"
+ }
+
+ assertStatus 200
+ assertContentContains "#grails"
+ }
+}
+{code}
+
+4) Run: grails functional-tests
+(Optionally specifying a single test name e.g. to run YourTests add Your)
+
+5) View the output in test/reports
+
+
+h2. Functional Testing Reference
+
+The test class has dynamic methods added by the plugin to make it easy to request content, post content, and interact with forms and page elements.
+
+URLs are resolved in the following ways:
+
+# Absolute urls are treated "as is"
+# URLs that do not begin with a / are relative to the last page retrieved, or if it is the first page retrieved, relative to the value of the system property "grails.functional.test.baseURL"
+# URLs that begin with / are relative to the application, or the value of grails.functional.test.baseURL system property if it is set.
+
+h3. property: cookiesEnabled
+
+Controls whether or not cookies are allowed. Default is true. You can change this through the course of your test code.
+
+h3. property: cookies
+
+Returns all current cookies in a set. Properties on the cookies includ "domain", "name", "value", "secure", "version", "path" and "comment"
+
+h3. property: javaScriptEnabled
+
+Controls whether or not Javascript code will be executed. Default is true. You can change this through the course of your test code.
+
+h3. property: redirectEnabled
+
+Controls whether or not redirects are automatically followed. Default is true. You can change this through the course of your test code.
+
+{note}
+If you want to be able to call assertRedirectUrlXXXX methods and prove that a redirect takes place, you must set this to false
+{note}
+
+h3. property: page
+
+This property is available at all times, once a page has been retrieved. It exposes the underlying HtmlUnit page object for more advanced manipulation.
+
+You can also get direct access to any forms of the page:
+{code}
+void testSomething() {
+ get('/mycontroller','myAction')
+
+ assertNotNull page.forms['userDetails'] // Make sure form is there
+}
+{code}
+
+h3. method: get(uri)
+
+This will issue a GET request to the URI which can be relative to the last page retrieved in the test method, or absolute within the application, or a full remote URL starting with http:// or https://
+
+An optional closure can be supplied that will enable you to attach parameters to the request:
+{code}
+void testSomething() {
+ get('/mycontroller') {
+ headers['X-something-special'] = 'A-value-here'
+
+ // NOTE: you can use this "method call" approach or assignment x = y
+ userName "marc"
+ email "marc@somewhere.com"
+ }
+
+ assertContentContains "it worked"
+}
+{code}
+
+h3. method: post(uri)
+
+As get(uri) but using the HTTP POST method. Note that you for POST and PUT you will usually also want to specify a body:
+
+{code}
+void testSomething() {
+ post('/mycontroller/save') {
+ body {
+ """
+<cart><item id="3"><title>Xenosapien</title><artist>Cephalic Carnage</artist></item></cart>
+"""
+ }
+ }
+
+ assertStatus 200
+}
+{code}
+
+The body closure expects the closure result to be a string.
+
+h3. method: put(uri)
+
+As get(uri) but using the HTTP PUT method
+
+h3. method: delete(uri)
+
+As get(uri) but using the HTTP DELETE method
+
+h3. method: click(idOrLinkText)
+
+This will click a link in the currently retrieved page, locating the link by an id attribute value, or if not found, by the text of the link.
+
+h3. method: followRedirect()
+
+Follows the redirect URL specified in the last response. For use after calling assertRedirectUrl with redirectEnabled set to false.
+
+h3. method: form(name)
+
+Obtains a reference to a form with name attribute matching the name passed to the method. You can then set or query values of fields in the form:
+{code}
+void testSomething() {
+ get('/mycontroller') {
+ userName "marc"
+ // NOTE: you can use this "method call" approach or assignment x = y
+ email "marc@somewhere.com"
+ }
+
+ form("userDetails") {
+ name = "Marc"
+ email = "secret@hades.com"
+ click "submit"
+ }
+
+ assertContentContains "form submitted"
+}
+{code}
+
+The form object return lets you locate elements by the value of their name attribute, by invoking methods or setting properties. Depending on their type you can interact with them in different ways:
+
+* Simple text fields such as inputs with type text, hidden, password etc can just be set or accessed as the value
+* Checkable items - radio buttons and checkboxes - can just be set to true/false
+* Selectable items - select boxes - can have their single selection get/set
+
+So for example, normal input fields can have their value attribute get/set when you access them:
+{code}
+form("userDetails") {
+ name = "Marc"
+ email = "secret@hades.com"
+ screenName "the_unknown_guest"
+ fields['convoluted.field.name'].value = "have to use setValue here"
+}
+{code}
+Select fields can have the selected item(s) changes by calling select:
+{code}
+form("userDetails") {
+ name = "Marc"
+ country = "uk" // this is a select box!
+ selects['currency.id'].select "GBP" // This is a select box retrieved explicitly
+}
+{code}
+Checkboxes and radios are just set to true/false to change their checked status:
+{code}
+form("userDetails") {
+ name = "Marc"
+ agreedTsAndCs true
+ click "submit"
+}
+{code}
+You can also access groups of radiobuttons by the field name attribute, and set the selected radio button in one easy call
+{code}
+form("userDetails") {
+ name = "Marc"
+ radioButtons.typeOfService = "POWERUSER"
+ click "submit"
+}
+{code}
+The above will find the radioButton of name typeOfService and value "POWERUSER" and set it to checked.
+
+To click a button or image input in a form, there is a synthetic method "click" method, as well as a click method on clickable elements:
+{code}
+form("userDetails") {
+ name = "Marc"
+ click "send"
+}
+{code}
+The above will find the clickable element in the form with name "send", or failing that with the value "send" and click it. If still nothing suitable is found, it will look for a button-type element with the *value* of the name specified. Alternatively you can do:
+{code}
+form("userDetails") {
+ name = "Marc"
+ send.click()
+}
+{code}
+Note that you can have nested closures in the "form" closure, to denote nested field names with dot notation:
+{code}
+form("userDetails") {
+ name "Marc"
+ address {
+ street "668 Rue des Mortes"
+ country "The U.S. of A"
+ }
+ send.click()
+}
+{code}
+The above would try to set the fields with names "address.street" and "address.country"
+
+{note}
+You can directly access fields of certain types using the "fields", "radiobuttons" and "selects" array properties.
+
+Properties and methods that you can access on specific types of field:
+# Text fields: "value" can be get or set
+# Radio buttons: "value" can be get or set - the value of the currently checked item in the group
+# Selects: "select(value)" and "deselect(value)" to change the selection. Property "selected" to get the list of selected values.
+{note}
+
+h3. method: byId(elementID)
+
+Retrieves an element from the current page by its id attribute. Returns null if there is no such element.
+
+h3. method: byName(elementName)
+
+Retrieves an element from the current page by its name attribute. Returns null if there is no such element. Throws and exception if there are multiple elements in the page with the same name
+
+h3. method: byXPath(xpathQuery)
+
+Retrieves the first element from the current page matching the XPath query. Returns null if there is no such element.
+
+h3. method: clearCache()
+
+Call this if you want to force the clearing of the JS and CSS cache, which may be useful if for example you are dynamically generating CSS or JS code in your test.
+
+h3. method: assertStatus <value>
+
+Called to assert the numerical status code of the last response:
+{code}
+void testSomething() {
+ get('/mycontroller','myAction') {
+ userName "marc"
+ // NOTE: you can use this "method call" approach or assignment x = y
+ email "marc@somewhere.com"
+ }
+
+ assertStatus 403 // we're not logged in!
+}
+{code}
+
+h3. method: assertContentContains <value>
+
+Asserts that the content of the last response contains the text supplied, case insensitive and all whitespace ignored.
+{code}
+assertContentContains "user profile"
+{code}
+
+h3. method: assertContentContainsStrict <value>
+
+Asserts that the content of the last response contains the text supplied, case sensitive, whitespace matching.
+{code}
+assertContentContainsStrict "User Profile"
+{code}
+
+h3. method: assertContent <value>
+
+Asserts that the content of the last response equals the text supplied, case insensitive and all whitespace ignored.
+{code}
+assertContent "<response>ok</response>"
+{code}
+
+h3. method: assertContentStrict <value>
+
+Asserts that the content of the last response equals the text supplied, case sensitive, whitespace matching.
+{code}
+assertContentStrict "<response>\nOK\n</response>\n"
+{code}
+
+h3. method: assertContentType <value>
+
+Asserts that the content type of the last response starts with the supplied string, eg assertContentType "text/html" will pass even if there is an
+encoding at the end.
+{code}
+assertContentType "text/html"
+assertContentType "text/html; charset=utf-8"
+{code}
+
+h3. method: assertContentTypeStrict <value>
+
+Asserts that the content type of the last response matches the supplied string, case and whitespace matching exactly
+{code}
+assertContentTypeStrict "text/html; charset=UTF-8"
+{code}
+
+h3. method: assertHeader <headername>, <value>
+
+Asserts that a response header equals the expected content, case and whitespace ignored eg:
+{code}
+assertHeader "Cache-Control", "private, max-age="
+{code}
+
+h3. method: assertHeaderStrict <headername>, <value>
+
+Asserts that a response header equals exactly the expected content eg:
+{code}
+assertHeaderStrict "Pragma", "no-cache"
+{code}
+
+h3. method: assertHeaderContains <headername>, <value>
+
+Asserts that a response header contains the expected content, case and whitespace ignored eg:
+{code}
+assertHeader "Set-Cookie", "domain=.google.co.uk"
+{code}
+
+h3. method: assertHeaderContainsStrict <headername>, <value>
+
+Asserts that a response header contains exactly the expected content eg:
+{code}
+assertHeaderContainsStrict "Set-Cookie", "domain=.google.co.uk"
+{code}
+
+h3. method: assertRedirectUrl <value>
+
+Asserts that the response included a redirect to the specified URL
+{code}
+assertRedirectUrl "/auth/login"
+{code}
+
+h3. method: assertRedirectUrlContains <value>
+
+Asserts that the response included a redirect that contains the specified string
+{code}
+assertRedirectUrlContains "?id=74"
+{code}
+
+h3. method: assertTitle <value>
+
+Asserts that the title of the current page equals the value supplied, ignoring case and whitespace
+
+h3. method: assertTitleContains <value>
+
+Asserts that the title of the current page contains the value supplied, ignoring case and whitespace
+
+h3. method: assertMeta <name>, <value>
+
+Asserts that the meta tag of the current page with the specified name equals the value supplied, ignoring case and whitespace
+
+h3. method: assertMetaContains <name>, <value>
+
+Asserts that the meta tag of the current page with the specified name contains the value supplied, ignoring case and whitespace
+
+h3. method: assertCookieExists <name>
+
+Asserts that a cookie with that name exists in the browser of the currently executing test
+
+h3. method: assertCookieExistsInDomain <name>, <domain>
+
+Asserts that a cookie with that name exists in specified domain in the browser of the currently executing test
+
+h3. method: assertCookieContains <name>, <content>
+
+Asserts that a cookie with that name exists in the browser of the currently executing test, and contains the content expected (loosely - case and whitespace insensitive)
+
+h3. method: assertCookieContainsStrict <name>, <content>
+
+Asserts that a cookie with that name exists in the browser of the currently executing test, and contains the content expected case and whitespace sensitive
+
+
+h2. Roadmap - future stuff
+
+* Make assert variants dump out the actual value compared to in the case of failure eg "Value: 'xxxxx' did not contain 'y'"
+* Add JSON and XML response parsing
+* Add JSON and XML request payloads
+* Monkey patch functional tests so no need to extend test class
+* Support setting post BODY (not just params)
+* Support asserting that an alert window pops up
+* Fix HTML results and XSLT template says Unit Tests
+* Custom test reports - with URL request stack (and all req params)
+* Add support for assertElememtWithId and assertElememtWithClass
+* Add assert variants that take message as first param
+* Analyze stacktraces to find line of test that failed and highlight in reports
+
6 application.properties
@@ -0,0 +1,6 @@
+#Grails Metadata file
+#Wed Jan 04 17:28:39 GMT 2012
+app.grails.version=1.3.7
+app.name=FunctionalTest
+plugins.hibernate=1.3.7
+plugins.tomcat=1.3.7
32 grails-app/conf/BuildConfig.groovy
@@ -0,0 +1,32 @@
+// Add XERCES & XALAN to classpath for building - we assume you're building with 1.1 or higher now
+/*
+def xmlJars = new File("${basedir}/lib").listFiles().findAll { it.name.endsWith("._jar") }
+
+grailsSettings.compileDependencies.addAll xmlJars
+grailsSettings.runtimeDependencies.addAll xmlJars
+grailsSettings.testDependencies.addAll xmlJars
+*/
+
+grails.project.dependency.resolution = {
+ inherits "global"
+ //flatDir name:'gfunclocalJars', dirs:'./lib/'
+
+ dependencies {
+ test( 'net.sourceforge.htmlunit:htmlunit:2.7')
+ test( 'net.sourceforge.htmlunit:htmlunit-core-js:2.7')
+ test( 'commons-codec:commons-codec:1.4')
+ test( 'commons-httpclient:commons-httpclient:3.1')
+ test( 'nekohtml:nekohtml:1.9.14')
+ test( 'cssparser:cssparser:0.9.5')
+ test( 'sac:sac:1.3')
+ test( 'serializer:serializer:2.7.1')
+ test( 'xalan:xalan:2.7.1')
+ test( 'xercesImpl:xercesImpl:2.9.1')
+ }
+
+ plugins {
+ runtime( ":tomcat:$grailsVersion") {
+ export = false
+ }
+ }
+}
27 grails-app/conf/DataSource.groovy
@@ -0,0 +1,27 @@
+dataSource {
+ pooled = true
+ driverClassName = "org.hsqldb.jdbcDriver"
+ username = "sa"
+ password = ""
+}
+// environment specific settings
+environments {
+ development {
+ dataSource {
+ dbCreate = "create-drop" // one of 'create', 'create-drop','update'
+ url = "jdbc:hsqldb:mem:devDB"
+ }
+ }
+ test {
+ dataSource {
+ dbCreate = "create-drop"
+ url = "jdbc:hsqldb:mem:testDb"
+ }
+ }
+ production {
+ dataSource {
+ dbCreate = "update"
+ url = "jdbc:hsqldb:file:prodDb;shutdown=true"
+ }
+ }
+}
11 grails-app/conf/FunctionalTestUrlMappings.groovy
@@ -0,0 +1,11 @@
+import grails.util.Environment
+
+class FunctionalTestUrlMappings {
+ static mappings = {
+ if (Environment.current != Environment.PRODUCTION) {
+ "/functionaltesting/$action?"{
+ controller = "functionalTestDataAccess"
+ }
+ }
+ }
+}
10 grails-app/conf/UrlMappings.groovy
@@ -0,0 +1,10 @@
+class UrlMappings {
+ static mappings = {
+ "/$controller/$action?/$id?"{
+ constraints {
+ // apply constraints here
+ }
+ }
+ "500"(view:'/error')
+ }
+}
49 grails-app/controllers/com/grailsrocks/functionaltest/controllers/FunctionalTestDataAccessController.groovy
@@ -0,0 +1,49 @@
+package com.grailsrocks.functionaltest.controllers
+
+import grails.util.Environment
+import grails.converters.JSON
+import grails.util.GrailsNameUtils
+
+class FunctionalTestDataAccessController {
+
+ def fixtureLoader
+
+ def objectExists = {
+ assert Environment.current != Environment.PRODUCTION
+ def clsName = params.className
+ def findField = GrailsNameUtils.getClassNameRepresentation(params.findField)
+ def findValue = params.findValue
+ def domclass = grailsApplication.getDomainClass(clsName).clazz
+ def obj = domclass."findBy${findField}"(findValue)
+ def res = [:]
+ if (!obj) {
+ res.error = 'Not found'
+ }
+ render(text: res as JSON, status:obj ? 200 : 404)
+ }
+
+ def findObject = {
+ assert Environment.current != Environment.PRODUCTION
+ def clsName = params.className
+ def findField = GrailsNameUtils.getClassNameRepresentation(params.findField)
+ def findValue = params.findValue
+ def domclass = grailsApplication.getDomainClass(clsName).clazz
+ def obj = domclass."findBy${findField}"(findValue)
+ if (obj) {
+ render obj as JSON
+ } else {
+ render(text: [error:'Not found'] as JSON, status: 404)
+ }
+ }
+
+ def fixture = {
+ assert Environment.current != Environment.PRODUCTION
+
+ def f = fixtureLoader.load(params.name)
+ def res = [:]
+ if (!f) {
+ res.error = 'No such fixture: [${params.name}]'
+ }
+ render(text: res as JSON, status: res.error ? 500 : 200)
+ }
+}
0  grails-app/i18n/messages.properties
No changes.
BIN  lib/commons-codec-1.4.jar
Binary file not shown
BIN  lib/commons-httpclient-3.1.jar
Binary file not shown
BIN  lib/cssparser-0.9.5.jar
Binary file not shown
BIN  lib/htmlunit-2.7.jar
Binary file not shown
BIN  lib/htmlunit-core-js-2.7.jar
Binary file not shown
BIN  lib/nekohtml-1.9.14.jar
Binary file not shown
BIN  lib/sac-1.3.jar
Binary file not shown
BIN  lib/serializer-2.7.1.jar
Binary file not shown
BIN  lib/xalan-2.7.1.jar
Binary file not shown
BIN  lib/xercesImpl-2.9.1.jar
Binary file not shown
36 scripts/CreateFunctionalTest.groovy
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2008-2009 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Gant script that creates a functional test
+ *
+ * @author Marc Palmer
+ */
+
+
+import org.codehaus.groovy.grails.commons.GrailsClassUtils as GCU
+
+includeTargets << grailsScript("_GrailsInit")
+includeTargets << grailsScript("_GrailsCreateArtifacts")
+
+target ('default': "Creates a new Grails functional test.") {
+ depends( checkVersion, parseArguments, createFunctionalTest )
+}
+
+target (createFunctionalTest: "Implementation of create-functional-test") {
+ def superClass = "functionaltestplugin.FunctionalTestCase"
+ createArtifact(name: argsMap["params"][0], suffix: "FunctionalTests", type: "FunctionalTest", path: "test/functional", superClass: superClass)
+}
427 scripts/FunctionalTests.groovy
@@ -0,0 +1,427 @@
+/*
+ * Copyright 2008-2009 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Gant script that runs the functional tests
+ *
+ * @author Marc Palmer
+ *
+ */
+
+import org.codehaus.groovy.grails.commons.GrailsClassUtils as GCU;
+import grails.util.GrailsUtil as GU;
+import grails.util.GrailsWebUtil as GWU
+import org.codehaus.groovy.grails.commons.GrailsApplication;
+import org.codehaus.groovy.grails.support.*
+import java.lang.reflect.Modifier;
+import junit.framework.TestCase;
+import junit.framework.TestResult;
+import junit.framework.TestSuite;
+import junit.textui.TestRunner;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.codehaus.groovy.grails.commons.spring.GrailsRuntimeConfigurator as GRC;
+import org.apache.tools.ant.taskdefs.optional.junit.*
+import org.springframework.mock.web.*
+import org.springframework.core.io.*
+import org.springframework.web.context.request.RequestContextHolder;
+import org.codehaus.groovy.grails.plugins.*
+import org.codehaus.groovy.grails.web.servlet.GrailsApplicationAttributes
+import org.springframework.transaction.support.TransactionTemplate
+import org.springframework.transaction.support.TransactionCallback
+import org.springframework.transaction.TransactionStatus
+import org.apache.commons.logging.LogFactory
+import grails.web.container.EmbeddableServerFactory
+
+Ant.property(environment: "env")
+grailsHome = Ant.antProject.properties."env.GRAILS_HOME"
+result = new TestResult()
+compilationFailures = []
+testingBaseURL = null
+testingInProcessJetty = false
+
+// Change default env to test
+scriptEnv = "test"
+
+includeTargets << grailsScript("Init")
+includeTargets << grailsScript("Bootstrap")
+includeTargets << grailsScript("Run")
+includeTargets << grailsScript("War")
+
+generateLog4jFile = true
+
+target('default': "Run a Grails application's functional tests") {
+ depends(classpath, checkVersion, configureProxy, parseArguments, clean, cleanTestReports)
+ runFunctionalTests()
+}
+
+reportsDir = "${basedir}/test/reports"
+
+def processResults = {
+ if (result) {
+ if (result.errorCount() > 0 || result.failureCount() > 0 || compilationFailures.size > 0) {
+ event("StatusFinal", ["Tests failed: ${result.errorCount()} errors, ${result.failureCount()} failures, ${compilationFailures.size} compilation errors. View reports in $reportsDir"])
+ exit(1)
+ }
+ else {
+ event("StatusFinal", ["Tests passed. View reports in $reportsDir"])
+ exit(0)
+ }
+
+ }
+ else {
+ event("StatusFinal", ["Tests passed. View reports in $reportsDir"])
+ exit(0)
+ }
+}
+
+target(runFunctionalTests: "The functional test implementation target") {
+ depends(packageApp)
+
+ // We accept commands of the form:
+ // grails functional-tests [URL] [testname1] [testname2] [testnameN]
+ // Where all in [...] are optional
+ ftArgs = argsMap["params"]
+ if (ftArgs && ftArgs[0] =~ "^http(s)?://") {
+ testingBaseURL = ftArgs[0]
+
+ // Shift the args
+ ftArgs.remove(0)
+ } else {
+ // Default to internally hosted app
+ testingBaseURL = "http://localhost:$serverPort$serverContextPath"
+ if (!testingBaseURL.endsWith('/')) testingBaseURL += '/'
+ testingInProcessJetty = true
+ }
+
+ if (testingInProcessJetty) {
+ // Do init required to simulate runWar
+ depends(configureProxy)
+ if (!argsMap["dev-mode"]) war()
+ }
+
+ if (config.grails.testing.reports.destDir) {
+ reportsDir = config.grails.testing.reports.destDir
+ }
+
+ Ant.mkdir(dir: reportsDir)
+ Ant.mkdir(dir: "${reportsDir}/html")
+ Ant.mkdir(dir: "${reportsDir}/plain")
+
+ compileTests()
+ packageTests()
+
+ /* In Grails 1.1 we will use Groovy mixin
+ // Call me evil
+ Class testBaseClass = classLoader.loadClass('functionaltestplugin.FunctionalTestCase')
+ GroovyTestCase.metaClass.mixin testBaseClass
+ */
+
+ def server
+ def completed = false
+ def previousRunMode
+
+ previousRunMode = System.getProperty('grails.run.mode', '')
+ System.setProperty('grails.run.mode', "functional-test")
+
+ try {
+ if (testingInProcessJetty) {
+ def savedOut = System.out
+ def savedErr = System.err
+ try {
+ new File(reportsDir, "bootstrap-out.txt").withOutputStream {outStream ->
+ System.out = new PrintStream(outStream)
+ new File(reportsDir, "bootstrap-err.txt").withOutputStream {errStream ->
+ System.err = new PrintStream(errStream)
+
+ if (argsMap["dev-mode"]) {
+ println "Running tests in dev mode"
+ server = runInline(SCHEME_HTTP, serverHost, serverPort, serverPortHttps)
+ }
+ else {
+ server = runWar(SCHEME_HTTP, serverHost, serverPort, serverPortHttps)
+ }
+ // start it
+ server.start()
+ }
+ }
+ } finally {
+ System.out = savedOut
+ System.err = savedErr
+ }
+ }
+
+
+ System.setProperty('grails.functional.test.baseURL', testingBaseURL)
+
+ System.out.println "Functional tests running with base url: ${testingBaseURL}"
+ // @todo Hmmm this doesn't look like the right event to use
+ event("AllTestsStart", ["Starting run-functional-tests"])
+ doFunctionalTests()
+ event("AllTestsEnd", ["Finishing run-functional-tests"])
+ produceReports()
+ completed = true
+ }
+ catch (Exception ex) {
+ ex.printStackTrace()
+ throw ex
+ }
+ finally {
+ if (testingInProcessJetty && server) {
+ stopWarServer()
+ }
+ System.setProperty('grails.run.mode', previousRunMode)
+ if (completed) {
+ processResults()
+ }
+ }
+}
+
+private runInline(scheme, host, httpPort, httpsPort) {
+ EmbeddableServerFactory serverFactory = loadServerFactory()
+ grailsServer = serverFactory.createInline("${basedir}/web-app", webXmlFile.absolutePath, serverContextPath, classLoader)
+ runServer server: grailsServer, host:host, httpPort: httpPort, httpsPort: httpsPort, scheme:scheme
+ startPluginScanner()
+}
+
+private runWar(scheme, host, httpPort, httpsPort) {
+ EmbeddableServerFactory serverFactory = loadServerFactory()
+ grailsServer = serverFactory.createForWAR(warName, serverContextPath)
+
+ grails.util.Metadata.getCurrent().put(grails.util.Metadata.WAR_DEPLOYED, "true")
+ runServer server:grailsServer, host:host, httpPort:httpPort, httpsPort: httpsPort, scheme: scheme
+}
+
+target(packageTests: "Puts some useful things on the classpath") {
+ Ant.copy(todir: testDirPath) {
+ fileset(dir: "${basedir}", includes: "application.properties")
+ }
+ Ant.copy(todir: testDirPath, failonerror: false) {
+ fileset(dir: "${basedir}/grails-app/conf", includes: "**", excludes: "*.groovy, log4j*, hibernate, spring")
+ fileset(dir: "${basedir}/grails-app/conf/hibernate", includes: "**/**")
+ fileset(dir: "${basedir}/src/java") {
+ include(name: "**/**")
+ exclude(name: "**/*.java")
+ }
+ fileset(dir: "${basedir}/test/functional") {
+ include(name: "**/**")
+ exclude(name: "**/*.java")
+ exclude(name: "**/*.groovy)")
+ }
+ }
+
+}
+target(compileTests: "Compiles the functional test cases") {
+ event("TestCompileStart", ['functional-tests'])
+
+ def destDir = testDirPath
+ Ant.mkdir(dir: destDir)
+ try {
+ //def nonTestCompilerClasspath = compilerClasspath.curry(false)
+ Ant.groovyc(destdir: destDir,
+ projectName: grailsAppName,
+ encoding: "UTF-8",
+ classpathref: "grails.test.classpath", {
+ src(path: "${basedir}/test/functional")
+ })
+ }
+ catch (Exception e) {
+ event("StatusFinal", ["Compilation Error: ${e.message}"])
+ exit(1)
+ }
+
+ classLoader = new URLClassLoader([new File(destDir).toURI().toURL()] as URL[],
+ classLoader)
+ Thread.currentThread().contextClassLoader = classLoader
+
+ event("TestCompileEnd", ['functional-tests'])
+}
+
+def populateTestSuite = {suite, testFiles, classLoader, String base ->
+ for (r in testFiles) {
+ try {
+ def fileName = r.URL.toString()
+ def endIndex = -8
+ if (fileName.endsWith(".java")) {
+ endIndex = -6
+ }
+ def className = fileName[fileName.indexOf(base) + base.size()..endIndex].replace('/' as char, '.' as char)
+ def c = classLoader.loadClass(className)
+ if (TestCase.isAssignableFrom(c) && !Modifier.isAbstract(c.modifiers)) {
+ suite.addTestSuite(c)
+ }
+ else {
+ event("StatusUpdate", ["Functional test ${r.filename} is not a valid test case. It does not implement junit.framework.TestCase or is abstract!"])
+ }
+ } catch (Exception e) {
+ compilationFailures << r.file.name
+ event("StatusFinal", ["Error loading functional test: ${e.message}"])
+ exit(1)
+ }
+ }
+}
+def runTests = {suite, TestResult result, Closure callback ->
+ for (TestSuite test in suite.tests()) {
+ new File("${reportsDir}/FUNCTEST-${test.name}.xml").withOutputStream {xmlOut ->
+ new File("${reportsDir}/plain/FUNCTEST-${test.name}.txt").withOutputStream {plainOut ->
+
+ def savedOut = System.out
+ def savedErr = System.err
+
+ try {
+ def outBytes = new ByteArrayOutputStream()
+ def errBytes = new ByteArrayOutputStream()
+ System.out = new PrintStream(outBytes)
+ System.err = new PrintStream(errBytes)
+ def xmlOutput = new XMLJUnitResultFormatter(output: xmlOut)
+ def plainOutput = new PlainJUnitResultFormatter(output: plainOut)
+ def junitTest = new JUnitTest(test.name)
+ try {
+ plainOutput.startTestSuite(junitTest)
+ xmlOutput.startTestSuite(junitTest)
+ savedOut.println "Running functional test ${test.name}..."
+ def start = System.currentTimeMillis()
+ def runCount = 0
+ def failureCount = 0
+ def errorCount = 0
+
+ for (i in 0..<test.testCount()) {
+ def thisTest = new TestResult()
+ thisTest.addListener(xmlOutput)
+ thisTest.addListener(plainOutput)
+ def t = test.testAt(i)
+ System.out.println "--Output from ${t.name}--"
+ System.err.println "--Output from ${t.name}--"
+
+ callback(test, {
+ savedOut.print " ${t.name}... "
+ event("TestStart", [test, t, thisTest])
+ // Let the test know where it can communicate with the user
+ t.consoleOutput = savedOut
+ test.runTest(t, thisTest)
+ event("TestEnd", [test, t, thisTest])
+ thisTest
+ })
+ runCount += thisTest.runCount()
+ failureCount += thisTest.failureCount()
+ errorCount += thisTest.errorCount()
+
+ if (thisTest.errorCount() > 0 || thisTest.failureCount() > 0) {
+ thisTest.errors().each {result.addError(t, it.thrownException())}
+ thisTest.failures().each {result.addFailure(t, it.thrownException())}
+ }
+ else {savedOut.println " Passed!"}
+ }
+ junitTest.setCounts(runCount, failureCount, errorCount);
+ junitTest.setRunTime(System.currentTimeMillis() - start)
+ } finally {
+ def outString = outBytes.toString()
+ def errString = errBytes.toString()
+ new File("${reportsDir}/FUNCTEST-${test.name}-out.txt").write(outString)
+ new File("${reportsDir}/FUNCTEST-${test.name}-err.txt").write(errString)
+ plainOutput?.setSystemOutput(outString)
+ plainOutput?.setSystemError(errString)
+ plainOutput?.endTestSuite(junitTest)
+ xmlOutput?.setSystemOutput(outString)
+ xmlOutput?.setSystemError(errString)
+ xmlOutput?.endTestSuite(junitTest)
+ }
+ } finally {
+ System.out = savedOut
+ System.err = savedErr
+ }
+
+ }
+ }
+ }
+}
+
+target(doFunctionalTests: "Run Grails' function tests under the test/functional directory") {
+ try {
+ def testFiles = resolveTestResources {"file:${basedir}/test/functional/${it}.groovy"}
+ testFiles.addAll(resolveTestResources {"file:${basedir}/test/functional/${it}.java"})
+ testFiles = testFiles.findAll {it.exists()}
+ if (testFiles.size() == 0) {
+ event("StatusUpdate", ["No tests found in test/functional to execute"])
+ return
+ }
+
+ def suite = new TestSuite()
+ classLoader.rootLoader.addURL(new File("test/functional").toURI().toURL())
+ populateTestSuite(suite, testFiles, classLoader, "test/functional/")
+ if (suite.testCount() > 0) {
+
+ event("TestSuiteStart", ["functional"])
+ int testCases = suite.countTestCases()
+ println "-------------------------------------------------------"
+ println "Running ${testCases} Functional Test${testCases > 1 ? 's' : ''}..."
+
+ def start = new Date()
+ runTests(suite, result) {test, invocation ->
+ invocation()
+ }
+ def end = new Date()
+
+ event("TestSuiteEnd", ["functional", suite])
+ event("StatusUpdate", ["Functional Tests Completed in ${end.time - start.time}ms"])
+ println "-------------------------------------------------------"
+ }
+ }
+ catch (Exception e) {
+ event("StatusFinal", ["Error running functional tests: ${e.toString()}"])
+ e.printStackTrace()
+ }
+}
+
+def resolveTestResources(patternResolver) {
+ def testNames = getTestNames(ftArgs)
+
+ if (!testNames) {
+ testNames = config.grails.testing.patterns ?: ['**/*']
+ }
+
+ def testResources = []
+ testNames.each {
+ def testFiles = resolveResources(patternResolver(it))
+ testResources.addAll(testFiles.findAll {it.exists()})
+ }
+ testResources
+}
+
+def getTestNames(testNames) {
+ // If a list of test class names is provided, split it into ant
+ // file patterns.
+ def nameSuffix = 'Tests'
+ if (config.grails.testing.nameSuffix) {
+ nameSuffix = config.grails.testing.nameSuffix
+ }
+
+ if (testNames) {
+ testNames = testNames.collect {
+ // If the test name includes a package, replace it with the
+ // corresponding file path.
+ if (it.indexOf('.') != -1) {
+ it = it.replace('.' as char, '/' as char)
+ }
+ else {
+ // Allow the test class to be in any package.
+ it = "**/$it"
+ }
+ return "${it}${nameSuffix}"
+ }
+ }
+
+ return testNames
+}
13 scripts/_Events.groovy
@@ -0,0 +1,13 @@
+eventAllTestsStart = {
+ if (getBinding().variables.containsKey("functionalTests")) {
+ functionalTests << "functional"
+ }
+}
+
+eventTestSuiteStart = { String type ->
+ if (type == "functional") {
+ testingBaseURL = argsMap["baseUrl"] ?: "http://localhost:$serverPort$serverContextPath"
+ if (!testingBaseURL.endsWith('/')) testingBaseURL += '/'
+ System.setProperty("grails.functional.test.baseURL", testingBaseURL)
+ }
+}
32 scripts/_Install.groovy
@@ -0,0 +1,32 @@
+//
+// This script is executed by Grails after plugin was installed to project.
+// This script is a Gant script so you can use all special variables provided
+// by Gant (such as 'baseDir' which points on project base dir). You can
+// use 'Ant' to access a global instance of AntBuilder
+//
+// For example you can create directory under project tree:
+// Ant.mkdir(dir:"/Users/marc/Projects/SimpleHttpTest/grails-app/jobs")
+//
+
+includeTargets << grailsScript("_GrailsInit")
+
+ant.mkdir(dir: "test/functional")
+
+// Activate XERCES & XALAN if this is a version of Grails beyond 1.0.x
+if (!grailsVersion.startsWith('1.0')) {
+
+ def clashingJars = ant.fileScanner {
+ fileset(dir: "${functionalTestPluginDir}/lib") {
+ include(name: "*._jar")
+ }
+ }.each {File jar ->
+ moveLib(jar.absolutePath, (jar.absolutePath - '._jar') + '.jar')
+ }
+}
+
+def moveLib(from, to) {
+ //done as a copy and delete due to strange locking problem on windows
+ ant.copy(overwrite: true, verbose: true, file: from,
+ toFile: to)
+ ant.delete(verbose: true, file: from)
+}
30 scripts/_Upgrade.groovy
@@ -0,0 +1,30 @@
+//
+// This script is executed by Grails during application upgrade ('grails upgrade' command).
+// This script is a Gant script so you can use all special variables
+// provided by Gant (such as 'baseDir' which points on project base dir).
+// You can use 'Ant' to access a global instance of AntBuilder
+//
+// For example you can create directory under project tree:
+// Ant.mkdir(dir:"/Users/marc/Projects/SimpleHttpTest/grails-app/jobs")
+//
+
+includeTargets << grailsScript("_GrailsInit")
+
+// Activate XERCES & XALAN if this is a version of Grails beyond 1.0.x
+if (!grailsVersion.startsWith('1.0')) {
+
+ def clashingJars = ant.fileScanner {
+ fileset(dir: "${functionalTestPluginDir}/lib") {
+ include(name: "*._jar")
+ }
+ }.each {File jar ->
+ moveLib(jar.absolutePath, (jar.absolutePath - '._jar') + '.jar')
+ }
+}
+
+def moveLib(from, to) {
+ //done as a copy and delete due to strange locking problem on windows
+ ant.copy(overwrite: true, verbose: true, file: from,
+ toFile: to)
+ ant.delete(verbose: true, file: from)
+}
63 src/groovy/com/grailsrocks/functionaltest/APITestCase.groovy
@@ -0,0 +1,63 @@
+package com.grailsrocks.functionaltest
+
+/**
+ * Test client that uses RESTClient
+ */
+class APITestCase extends TestCaseBase {
+
+ def getRequestConfig() {
+
+ }
+
+ void clientChanged() {
+
+ }
+
+ void request(URL url, String method, Closure setupDSL) {
+
+ }
+
+ Map getRequestHeaders() {
+
+ }
+
+ Map getRequestParameters() {
+
+ }
+
+ int getResponseStatus() {
+
+ }
+
+ String getResponseAsString() {
+
+ }
+
+ def getResponseDOM() {
+
+ }
+
+ String getResponseContentType() {
+
+ }
+
+ String getResponseHeader(String name) {
+
+ }
+
+ Map getResponseHeaders() {
+
+ }
+
+ String getCurrentURL() {
+
+ }
+
+ String getRedirectURL() {
+
+ }
+
+ String followRedirect() {
+
+ }
+}
294 src/groovy/com/grailsrocks/functionaltest/BrowserTestCase.groovy
@@ -0,0 +1,294 @@
+package com.grailsrocks.functionaltest
+
+import junit.framework.AssertionFailedError
+import com.gargoylesoftware.htmlunit.ElementNotFoundException
+import com.gargoylesoftware.htmlunit.html.HtmlForm
+
+import com.grailsrocks.functionaltest.client.BrowserClient
+import com.grailsrocks.functionaltest.client.htmlunit.*
+import com.grailsrocks.functionaltest.client.Client
+
+class BrowserTestCase extends TestCaseBase {
+ void setBrowser(String browser) {
+ client.clientState.browser = browser
+ }
+
+ @Override
+ Client getClient() {
+ def c = super.getClient()
+ if (client instanceof BrowserClient) {
+ return c
+ } else {
+ throw new IllegalArgumentException("Cannot change browser, current client is not a browser")
+ }
+ }
+
+ def getBrowser() {
+ client.clientState.browser
+ }
+
+ void clearCache() {
+ client.cache.clear()
+ }
+
+ boolean getCookiesEnabled() {
+ client.cookieManager.cookiesEnabled
+ }
+
+ void setCookiesEnabled(boolean enabled) {
+ client.cookieManager.cookiesEnabled = enabled
+ }
+
+ boolean getJavaScriptEnabled() {
+ client.javaScriptEnabled
+ }
+
+ void setJavaScriptEnabled(boolean enabled) {
+ client.javaScriptEnabled = enabled
+ }
+
+ void setPopupBlockerEnabled(boolean enabled) {
+ client.popupBlockerEnabled = enabled
+ }
+
+ boolean getPopupBlockerEnabled() {
+ client.popupBlockerEnabled
+ }
+
+ /**
+ * Return the list of cookie objects from HtmlUnit
+ */
+ def getCookies() {
+ client.cookieManager.cookies
+ }
+
+ void back() {
+ if (urlStack.size() < 2) {
+ throw new IllegalStateException('You cannot call back() without first generating at least 2 responses')
+ }
+ urlStack.remove(urlStack[-1])
+ def lastPage = urlStack[-1]
+ while (urlStack.size() > 1 && isRedirectStatus(lastPage.statusCode)) {
+ urlStack.remove(urlStack[-1])
+ lastPage = urlStack[-1]
+ }
+ if (isRedirectStatus(lastPage.statusCode)) {
+ throw new IllegalStateException('Unable to find a non-redirect URL in the history')
+ }
+ def c = client
+ c._page = lastPage.page
+ c.response = lastPage.response
+ }
+
+ def getPage() {
+ assertNotNull "Page must never be null!", clientState._page
+ return clientState._page
+ }
+
+ def byXPath(expr) {
+ try {
+ def results = page.getByXPath(expr.toString())
+ if (results.size() > 1) {
+ return results
+ } else {
+ return results[0]
+ }
+ } catch (ElementNotFoundException e) {
+ return null
+ }
+ }
+
+ def byId(id) {
+ try {
+ return page.getElementById(id.toString())
+ } catch (ElementNotFoundException e) {
+ return null
+ }
+ }
+
+ def byClass(cssClass) {
+ try {
+ def results = page.getByXPath("//*[@class]").findAll { element ->
+ def attribute = element.attributes?.getNamedItem('class')
+
+ return attribute?.value?.split().any { it == cssClass }
+ }
+ if (results.size() > 1) {
+ return results
+ } else {
+ return results[0]
+ }
+ } catch (ElementNotFoundException e) {
+ println "No element found for class $cssClass"
+ return null
+ }
+ }
+
+ def byName(name) {
+ def elems = page.getElementsByName(name.toString())
+ if (elems.size() > 1) {
+ return elems // return the list
+ } else if (!elems) {
+ return null
+ }
+ return elems[0] // return the single element
+ }
+
+
+ /**
+ * Get the first HTML form on the page, if any, and run the closure on it.
+ * Useful when form has no name.
+ * @param closure Optional closure containing code to set/get form fields
+ * @return the HtmlUnit form object
+ */
+ def form(Closure closure) {
+ def f = page.forms?.getAt(0)
+ if (!f) {
+ throw new IllegalArgumentException("There are no forms in the current response")
+ }
+ processForm(f, closure)
+ }
+
+ /**
+ * Get the HTML form by ID or name, with an optional closure to set field values
+ * on the form
+ * @param closure Optional closure containing code to set/get form fields
+ * @return the HtmlUnit form object
+ */
+ def form(name, Closure closure) {
+ def f = byId(name)
+ if (!f) {
+ f = page.getFormByName(name)
+ }
+ if (!f) {
+ throw new IllegalArgumentException("There is no form with id/name [$name]")
+ }
+ processForm(f, closure)
+ }
+
+ /**
+ * Check the form is valid, and then if necessary run the closure delegating to the form
+ * wrapper to implement all our magic
+ * @return the HtmlUnit form object
+ */
+ protected processForm( form, Closure closure = null) {
+ if (!(form instanceof HtmlForm)) {
+ throw new IllegalArgumentException("Element of id/name $name is not an HTML form")
+ }
+ if (closure) {
+ closure.delegate = new FormWrapper(this, form)
+ closure.resolveStrategy = Closure.DELEGATE_FIRST
+ closure.call()
+ }
+ return form
+ }
+
+ def getResponse() {
+ client.response
+ }
+
+ protected makeRequest(url, method, paramSetupClosure) {
+ System.out.println("\n\n${'>'*20} Making request to $url using method $method ${'>'*20}")
+
+ def reqURL = makeRequestURL(url)
+
+ System.out.println("Initializing web request settings for $reqURL")
+ client.request(reqURL, method, paramSetupClosure)
+
+ // Now let's see if it was a redirect
+ handleRedirects()
+ }
+
+ /**
+ * Experimental code in HtmlUnit is called here. May change in future, YMMV
+ */
+ void waitForScripts(timeout) {
+ consoleOutput.println "Waiting for JavaScripts, timeout $timeout"
+ def n = client.waitForBackgroundJavaScript(timeout)
+ consoleOutput.println "Finished waiting for JavaScripts, pending tasks: $n"
+ }
+
+ /**
+ * Clicks an element, finding the link/button by first the id attribute, or failing that the clickable text of the link.
+ */
+ def click(anchor) {
+ def a = byId(anchor)
+ try {
+ if (!a) a = page.getFirstAnchorByText(anchor)
+ } catch (ElementNotFoundException e) {
+ }
+ if (!a) {
+ throw new IllegalArgumentException("No such element for id or anchor text [${anchor}]")
+ }
+ System.out.println "Clicked [$anchor] which resolved to a [${a.class}]"
+ a.click()
+ // page loaded, events are triggered if necessary
+
+ // Now let's see if it was a redirect
+ handleRedirects()
+ }
+
+ void assertTitleContains(String expected) {
+ boolean con = stripWS(page.titleText.toLowerCase()).contains(stripWS(expected?.toLowerCase()))
+ assertTrue "Expected title of response to loosely contain [${expected}] but was [${page.titleText}]".toString(), con
+ }
+
+ void assertTitle(String expected) {
+ assertTrue "Expected title of response to loosely match [${expected}] but was [${page.titleText}]".toString(),
+ stripWS(expected?.toLowerCase()) == stripWS(page.titleText.toLowerCase())
+ }
+
+ void assertMetaContains(String name, String expected) {
+ def node = page.getElementsByTagName('meta')?.iterator().find { it.attributes?.getNamedItem('name')?.nodeValue == name }
+ if (!node) throw new AssertionFailedError("No meta tag found with name $name")
+ def nodeValue = node.attributes.getNamedItem('content').nodeValue
+ assertTrue stripWS(nodeValue.toLowerCase()).contains(stripWS(expected?.toLowerCase()))
+ }
+
+ void assertMeta(String name) {
+ def node = page.getElementsByTagName('meta')?.iterator().find { it.attributes?.getNamedItem('name')?.nodeValue == name }
+ if (!node) throw new AssertionFailedError("No meta tag found with name $name")
+ }
+
+ void assertCookieExists(String cookieName) {
+ if (!client.cookieManager.getCookie(cookieName)) {
+ def cookieList = (client.cookieManager.cookies.collect { it.name }).join(',')
+ throw new AssertionFailedError("There is no cookie with name $cookieName, the cookies that exist are: $cookieList")
+ }
+ }
+
+ void assertCookieExistsInDomain(String cookieName, String domain) {
+ def domainCookies = client.cookieManager.getCookies(cookieName)
+ if (!domainCookies) {
+ def cookieList = (client.cookieManager.cookies.collect { it.name }).join(',')
+ throw new AssertionFailedError("There are no cookies for domain $domain")
+ }
+ assertTrue domainCookies.find { it.name == cookieName }
+ }
+
+ void assertCookieContains(String cookieName, String value) {
+ assertCookieExists(cookieName)
+ def v = client.cookieManager.getCookie(cookieName)
+ assertTrue stripWS(v.toLowerCase()).contains(stripWS(value?.toLowerCase()))
+ }
+
+ void assertCookieContainsStrict(String cookieName, String value) {
+ assertCookieExists(cookieName)
+ def v = client.cookieManager.getCookie(cookieName)
+ assertTrue v.contains(value)
+ }
+
+ void assertElementTextContains(String id, String expected) {
+ def node = byId(id)
+ if (!node) throw new IllegalArgumentException("No element found with id $id")
+ assertTrue stripWS(node.textContent.toLowerCase()).contains(stripWS(expected?.toLowerCase()))
+ }
+
+ void assertElementTextContainsStrict(String id, String expected) {
+ def node = byId(id)
+ if (!node) throw new IllegalArgumentException("No element found with id $id")
+ assertTrue node.textContent.contains(expected)
+ }
+
+
+}
61 src/groovy/com/grailsrocks/functionaltest/FunctionalTestException.groovy
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2008-2009 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * The original code of this plugin was developed by Historic Futures Ltd.
+ * (www.historicfutures.com) and open sourced.
+ */
+
+package com.grailsrocks.functionaltest
+
+import grails.util.GrailsUtil
+
+class FunctionalTestException extends junit.framework.AssertionFailedError {
+ def urlStack
+ def hackedCause
+
+ FunctionalTestException(TestCaseBase test, Throwable cause) {
+ super(cause.message)
+ this.hackedCause = GrailsUtil.sanitize(cause)
+ this.urlStack = test.urlStack
+ }
+
+ void dumpURLStack(PrintWriter pw = null) {
+ if (!pw) pw = System.out
+ pw.println "URL Stack that resulted in ${hackedCause ?: 'failure'}"
+ pw.println "---------------"
+ urlStack?.reverseEach {
+ pw.println "${it.url} (${it.source})"
+ }
+ pw.println "---------------"
+
+ if (hackedCause) {
+ hackedCause.printStackTrace(pw)
+ } else {
+ super.printStackTrace(pw)
+ }
+ }
+
+ void printStackTrace() {
+ dumpURLStack()
+ }
+
+ void printStackTrace(PrintStream s) {
+ dumpURLStack(new PrintWriter(s))
+ }
+
+ void printStackTrace(PrintWriter s) {
+ dumpURLStack(s)
+ }
+}
5 src/groovy/com/grailsrocks/functionaltest/HybridTestCase.groovy
@@ -0,0 +1,5 @@
+package com.grailsrocks.functionaltest
+
+class HybridTestCase extends TestCaseBase {
+
+}
438 src/groovy/com/grailsrocks/functionaltest/TestCaseBase.groovy
@@ -0,0 +1,438 @@
+
+/*
+ * Copyright 2008-2009 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * The original code of this plugin was developed by Historic Futures Ltd.
+ * (www.historicfutures.com) and open sourced.
+ */
+package com.grailsrocks.functionaltest
+
+import org.codehaus.groovy.runtime.InvokerHelper
+import org.codehaus.groovy.runtime.StackTraceUtils
+
+import java.net.URLEncoder
+
+import grails.util.GrailsUtil
+import grails.converters.JSON
+import grails.converters.XML
+import grails.util.Environment
+
+import com.grailsrocks.functionaltest.util.HTTPUtils
+
+import junit.framework.AssertionFailedError
+
+import com.grailsrocks.functionaltest.client.*
+
+class TestCaseBase extends GroovyTestCase implements GroovyInterceptable, ClientListener {
+
+ static MONKEYING_DONE
+
+ static BORING_STACK_ITEMS = ['FunctionalTests', 'functionaltestplugin.', 'gant.']
+
+ static {
+ StackTraceUtils.addClassTest { className ->
+ if (BORING_STACK_ITEMS.find { item ->
+ return className.startsWith(item)
+ }) {
+ return false
+ } else {
+ return null
+ }
+ }
+ }
+
+ def baseURL // populated via test script
+ def urlStack = new ArrayList()
+ boolean autoFollowRedirects = true
+ def consoleOutput
+ protected stashedClients = [:]
+ String currentClientId
+ Client currentClient
+
+ def contentTypeForJSON = 'application/json'
+ def contentTypeForXML = 'text/xml'
+
+ protected void setUp() {
+ super.setUp()
+
+ baseURL = System.getProperty('grails.functional.test.baseURL')
+
+ if (!MONKEYING_DONE) {
+ BrowserClient.initVirtualMethods()
+ MONKEYING_DONE = true
+ }
+ }
+
+ void switchClient(Class<Client> type = BrowserClient) {
+ currentClient = type.newInstance(this)
+ }
+
+ Client getClient() {
+ if (!currentClient) {
+ switchClient()
+ clientChanged()
+ }
+ return currentClient
+ }
+
+ protected void clientChanged() {
+ client.clientChanged()
+ }
+
+ boolean getRedirectEnabled() {
+ autoFollowRedirects
+ }
+
+ void setRedirectEnabled(boolean enabled) {
+ autoFollowRedirects = enabled
+ }
+
+ /**
+ * Call to switch between multiple client browsers, simulating different users
+ */
+ void client(String id) {
+ //System.out.println "Stashed clients: ${stashedClients.dump()}"
+ if (id != currentClientId) {
+ // If we were currently unnamed but have some state, save our state with name ""
+ stashClient(currentClientId ?: '')
+ // restore client if it is known, else
+ unstashClient(id)
+ currentClientId = id
+ }
+ }
+
+ protected void stashClient(id) {
+ stashedClients[id] = client
+ currentClient = null
+ }
+
+ protected void unstashClient(id) {
+ // Clear them in case this is a new unknown client name
+ def c = stashedClients[id]
+ if (c) {
+ client = c
+ }
+
+ clientChanged()
+ }
+
+ protected void tearDown() {
+ currentClient = null
+ super.tearDown()
+ }
+
+ def invokeMethod(String name, args) {
+ def t = this
+ if ((name.startsWith('assert') ||
+ name.startsWith('shouldFail') ||
+ name.startsWith('fail')) ) {
+ try {
+ return InvokerHelper.getMetaClass(this).invokeMethod(this,name,args)
+ } catch (Throwable e) {
+ // Protect against nested func test exceptions when one assertX calls another
+ if (!(e instanceof FunctionalTestException)) {
+ reportFailure(e.message ?: e.toString())
+ throw sanitize(new FunctionalTestException(this, e))
+ } else throw e
+ }
+ } else {
+ try {
+ return InvokerHelper.getMetaClass(this).invokeMethod(this,name,args)
+ } catch (Throwable e) {
+ if (!(e instanceof FunctionalTestException)) {
+ reportFailure(e.toString())
+ throw sanitize(new FunctionalTestException(this, e))
+ } else throw e
+ }
+ }
+ }
+
+ protected sanitize(Throwable t) {
+ StackTraceUtils.deepSanitize(t)
+ }
+
+ protected void reportFailure(msg) {
+ // Write out to user console
+ if (!msg) {
+ msg = "[no message available]"
+ }
+ consoleOutput.println "\nFailed: ${msg}"
+ // Write to output capture file
+ //System.out.println "\nFailed: ${msg}"
+ if (urlStack) {
+ consoleOutput.println "URL: ${urlStack[-1].url}"
+ }
+ consoleOutput.println ""
+ }
+
+ void followRedirect() {
+ if (redirectEnabled) {
+ throw new IllegalStateException("Trying to followRedirect() but you have not disabled automatic redirects so I can't! Do redirectEnabled = false first, then call followRedirect() after asserting.")
+ }
+ doFollowRedirect()
+ }
+
+ protected void doFollowRedirect() {
+ def u = client.followRedirect()
+ if (u) {
+ System.out.println("Followed redirect to $u")
+ } else {
+ throw new IllegalStateException('The last response was not a redirect, so cannot followRedirect')
+ }
+ }
+
+ def forceTrailingSlash(url) {
+ if (!url.endsWith('/')) {
+ url += '/'
+ }
+ return url
+ }
+
+ URL makeRequestURL(url) {
+ def reqURL
+ url = url.toString()
+ if ((url.indexOf('://') >= 0) || url.startsWith('file:')) {
+ reqURL = url.toURL()
+ } else {
+ def base
+ if (url.startsWith('/')) {
+ base = forceTrailingSlash(baseURL)
+ url -= '/'
+ } else {
+ base = client.currentURL ? client.currentURL : baseURL
+ }
+ reqURL = new URL(new URL(base), url.toString())
+ }
+ return reqURL
+ }
+
+ protected handleRedirects() {
+ if (HTTPUtils.isRedirectStatus(client.responseStatus)) {
+ if (autoFollowRedirects) {
+ this.doFollowRedirect()
+ }
+ }
+ }
+
+ def get(url, Closure paramSetup = null) {
+ client.request(new URL(url), 'GET', paramSetup)
+ }
+
+ def post(url, Closure paramSetup = null) {
+ client.request(new URL(url), 'POST', paramSetup)
+ }
+
+ def delete(url, Closure paramSetup = null) {
+ client.request(new URL(url), 'DELETE', paramSetup)
+ }
+
+ def put(url, Closure paramSetup = null) {
+ client.request(new URL(url), 'PUT', paramSetup)
+ }
+
+ void assertContentDoesNotContain(String expected) {
+ assertFalse "Expected content to not loosely contain [$expected] but it did".toString(), stripWS(client.responseAsString?.toLowerCase()).contains(stripWS(expected?.toLowerCase()))
+ }
+
+ void assertContentContains(String expected) {
+ assertTrue "Expected content to loosely contain [$expected] but it didn't".toString(), stripWS(client.responseAsString?.toLowerCase()).contains(stripWS(expected?.toLowerCase()))
+ }
+
+ void assertContentContainsStrict(String expected) {
+ assertTrue "Expected content to strictly contain [$expected] but it didn't".toString(), client.responseAsString?.contains(expected)
+ }
+
+ void assertContent(String expected) {
+ assertEquals stripWS(expected?.toLowerCase()), stripWS(client.responseAsString?.toLowerCase())
+ }
+
+ void assertContentStrict(String expected) {
+ assertEquals expected, client.responseAsString
+ }
+
+ void assertStatus(int status) {
+ def msg = "Expected HTTP status [$status] but was [${client.responseStatus}]"
+ if (HTTPUtils.isRedirectStatus(client.responseStatus)) msg += " (received a redirect to ${client.redirectUrl})"
+ assertTrue msg.toString(), status == client.responseStatus
+ }
+
+ void assertRedirectUrl(String expected) {
+ if (redirectEnabled) {
+ throw new IllegalStateException("Asserting redirect, but you have not disabled redirects. Do redirectEnabled = false first, then call followRedirect() after asserting.")
+ }
+ if (!HTTPUtils.isRedirectStatus(response.statusCode)) {
+ throw new AssertionFailedError("Asserting redirect, but response was not a valid redirect status code")
+ }
+ assertEquals expected, client.redirectRL
+ }
+
+ void assertRedirectUrlContains(String expected) {
+ if (redirectEnabled) {
+ throw new IllegalStateException("Asserting redirect, but you have not disabled redirects. Do redirectEnabled = false first, then call followRedirect() after asserting.")
+ }
+ if (!HTTPUtils.isRedirectStatus(client.responseStatus)) {
+ throw new AssertionFailedError("Asserting redirect, but response was not a valid redirect status code")
+ }
+ if (!client.redirectURL?.contains(expected)) {
+ throw new AssertionFailedError("Asserting redirect contains [$expected], but it didn't. Was: [${client.redirectUrl}]")
+ }
+ }
+
+ void assertContentTypeStrict(String expected) {
+ assertEquals expected, client.responseContentType
+ }
+
+ void assertContentType(String expected) {
+ assertTrue stripWS(client.responseContentType.toLowerCase()).startsWith(stripWS(expected?.toLowerCase()))
+ }
+
+ void assertHeader(String header, String expected) {
+ assertEquals stripWS(expected.toLowerCase()), stripWS(client.getResponseHeader(header)?.toLowerCase())
+ }
+
+ void assertHeaderStrict(String header, String expected) {
+ assertEquals expected, client.getResponseHeader(header)
+ }
+
+ void assertHeaderContains(String header, String expected) {
+ assertTrue stripWS(client.getResponseHeader(header)?.toLowerCase()).contains(stripWS(expected?.toLowerCase()))
+ }
+
+ void assertHeaderContainsStrict(String header, String expected) {
+ assertTrue client.getResponseHeader(header)?.contains(expected)
+ }
+
+ /**
+ * Make sure a domain object exists in the target system
+ * Relies on access to our testing controller
+ * @todo don't use get for this! it screws up url stack
+ */
+ void assertDomainObjectExists( Map args) {
+ get('/functionaltesting/objectExists') {
+ className = args.className
+ findField = args.findBy
+ findValue = args.value
+ }
+
+ assertStatus 200
+ }
+
+ /**
+ * Make sure a domain object exists in the target system
+ * Relies on access to our testing controller
+ * @todo don't use get for this! it screws up url stack
+ */
+ grails.converters.JSON getDomainObject( Map args) {
+ get('/functionaltesting/findObject') {
+ className = args.className
+ findField = args.findBy
+ findValue = args.value
+ }
+
+ assertStatus 200
+
+ return response.contentAsString.decodeJSON()
+ }
+
+ grails.converters.JSON getJSON() {
+ assertContentType contentTypeForJSON
+ client.responseAsString.decodeJSON()
+ }
+
+ grails.converters.XML getXML() {
+ assertContentType contentTypeForXML
+ client.responseAsString.decodeXML()
+ }
+
+ /**
+ * Load a fixture into the app using the fixtures plugin
+ */
+ void fixture(String name) {
+ def result = testDataRequest('fixture', [name:name])
+ if (result.error) {
+ throw new UnsupportedOperationException("Cannot load fixture [$name], the application replied with: [{}$result.error}]")
+ }
+ }
+
+ def URLEncode(x) {
+ URLEncoder.encode(x.toString(), 'utf-8')
+ }
+
+ /**
+ * Send a request to the test data controller that this plugin injects into non-production apps
+ * @param action The name of the controller action to execute eg findObject
+ * @param params The query args
+ * @return The JSON response object
+ */
+ def testDataRequest(action, params ) {
+ def args = (params.collect { k, v -> k+'='+URLEncode(v) }).join('&')
+ grails.converters.JSON.parse(makeRequestURL("/functionaltesting/$action?$args").text)
+ }
+
+ /**
+ * Assert that the mock mail system has a mail matching the specified args
+ */
+ void assertEmailSent( Map args) {
+ try {
+ def result = makeRequestURL('/greenmail/list').text
+ result = result?.toLowerCase()
+ if (result.indexOf(args.to.toLowerCase()) < 0 || result.indexOf(args.subject?.toLowerCase()) < 0) {
+ throw new AssertionFailedError("There was no email to an address containing [$args.to] with subject containing [$args.subject] found - greenmail had the following: ${result}")
+ }
+ } catch (FileNotFoundException fnfe) {
+ throw new UnsupportedOperationException("Cannot interact with mocked mails, the application does not have the 'greenmail' plugin installed or url mapping for /greenmail/\$action? is missing")
+ }
+ }
+
+ /**
+ * Clear the greenmail email queue.
+ * @todo should do this after every test run from the test runner
+ */
+ void clearEmails() {
+ def result = makeRequestURL('/greenmail/clear').text
+ }
+
+ /**
+ * Extract the first match from the contentAsString using the supplied regex
+ */
+ String extract(regexPattern) {
+ def m = response.contentAsString =~ regexPattern
+ return m ? m[0][1] : null
+ }
+
+/*
+ void assertXML(String xpathExpr, expectedValue) {
+
+ }
+*/
+ String stripWS(String s) {
+ def r = new StringBuffer()
+ s?.each { c ->
+ if (!Character.isWhitespace(c.toCharacter())) r << c
+ }
+ r.toString()
+ }
+
+ void contentChanged(ContentChangedEvent event) {
+ // params.method ? params.method.toString()+' ' :
+ consoleOutput.print '#'
+ while(urlStack.size() >= 50){ // only keep a window of the last 50 urls
+ urlStack.remove(0)
+ }
+ urlStack << event
+ }
+}
+
+
0  src/groovy/com/grailsrocks/functionaltest/client/APIClient.groovy
No changes.
305 src/groovy/com/grailsrocks/functionaltest/client/BrowserClient.groovy
@@ -0,0 +1,305 @@
+package com.grailsrocks.functionaltest.client
+
+import com.gargoylesoftware.htmlunit.WebRequestSettings
+import com.gargoylesoftware.htmlunit.util.NameValuePair
+import com.gargoylesoftware.htmlunit.WebRequestSettings
+import com.gargoylesoftware.htmlunit.WebClient
+import com.gargoylesoftware.htmlunit.WebWindowEvent
+import com.gargoylesoftware.htmlunit.WebWindowListener
+import com.gargoylesoftware.htmlunit.html.HtmlPage
+import com.gargoylesoftware.htmlunit.html.HtmlInput
+import com.gargoylesoftware.htmlunit.html.HtmlForm
+import com.gargoylesoftware.htmlunit.html.HtmlSelect
+import com.gargoylesoftware.htmlunit.html.HtmlTextArea
+import com.gargoylesoftware.htmlunit.html.HtmlRadioButtonInput
+import com.gargoylesoftware.htmlunit.html.HtmlCheckBoxInput
+import com.gargoylesoftware.htmlunit.HttpMethod
+import com.gargoylesoftware.htmlunit.ElementNotFoundException
+import com.gargoylesoftware.htmlunit.BrowserVersion
+import com.gargoylesoftware.htmlunit.html.HtmlAttributeChangeListener
+import com.gargoylesoftware.htmlunit.html.DomChangeListener
+import com.gargoylesoftware.htmlunit.html.HtmlAttributeChangeEvent
+import com.gargoylesoftware.htmlunit.html.DomChangeEvent
+import com.gargoylesoftware.htmlunit.WebWindow
+import com.gargoylesoftware.htmlunit.WebResponse
+import com.gargoylesoftware.htmlunit.Page
+
+import com.grailsrocks.functionaltest.dsl.RequestBuilder
+import com.grailsrocks.functionaltest.client.htmlunit.*
+
+import com.grailsrocks.functionaltest.util.HTTPUtils
+
+class BrowserClient implements Client, WebWindowListener, HtmlAttributeChangeListener, DomChangeListener {
+ WebRequestSettings settings
+
+ def interceptingPageCreator = new InterceptingPageCreator(this)
+
+ def _client
+ def mainWindow
+ def browser
+ def response
+ def redirectUrl
+ def _page
+
+ ClientListener listener
+
+ BrowserClient(ClientListener listener) {
+ this.listener = listener
+ println "Creating new client"
+ _client = browser ? new WebClient(BrowserVersion[browser]) : new WebClient()
+ println "Created new client ${_client }"
+ _client.addWebWindowListener(this)
+ _client.redirectEnabled = false // We're going to handle this thanks very much
+ _client.popupBlockerEnabled = true
+ _client.javaScriptEnabled = true
+ _client.throwExceptionOnFailingStatusCode = false
+ _client.pageCreator = interceptingPageCreator
+ }
+
+ boolean getJavaScriptEnabled() {
+ _client.javaScriptEnabled
+ }
+
+ void setJavaScriptEnabled(boolean enabled) {
+ _client.javaScriptEnabled = enabled
+ }
+
+ void setPopupBlockerEnabled(boolean enabled) {
+ _client.popupBlockerEnabled = enabled
+ }
+
+ boolean getPopupBlockerEnabled() {
+ _client.popupBlockerEnabled
+ }
+
+ /**
+ * Set up our magic on the HtmlUnit classes
+ */
+ static initVirtualMethods() {
+ HtmlPage.metaClass.getForms = { ->
+ new FormsWrapper(delegate)
+ }
+ HtmlForm.metaClass.getFields = { ->
+ new FieldsWrapper(delegate)
+ }
+ HtmlForm.metaClass.getSelects = { ->
+ new SelectsWrapper(delegate)
+ }
+ HtmlForm.metaClass.getRadioButtons = { ->
+ new RadioButtonsWrapper(delegate)
+ }
+ HtmlInput.metaClass.setValue = { value ->
+ System.out.println("Setting value to [$value] on field [name:${delegate.nameAttribute} id:${delegate.id}] of form [${delegate.enclosingForm?.nameAttribute}]")
+ delegate.valueAttribute = value
+ }
+ HtmlInput.metaClass.getValue = { ->
+ return delegate.valueAttribute
+ }
+ HtmlTextArea.metaClass.setValue = { value ->
+ System.out.println("Setting value to [$value] on text area [name:${delegate.nameAttribute} id:${delegate.id}] of form [${delegate.enclosingForm?.nameAttribute}]")
+ delegate.text = value
+ }
+ HtmlTextArea.metaClass.getValue = { ->
+ return delegate.text
+ }
+ HtmlSelect.metaClass.select = { value ->
+ System.out.println("Selecting option [$value] on select field [name:${delegate.nameAttribute} id:${delegate.id}] of form [${delegate.enclosingForm?.nameAttribute}]")
+ delegate.setSelectedAttribute(value?.toString(), true)
+ }
+ HtmlSelect.metaClass.deselect = { value ->
+ delegate.setSelectedAttribute(value?.toString(), false)
+ }
+ HtmlSelect.metaClass.getSelected = { ->
+ return delegate.getSelectedOptions()?.collect { it.valueAttribute }
+ }
+ }
+
+ void clientChanged() {
+ mainWindow = _client?.currentWindow
+ }
+
+ def getRequestConfig() {
+ settings
+ }
+
+ int getResponseStatus() {
+ response.statusCode
+ }
+
+ String getRedirectURL() {
+ response.redirectUrl
+ }
+
+ String getResponseContentType() {
+ response.contentType
+ }
+
+
+ void nodeAdded(DomChangeEvent event) {
+ System.out.println "Added DOM node [${nodeToString(event.changedNode)}] to parent [${nodeToString(event.parentNode)}]"
+ }
+
+ void nodeDeleted(DomChangeEvent event) {
+ System.out.println "Removed DOM node [${nodeToString(event.changedNode)}] from parent [${nodeToString(event.parentNode)}]"
+ }
+
+ void attributeAdded(HtmlAttributeChangeEvent event) {
+ def tag = event.htmlElement.tagName
+ def name = event.htmlElement.attributes.getNamedItem('name')
+ def id = event.htmlElement.attributes.getNamedItem('id')
+ System.out.println "Added attribute ${event.name} with value ${event.value} to tag [${tag}] (id: $id / name: $name)"
+ }
+
+ void attributeRemoved(HtmlAttributeChangeEvent event) {
+ def tag = event.htmlElement.tagName
+ def name = event.htmlElement.attributes.getNamedItem('name')
+ def id = event.htmlElement.attributes.getNamedItem('id')
+ System.out.println "Removed attribute ${event.name} from tag [${tag}] (id: $id / name: $name)"
+ }
+
+ void attributeReplaced(HtmlAttributeChangeEvent event) {
+ def tag = event.htmlElement.tagName
+ def name = event.htmlElement.attributes.getNamedItem('name')
+ def id = event.htmlElement.attributes.getNamedItem('id')
+ System.out.println "Changed attribute ${event.name} to ${event.value} on tag [${tag}] (id: $id / name: $name)"
+ }
+
+ void webWindowClosed(WebWindowEvent event) {
+
+ }
+
+ void webWindowContentChanged(WebWindowEvent event) {
+ System.out.println "Content of web window [${event.webWindow}] changed"
+ if (event.webWindow == mainWindow) {
+ _page = event.newPage
+ def response = _page.webResponse
+ newResponseReceived(response)
+ listener.contentChanged( new ContentChangedEvent(
+ url: response.requestSettings.url,
+ method: response.requestSettings.httpMethod,
+ eventSource: 'webWindowContentChange event',
+ statusCode: response.statusCode) )
+ } else {
+ System.out.println "New content of web window [${event.webWindow}] was not for main window, ignoring"
+ }
+ }
+
+ void webWindowOpened(WebWindowEvent event) {
+ // @todo we need to think how to handle multiple windows
+ }
+
+ protected newResponseReceived(response) {
+ System.out.println("${'<'*20} Received response from ${response.requestMethod} ${response.requestUrl} ${'<'*20}")
+ if (HTTPUtils.isRedirectStatus(response.statusCode)) {
+ redirectUrl = response.getResponseHeaderValue('Location')
+ System.out.println("Response was a redirect to ${redirectUrl} ${'<'*20}")
+ } else {
+ redirectUrl = null
+ }
+ dumpResponseHeaders(response)
+ System.out.println("Content")
+ System.out.println('='*40)
+ System.out.println(response.contentAsString)
+ System.out.println('='*40)
+ System.out.println('')
+ response = response
+ }
+
+ String getCurrentURL() {
+ response?.requestSettings?.url
+ }
+
+ String followRedirect() {
+ if (redirectUrl) {
+ get(u)
+ return u
+ } else {
+ return null
+ }
+ }
+
+ void request(URL url, String method, Closure paramSetupClosure) {
+ settings = new WebRequestSettings(url)
+ settings.httpMethod = HttpMethod.valueOf(method)
+
+ if (paramSetupClosure) {
+ def wrapper = new RequestBuilder(settings)
+ paramSetupClosure.delegate = wrapper
+ paramSetupClosure.call()
+
+ wrapper.@headers.each { entry ->
+ settings.addAdditionalHeader(entry.key, entry.value.toString())
+ }
+
+ if (wrapper.@reqParameters) {
+ def params = []
+ wrapper.@reqParameters.each { pair ->
+ params << new NameValuePair(pair[0], pair[1].toString())
+ }
+ settings.requestParameters = params
+ }
+ }
+
+ dumpRequestInfo(settings)
+
+ response = _client.loadWebResponse(settings)
+ _page = _client.loadWebResponseInto(response, mainWindow)
+
+ // By this time the events will have been triggered
+ }
+
+ Map getRequestHeaders() {
+ reqSettings?.additionalHeaders
+ }
+
+ Map getRequestParameters() {
+ reqSettings?.requestParameters
+ }
+
+ String getResponseAsString() {
+ response.contentAsString
+ }
+
+ def getResponseDOM() {
+
+ }
+
+ String getResponseHeader(String name) {
+ response.getResponseHeaderValue(name)
+ }
+
+ Map getResponseHeaders() {
+ response.responseHeaders
+ }
+
+ protected dumpRequestInfo(reqSettings) {
+ System.out.println("Request parameters:")
+ System.out.println('='*40)
+ reqSettings?.requestParameters?.each {
+ System.out.println( "${it.name}: ${it.value}")
+ }
+ System.out.println('='*40)
+ System.out.println("Request headers:")
+ System.out.println('='*40)
+ reqSettings?.additionalHeaders?.each {Map.Entry it ->
+ System.out.println("${it.key}: ${it.value}")
+ }
+ System.out.println('='*40)
+ }
+
+ protected dumpResponseHeaders(response) {
+ System.out.println("Response was ${response.statusCode} '${response.statusMessage}', headers:")
+ System.out.println('='*40)
+ response?.responseHeaders?.each {
+ System.out.println( "${it.name}: ${it.value}")
+ }
+ System.out.println('='*40)
+ }
+
+ String nodeToString(def n) {
+ "[${n?.nodeName}] with value [${n?.nodeValue}] and "+
+ "id [${n?.attributes?.getNamedItem('id')?.nodeValue}], name [${n?.attributes?.getNamedItem('name')?.nodeValue}]"
+ }
+
+
+}
21 src/groovy/com/grailsrocks/functionaltest/client/Client.groovy
@@ -0,0 +1,21 @@
+package com.grailsrocks.functionaltest.client
+
+interface Client {
+
+ def getRequestConfig()
+ void clientChanged()
+ void request(URL url, String method, Closure setupDSL)
+
+ Map getRequestHeaders()
+ Map getRequestParameters()
+ int getResponseStatus()
+ String getResponseAsString()
+ def getResponseDOM()
+ String getResponseContentType()
+ String getResponseHeader(String name)
+ Map getResponseHeaders()
+ String getCurrentURL()
+
+ String getRedirectURL()
+ String followRedirect()
+}
5 src/groovy/com/grailsrocks/functionaltest/client/ClientListener.groovy
@@ -0,0 +1,5 @@
+package com.grailsrocks.functionaltest.client
+
+interface ClientListener {
+ void contentChanged(ContentChangedEvent event)
+}
9 src/groovy/com/grailsrocks/functionaltest/client/ContentChangedEvent.groovy
@@ -0,0 +1,9 @@
+package com.grailsrocks.functionaltest.client
+
+class ContentChangedEvent {
+ String url
+ String method
+ String eventSource
+ int statusCode
+
+}
25 src/groovy/com/grailsrocks/functionaltest/client/InterceptingPageCreator.groovy
@@ -0,0 +1,25 @@
+package com.grailsrocks.functionaltest.client
+
+import com.gargoylesoftware.htmlunit.DefaultPageCreator
+import com.gargoylesoftware.htmlunit.WebWindow
+import com.gargoylesoftware.htmlunit.WebResponse
+import com.gargoylesoftware.htmlunit.html.HtmlPage
+import com.gargoylesoftware.htmlunit.Page
+
+class InterceptingPageCreator extends DefaultPageCreator {
+ def client
+
+ InterceptingPageCreator(BrowserClient client) {
+ this.client = client
+ }
+
+ Page createPage(WebResponse webResponse, WebWindow webWindow) {
+ def p = super.createPage(webResponse,webWindow)
+ if (p instanceof HtmlPage) {
+ p.addDomChangeListener(client)
+ p.addHtmlAttributeChangeListener(client)
+ }
+ return p
+ }
+}
+
42 src/groovy/com/grailsrocks/functionaltest/client/htmlunit/FieldsWrapper.groovy
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2008-2009 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * The original code of this plugin was developed by Historic Futures Ltd.
+ * (www.historicfutures.com) and open sourced.
+ */
+
+package com.grailsrocks.functionaltest.client.htmlunit
+
+import com.gargoylesoftware.htmlunit.html.HtmlForm
+import junit.framework.Assert