├── docs ├── __init__.py ├── source │ ├── intro │ │ ├── index.rst │ │ ├── install.rst │ │ └── tutorial.rst │ ├── index.rst │ ├── lxml.rst │ ├── browsers.rst │ ├── ini.rst │ ├── Makefile │ └── conf.py ├── examples │ ├── templates │ │ ├── results.html │ │ └── index.html │ ├── alfajor.ini │ ├── test_simple.py │ ├── __init__.py │ └── webapp.py └── licenses │ ├── jquery.txt │ ├── lxml.txt │ ├── flatland.txt │ └── werkzeug.txt ├── tests ├── browser │ ├── images │ │ └── bread.jpg │ ├── templates │ │ ├── index.html │ │ ├── assign_cookie.html │ │ ├── ajax.html │ │ ├── assign_cookies.html │ │ ├── seq_d.html │ │ ├── seq_a.html │ │ ├── seq_b.html │ │ ├── seq_c.html │ │ ├── form_textareas.html │ │ ├── dom.html │ │ ├── form_multipart.html │ │ ├── form_radios.html │ │ ├── form_select.html │ │ ├── form_checkboxes.html │ │ ├── form_methods.html │ │ ├── waitfor.html │ │ ├── form_submit.html │ │ └── form_fill.html │ ├── alfajor.ini │ ├── __init__.py │ ├── test_dom.py │ ├── webapp.py │ ├── test_browser.py │ └── test_forms.py ├── client │ ├── alfajor.ini │ ├── __init__.py │ ├── test_basic.py │ └── webapp.py ├── __init__.py └── test_management.py ├── setup.cfg ├── MANIFEST.in ├── CHANGES ├── alfajor ├── browsers │ ├── __init__.py │ ├── zero.py │ ├── network.py │ ├── managers.py │ ├── _waitexpr.py │ ├── wsgi.py │ ├── selenium.py │ └── _lxml.py ├── runners │ ├── __init__.py │ └── nose.py ├── __init__.py ├── _compat.py ├── _config.py ├── utilities.py ├── _management.py └── apiclient.py ├── .gitignore ├── .hgignore ├── AUTHORS ├── scripts ├── alfajor-invoke └── ntest ├── README ├── setup.py └── LICENSE /docs/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /tests/browser/images/bread.jpg: -------------------------------------------------------------------------------- 1 | Ceci n'est pas une JPEG. 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test = nosetests 3 | 4 | [nosetests] 5 | detailed-errors=1 6 | 7 | -------------------------------------------------------------------------------- /tests/browser/templates/index.html: -------------------------------------------------------------------------------- 1 |

hi there

2 | -------------------------------------------------------------------------------- /tests/browser/templates/assign_cookie.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Assigned Cookie!

4 | 5 | -------------------------------------------------------------------------------- /tests/browser/templates/ajax.html: -------------------------------------------------------------------------------- 1 |
2 | Active ajax count 3 |
4 | -------------------------------------------------------------------------------- /tests/browser/templates/assign_cookies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Assigned Cookies!

4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/client/alfajor.ini: -------------------------------------------------------------------------------- 1 | [default-targets] 2 | default+apiclient=wsgi 3 | 4 | [default+apiclient.wsgi] 5 | server-entry-point = tests.client.webapp:WebApp() 6 | base_url = http://localhost 7 | -------------------------------------------------------------------------------- /docs/source/intro/index.rst: -------------------------------------------------------------------------------- 1 | Introduction to Alfajor 2 | ----------------------- 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :glob: 7 | 8 | install 9 | tutorial 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/browser/templates/seq_d.html: -------------------------------------------------------------------------------- 1 | seq/d 3 | 4 |

${request_id}

5 |

${referrer}

6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/examples/templates/results.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Alfajor Recommendator Results for {name} 4 | 5 | 6 |

Hola ${name}

7 |

Che, ${name}, you should

8 | 9 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Alfajor 3 | ======= 4 | 5 | 6 | Contents 7 | -------- 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :glob: 12 | 13 | intro/index* 14 | browsers 15 | lxml 16 | ini 17 | 18 | -------------------------------------------------------------------------------- /tests/browser/templates/seq_a.html: -------------------------------------------------------------------------------- 1 | 2 | seq/a 3 | 4 |

seq/b

5 |

${request_id}

6 |

${referrer}

7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/browser/templates/seq_b.html: -------------------------------------------------------------------------------- 1 | 2 | seq/b 3 | 4 |

seq/c

5 |

${request_id}

6 |

${referrer}

7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/browser/templates/seq_c.html: -------------------------------------------------------------------------------- 1 | 2 | seq/c 3 | 4 |

seq/c

5 |

${request_id}

6 |

${referrer}

7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/source/lxml.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Alfajor-flavored LXML 3 | ===================== 4 | 5 | 6 | * Indexing 7 | * Containment 8 | 9 | * printing 10 | * text_content 11 | * innerHTML 12 | 13 | * forms 14 | 15 | * differences from lxml.html 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README 3 | include AUTHORS 4 | include CHANGES 5 | recursive-include tests *py *ini *css *js *jpg *html 6 | #recursive-include docs/source *rst *py 7 | #recursive-include docs/text *txt 8 | #recursive-include docs/html *html *txt *png *css *js *inv 9 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Alfajor Release History 2 | ======================= 3 | 4 | - Added a browser called 'network' which talks to a web server 5 | over a network socket using urllib2. 6 | 7 | 8 | 0.1 (June 24th, 2010) 9 | --------------------- 10 | 11 | - Initial public alpha release. 12 | -------------------------------------------------------------------------------- /alfajor/browsers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Functional browsers.""" 8 | -------------------------------------------------------------------------------- /alfajor/runners/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Tools for integration with automated test runners.""" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | *.py? 3 | *.egg-info 4 | *,cover 5 | *.swp 6 | .DS_Store 7 | .coverage 8 | .coverage.* 9 | cover 10 | htmlcov 11 | bin/* 12 | include 13 | lib 14 | dist 15 | build 16 | 17 | docs/text 18 | docs/html 19 | docs/doctrees 20 | docs/doctest 21 | docs/pickles 22 | docs/source/_static 23 | docs/source/_template 24 | 25 | -------------------------------------------------------------------------------- /tests/browser/templates/form_textareas.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | textareas 4 | 5 | 6 |

All Data

7 |
${data}
8 |
9 |
10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | 3 | MANIFEST 4 | *.py? 5 | *.egg-info 6 | *,cover 7 | .DS_Store 8 | .coverage 9 | .coverage.* 10 | cover 11 | htmlcov 12 | bin/* 13 | include 14 | lib 15 | dist 16 | build 17 | 18 | docs/text 19 | docs/html 20 | docs/doctrees 21 | docs/doctest 22 | docs/pickles 23 | docs/source/_static 24 | docs/source/_template 25 | 26 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Alfajor is an open source project of Action Without Borders, Inc. in 2 | collaboration with the Alfajor contributors. 3 | 4 | Contributors are: 5 | 6 | - Jason Kirtland 7 | - Dan Colish 8 | - Craig Dennis 9 | - Michel Pelletier 10 | - Scott Wilson 11 | - Kevin Turner 12 | 13 | -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | from alfajor import APIClient 8 | 9 | 10 | client = APIClient() 11 | client.configure_in_scope() 12 | -------------------------------------------------------------------------------- /docs/licenses/jquery.txt: -------------------------------------------------------------------------------- 1 | jQuery JavaScript Library v1.4.2 2 | http://jquery.com/ 3 | 4 | Copyright 2010, John Resig 5 | Dual licensed under the MIT or GPL Version 2 licenses. 6 | http://jquery.org/license 7 | 8 | Includes Sizzle.js 9 | http://sizzlejs.com/ 10 | Copyright 2010, The Dojo Foundation 11 | Released under the MIT, BSD, and GPL Licenses. 12 | 13 | Date: Sat Feb 13 22:33:48 2010 -0500 14 | -------------------------------------------------------------------------------- /scripts/alfajor-invoke: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """An alternate version of alfajor-invoke that can run in a source checkout.""" 3 | import os 4 | import sys 5 | 6 | here = os.path.dirname(__file__) 7 | try: 8 | from alfajor.utilities import invoke 9 | except ImportError: 10 | sys.path.append(os.path.join(here, os.path.pardir)) 11 | from alfajor.utilities import invoke 12 | 13 | invoke() 14 | -------------------------------------------------------------------------------- /docs/examples/alfajor.ini: -------------------------------------------------------------------------------- 1 | [default-targets] 2 | default+browser=wsgi 3 | 4 | [examples] 5 | wsgi=wsgi 6 | *=selenium 7 | 8 | [examples+browser.wsgi] 9 | server-entry-point = docs.examples.webapp:webapp() 10 | base_url = http://localhost:8009 11 | 12 | [examples+browser.selenium] 13 | cmd = alfajor-invoke docs.examples.webapp:run 14 | server_url = http://localhost:8009 15 | ping-address = localhost:8009 16 | -------------------------------------------------------------------------------- /docs/examples/test_simple.py: -------------------------------------------------------------------------------- 1 | from docs.examples import browser, browser_test 2 | 3 | 4 | @browser_test() 5 | def test_entering_name(): 6 | browser.open('/') 7 | assert 'Alfajor' in browser.document['#mainTitle'].text_content 8 | browser.document['form input[name="name"]'][0].value = 'Juan' 9 | browser.document['button'][0].click() 10 | assert 'Juan' in browser.document['h1'][0].text_content 11 | -------------------------------------------------------------------------------- /docs/examples/__init__.py: -------------------------------------------------------------------------------- 1 | from nose.tools import with_setup 2 | from alfajor import WebBrowser 3 | 4 | 5 | browser = WebBrowser() 6 | browser.configure_in_scope('examples') 7 | 8 | def setup_fn(): 9 | pass 10 | 11 | def teardown_fn(): 12 | browser.reset() 13 | 14 | def browser_test(): 15 | def dec(fn): 16 | return with_setup(setup_fn, teardown_fn)(fn) 17 | return dec 18 | 19 | browser_test.__test__ = False 20 | -------------------------------------------------------------------------------- /tests/browser/templates/dom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
foo
5 |
6 |
    7 |
  • 1
  • 8 |
  • 2
  • 9 |
10 |
11 |
12 |

msg 1

13 |

msg
2

14 | 15 |

msg 4

16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /alfajor/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Alfajor functional browser and HTTP client.""" 8 | 9 | from alfajor._management import APIClient, WebBrowser 10 | 11 | 12 | __all__ = ['APIClient', 'WebBrowser'] 13 | __version__ = '0.2' 14 | -------------------------------------------------------------------------------- /docs/source/browsers.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Browsers 3 | ======== 4 | 5 | 6 | WSGI 7 | ---- 8 | 9 | Capabilities 10 | ++++++++++++ 11 | 12 | * cookies 13 | * headers 14 | * in-process 15 | * status 16 | 17 | 18 | Selenium 19 | -------- 20 | 21 | Capabilities 22 | ++++++++++++ 23 | 24 | * cookies 25 | * javascript 26 | * selenium 27 | * visibility 28 | 29 | 30 | Zero 31 | ---- 32 | 33 | Capabilities 34 | ++++++++++++ 35 | 36 | * no capabilities 37 | 38 | -------------------------------------------------------------------------------- /tests/client/test_basic.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | from tests.client import client 8 | 9 | 10 | def test_simple_json_fetch(): 11 | response = client.get('/json_data') 12 | assert response.is_json 13 | assert response.json['test'] == 'data' 14 | -------------------------------------------------------------------------------- /scripts/ntest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """An alternate to nosetests that uses the plugin without installation.""" 3 | import os 4 | import sys 5 | import nose 6 | 7 | here = os.path.dirname(__file__) 8 | try: 9 | import alfajor 10 | except ImportError: 11 | sys.path.append(os.path.join(here, os.path.pardir)) 12 | 13 | from alfajor.runners.nose import Alfajor 14 | 15 | 16 | path = os.environ.get('PATH', '') 17 | os.environ['PATH'] = path + os.pathsep + here 18 | nose.main(addplugins=[Alfajor()]) 19 | -------------------------------------------------------------------------------- /docs/source/ini.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | alfajor.ini 3 | =========== 4 | 5 | 6 | .. code-block:: ini 7 | 8 | [default-targets] 9 | default+browser=wsgi 10 | 11 | [self-tests] 12 | wsgi=wsgi 13 | *=selenium 14 | zero=zero 15 | 16 | [self-tests+browser.wsgi] 17 | server-entry-point = tests.browser.webapp:webapp() 18 | base_url = http://localhost 19 | 20 | [self-tests+browser.selenium] 21 | cmd = alfajor-invoke tests.browser.webapp:run 22 | server_url = http://localhost:8008 23 | ping-address = localhost:8008 24 | -------------------------------------------------------------------------------- /tests/browser/alfajor.ini: -------------------------------------------------------------------------------- 1 | [default-targets] 2 | default+browser=wsgi 3 | 4 | [self-tests] 5 | wsgi=wsgi 6 | network=network 7 | *=selenium 8 | zero=zero 9 | 10 | [self-tests+browser.wsgi] 11 | server-entry-point = tests.browser.webapp:webapp() 12 | base_url = http://localhost:8008 13 | 14 | [self-tests+browser.network] 15 | cmd = alfajor-invoke tests.browser.webapp:run 16 | server_url = http://localhost:8008 17 | ping-address = localhost:8008 18 | 19 | [self-tests+browser.selenium] 20 | cmd = alfajor-invoke tests.browser.webapp:run 21 | server_url = http://localhost:8008 22 | ping-address = localhost:8008 23 | -------------------------------------------------------------------------------- /tests/test_management.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | from alfajor._management import _DeferredProxy 8 | 9 | from nose.tools import assert_raises 10 | 11 | 12 | def test_proxy_readiness(): 13 | class Sentinel(object): 14 | prop = 123 15 | sentinel = Sentinel() 16 | 17 | proxy = _DeferredProxy() 18 | assert_raises(RuntimeError, getattr, proxy, 'prop') 19 | 20 | proxy = _DeferredProxy() 21 | proxy._factory = lambda: sentinel 22 | assert proxy.prop == 123 23 | -------------------------------------------------------------------------------- /tests/browser/templates/form_multipart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | forms 4 | 5 | 6 |

Method

7 |
${request.method}
8 |

All Data

9 |
${data}
10 |
${files}
11 |

${request_id}

12 |
13 |
14 | Search: 15 | 16 |
17 |
18 | Search: 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /docs/licenses/lxml.txt: -------------------------------------------------------------------------------- 1 | [file references in this document refer to the lxml 2.2.3 2 | distribution, not Alfajor. -the Alfajor team] 3 | 4 | lxml is copyright Infrae and distributed under the BSD license (see 5 | doc/licenses/BSD.txt), with the following exceptions: 6 | 7 | Some code, such a selftest.py, selftest2.py and 8 | src/lxml/_elementpath.py are derived from ElementTree and 9 | cElementTree. See doc/licenses/elementtree.txt for the license text. 10 | 11 | test.py, the test-runner script, is GPL and copyright Shuttleworth 12 | Foundation. See doc/licenses/GPL.txt. It is believed the unchanged 13 | inclusion of test.py to run the unit test suite falls under the 14 | "aggregation" clause of the GPL and thus does not affect the license 15 | of the rest of the package. 16 | 17 | the doctest.py module is taken from the Python library and falls under 18 | the PSF Python License. 19 | -------------------------------------------------------------------------------- /tests/browser/templates/form_radios.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | radios 4 | 5 | 6 |

All Data

7 |
${data}
8 |
9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 | 18 |
19 | 20 |
21 |
22 | 23 |
24 | 25 |
26 | 27 |
28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/browser/templates/form_select.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | select 4 | 5 | 6 |

All Data

7 |
${data}
8 |
9 |
10 | 16 | 17 |
18 |
19 | 24 | 25 |
26 |
27 | 33 | 34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /tests/browser/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | import os 8 | 9 | from alfajor import WebBrowser 10 | 11 | from nose.tools import with_setup 12 | 13 | browser = WebBrowser() 14 | browser.configure_in_scope('self-tests') 15 | 16 | 17 | def setup_fn(): 18 | pass 19 | 20 | 21 | def teardown_fn(): 22 | browser.reset() 23 | 24 | 25 | def browser_test(): 26 | def dec(fn): 27 | return with_setup(setup_fn, teardown_fn)(fn) 28 | return dec 29 | 30 | browser_test.__test__ = False 31 | 32 | 33 | def screenshot_fails(file): 34 | def dec(fn): 35 | def test(*args, **kw): 36 | try: 37 | fn(*args, **kw) 38 | except: 39 | if os.path.exists(file): 40 | os.remove(file) 41 | return True 42 | else: 43 | return False 44 | test.__name__ = fn.__name__ 45 | return test 46 | return dec 47 | -------------------------------------------------------------------------------- /tests/browser/templates/form_checkboxes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | checkboxes 4 | 5 | 6 |

All Data

7 |
${data}
8 |
9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 | 18 |
19 | 20 |
21 |
22 | 23 |
24 | 25 | 26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/licenses/flatland.txt: -------------------------------------------------------------------------------- 1 | [file references in this document refer to the flatland distribution, 2 | not Alfajor. -the Alfajor team] 3 | 4 | Copyright (c) The Flatland authors and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a 7 | copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included 15 | in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 18 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /tests/browser/templates/form_methods.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | forms 4 | 5 | 6 |

Query String Data

7 |
${args}
8 |

POST Data

9 |
${form}
10 |
11 |
12 | First Name: 13 | Email: 14 |
15 |
16 | First Name: 17 | Email: 18 |
19 |
20 | First Name: 21 | Email: 22 |
23 |
24 | First Name: 25 | Email: 26 |
27 |
28 | First Name: 29 | Email: 30 |
31 |
32 | First Name: 33 | Email: 34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/examples/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Alfajor recommendator 4 | 5 | 6 |

Alfajor Recommendation System

7 |

Welcome to ARS. We will try and recommend you the appropriate 8 | alfajor based on your answers to the following simple questions.

9 | 10 |
11 |
12 |
13 |
14 |
15 |
16 | 22 |
23 |
Please check all that apply:
24 |
25 |
    26 |
  • 28 |
  • 30 |
31 |
32 |
33 |
34 |
35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/source/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | VERSION ?= tip 8 | RELEASE ?= $(VERSION) 9 | 10 | # Internal variables. 11 | ALLSPHINXOPTS = -D version=$(VERSION) -D release=$(RELEASE) \ 12 | -d ../doctrees $(SPHINXOPTS) . 13 | 14 | help: 15 | @echo "Please use \`make ' where is one of" 16 | @echo " html to make standalone HTML files" 17 | @echo " text to make standalone text files" 18 | @echo " sdist to build documentation for release" 19 | @echo " doctest to run doctests" 20 | @echo " pickles to build pickles" 21 | @echo " clean to remove generated artifacts" 22 | 23 | sdist: clean text html 24 | 25 | clean: 26 | for i in doctrees html text doctest pickles website; do \ 27 | rm -rf ../$$i; \ 28 | done 29 | 30 | html: 31 | mkdir -p ../html ../doctrees _static _template 32 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) ../html 33 | @echo 34 | @echo "Build finished. The HTML pages are in ../html." 35 | 36 | text: 37 | mkdir -p ../text ../doctrees 38 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) ../text 39 | @echo 40 | @echo "Build finished. The text pages are in ../text." 41 | 42 | doctest: 43 | mkdir -p ../doctrees ../doctest 44 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) ../doctest 45 | 46 | pickles: 47 | mkdir -p ../pickles ../doctest 48 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) ../pickles 49 | -------------------------------------------------------------------------------- /tests/browser/templates/waitfor.html: -------------------------------------------------------------------------------- 1 | 2 | waitfor 3 | 4 | 7 | 8 | 35 | 36 | 37 | wait_for_element_not_present 38 |
wait_for_element_not_present
39 | wait_for_js 40 | wait_for_element 41 | wait_for_ajax 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /alfajor/_compat.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Glue code for Python version compatibility.""" 8 | 9 | _json = None 10 | 11 | try: 12 | property.getter 13 | except AttributeError: 14 | class property(property): 15 | """A work-alike for Python 2.6's property.""" 16 | __slots__ = () 17 | 18 | def getter(self, fn): 19 | return property(fn, self.fset, self.fdel) 20 | 21 | def setter(self, fn): 22 | return property(self.fget, fn, self.fdel) 23 | 24 | def deleter(self, fn): 25 | return property(self.fget, self.fset, fn) 26 | else: 27 | property = property 28 | 29 | 30 | def _load_json(): 31 | global _json 32 | if _json is None: 33 | try: 34 | import json as _json 35 | except ImportError: 36 | try: 37 | import simplejson as _json 38 | except ImportError: 39 | pass 40 | if not _json: 41 | raise ImportError( 42 | "This feature requires Python 2.6+ or simplejson.") 43 | 44 | 45 | def json_loads(*args, **kw): 46 | if _json is None: 47 | _load_json() 48 | return _json.loads(*args, **kw) 49 | 50 | 51 | def json_dumps(*args, **kw): 52 | if _json is None: 53 | _load_json() 54 | return _json.dumps(*args, **kw) 55 | -------------------------------------------------------------------------------- /docs/licenses/werkzeug.txt: -------------------------------------------------------------------------------- 1 | [file references in this document refer to the werkzeug 0.4 2 | distribution, not Alfajor. -the Alfajor team] 3 | 4 | Copyright (c) 2006 by the respective authors (see AUTHORS file). 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are 9 | met: 10 | 11 | * Redistributions of source code must retain the above copyright 12 | notice, this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above 15 | copyright notice, this list of conditions and the following 16 | disclaimer in the documentation and/or other materials provided 17 | with the distribution. 18 | 19 | * The names of the contributors may not be used to endorse or 20 | promote products derived from this software without specific 21 | prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 24 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 25 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 26 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 27 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 28 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 29 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 30 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 31 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 32 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 33 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /tests/browser/templates/form_submit.html: -------------------------------------------------------------------------------- 1 | d> 2 | forms 3 | 4 | 5 |

Method

6 |
${request.method}
7 |

All Data

8 |
${data}
9 |

${request_id}

10 |
11 |
12 | Search: 13 |
14 |
15 | Search: 16 |
17 |
18 | Search: 19 | 20 |
21 |
22 | Search: 23 | 24 |
25 |
26 | Search: 27 | 28 |
29 |
30 | Search: 31 | 32 | 33 |
34 |
35 | Search: 36 | 37 | 38 |
39 |
40 | Search: 41 | 42 |
43 |
44 | X: 45 | Y: 46 | 47 |
48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /alfajor/_config.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """INI helpers.""" 8 | import ConfigParser 9 | from StringIO import StringIO 10 | 11 | 12 | class Configuration(ConfigParser.SafeConfigParser): 13 | """Alfajor run-time configuration.""" 14 | 15 | _default_config = """\ 16 | [default] 17 | wsgi = wsgi 18 | * = selenium 19 | 20 | [default+browser.zero] 21 | """ 22 | 23 | def __init__(self, file): 24 | ConfigParser.SafeConfigParser.__init__(self) 25 | self.readfp(StringIO(self._default_config)) 26 | if not self.read(file): 27 | raise IOError("Could not open config file %r" % file) 28 | self.source = file 29 | 30 | def get_section(self, name, default=None, 31 | template='%(name)s', logger=None, fallback=None, **kw): 32 | section_name = template % dict(kw, name=name) 33 | try: 34 | return dict(self.items(section_name)) 35 | except ConfigParser.NoSectionError: 36 | pass 37 | 38 | msg = "Configuration %r does not contain section %r" % ( 39 | self.source, section_name) 40 | 41 | if fallback and fallback != name: 42 | try: 43 | section = self.get_section(fallback, default, template, 44 | logger, **kw) 45 | except LookupError: 46 | pass 47 | else: 48 | if logger: 49 | fallback_name = fallback % dict(kw, name=fallback) 50 | logger.debug("%s, falling back to %r" % ( 51 | msg, section_name, fallback_name)) 52 | return section 53 | if default is not None: 54 | if logger: 55 | logger.debug(msg + ", using default.") 56 | return default 57 | raise LookupError(msg) 58 | -------------------------------------------------------------------------------- /tests/browser/templates/form_fill.html: -------------------------------------------------------------------------------- 1 | 2 | form_field_fill_ordering 3 |
4 | This came back from the server 5 |
${data}
6 |
7 | 8 |
9 | 11 | 13 | Language : 19 |
20 | 21 |
22 |
23 | 24 | 25 |
26 | 27 | 28 | 29 |
30 |
31 | 32 | 35 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /tests/browser/test_dom.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | from . import browser 8 | 9 | 10 | def test_indexing(): 11 | browser.open('/dom') 12 | doc = browser.document 13 | 14 | assert doc['#A'].tag == 'dl' 15 | assert doc['dl#A'].tag == 'dl' 16 | assert doc['body #A'].tag == 'dl' 17 | assert isinstance(doc['#A ul'], list) 18 | assert isinstance(doc['body #A ul'], list) 19 | assert doc['#C'][0].text == '1' 20 | assert len(doc['#C']['li']) == 2 21 | 22 | 23 | def test_containment(): 24 | browser.open('/dom') 25 | doc = browser.document 26 | 27 | assert 'dl' in doc 28 | assert '#C' in doc 29 | assert not '#C div' in doc 30 | assert 'li' in doc 31 | assert not 'div' in doc 32 | assert doc['body'][0] in doc 33 | assert not doc['#B'] in doc 34 | assert 0 in doc 35 | assert not 2 in doc 36 | 37 | 38 | def test_xpath(): 39 | browser.open('/dom') 40 | doc = browser.document 41 | 42 | assert doc['#A'].fq_xpath == '/html/body/dl' 43 | assert doc.xpath('/html/body/dl')[0] is doc['#A'] 44 | 45 | 46 | def test_innerhtml(): 47 | browser.open('/dom') 48 | ps = browser.document['p'] 49 | 50 | assert ps[0].innerHTML == 'msg 1' 51 | assert ps[1].innerHTML == 'msg
2' 52 | assert ps[2].innerHTML == 'msg
&
3' 53 | assert ps[3].innerHTML == 'msg 4' 54 | 55 | 56 | def test_text_content(): 57 | browser.open('/dom') 58 | ps = browser.document['p'] 59 | 60 | assert ps[0].text_content == 'msg 1' 61 | assert ps[1].text_content == 'msg2' 62 | assert ps[2].text_content == 'msg&3' 63 | assert ps[2].text_content() == 'msg&3' 64 | assert ps[2].text == 'msg' 65 | assert ps[3].text_content == 'msg 4' 66 | 67 | 68 | def test_visibility(): 69 | browser.open('/dom') 70 | p = browser.document['p.hidden'][0] 71 | if 'visibility' in browser.capabilities: 72 | assert not p.is_visible 73 | else: 74 | assert p.is_visible 75 | -------------------------------------------------------------------------------- /alfajor/browsers/zero.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """A non-functional web browser. 8 | 9 | Documents the canonical base implementation of browsers. Zero is instantiable 10 | and usable, however it does not supply any capabilities. 11 | 12 | """ 13 | 14 | from alfajor.utilities import lazy_property 15 | from alfajor.browsers._lxml import DOMMixin, base_elements, html_parser_for 16 | from alfajor.browsers._waitexpr import WaitExpression 17 | 18 | 19 | __all__ = ['Zero'] 20 | 21 | 22 | class Zero(DOMMixin): 23 | """A non-functional web browser.""" 24 | 25 | capabilities = [] 26 | 27 | wait_expression = WaitExpression 28 | 29 | user_agent = { 30 | 'browser': 'zero', 31 | 'platform': 'python', 32 | 'version': '0.1', 33 | } 34 | 35 | location = '/' 36 | 37 | status_code = 0 38 | 39 | status = None 40 | 41 | response = """\ 42 | 43 | 44 |

Not Implemented 45 |

Web browsing unavailable.

46 | 47 | 48 | """ 49 | 50 | def open(self, url, wait_for=None, timeout=0): 51 | """Navigate to *url*.""" 52 | 53 | def reset(self): 54 | """Reset browser state (clear cookies, etc.)""" 55 | 56 | def wait_for(self, condition, timeout=0): 57 | """Wait for *condition*.""" 58 | 59 | def sync_document(self): 60 | """The document is always synced.""" 61 | 62 | headers = {} 63 | """A dictionary of HTTP response headers.""" 64 | 65 | cookies = {} 66 | """A dictionary of cookies visible to the current page.""" 67 | 68 | def set_cookie(self, name, value, domain=None, path='/', **kw): 69 | """Set a cookie.""" 70 | 71 | def delete_cookie(self, name, domain=None, path='/', **kw): 72 | """Delete a cookie.""" 73 | 74 | @lazy_property 75 | def _lxml_parser(self): 76 | return html_parser_for(self, base_elements) 77 | 78 | # ? select_form(...) -> ... 79 | 80 | # future capability: 81 | # file upload 82 | -------------------------------------------------------------------------------- /docs/source/intro/install.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Installing Alfajor 3 | ================== 4 | 5 | The Alfajor repository is available for check out from our `github page`_. 6 | 7 | .. _`github page`: https://github.com/idealist/Alfajor 8 | 9 | 10 | Setup: 11 | ______ 12 | 13 | 1) Create and activate a virtualenv_ (optional) 14 | 1) Next we need to install dependencies. From the top of the distribution 15 | run:: 16 | 17 | $ python setup.py develop 18 | 19 | 1) Next install nose using either:: 20 | 21 | $ easy_install nose 22 | 23 | OR:: 24 | 25 | $ pip install nose 26 | 27 | 28 | 29 | .. _virtualenv: http://pypi.python.org/pypi/virtualenv 30 | 31 | If you don't have Selenium installed, download `Selenium Server`_. All you need 32 | is the selenium-server.jar, and no configuration is required. 33 | 34 | Run it with:: 35 | 36 | $ java -jar selenium-server.jar 37 | 38 | .. _`Selenium Server`: http://seleniumhq.org/download/ 39 | 40 | .. todo:: List which versions of Selenium are required 41 | 42 | 43 | See it in action: 44 | _________________ 45 | 46 | After following the steps above, the Alfajor plugin should be available 47 | and listing command-line options for nose. You can verify this by typing:: 48 | 49 | $ nosetests --help 50 | 51 | To run the standard tests that use an in-process web app through a WSGI 52 | interface, simply type:: 53 | 54 | $ nosetests 55 | 56 | To run the same tests but using a real web browser, type:: 57 | 58 | $ nosetests --browser=firefox 59 | 60 | .. admonition:: You can use all sorts of browsers 61 | 62 | A list of valid browsers is: 63 | *firefox 64 | *mock 65 | *firefoxproxy 66 | *pifirefox 67 | *chrome 68 | *iexploreproxy 69 | *iexplore 70 | *firefox3 71 | *safariproxy 72 | *googlechrome 73 | *konqueror 74 | *firefox2 75 | *safari 76 | *piiexplore 77 | *firefoxchrome 78 | *opera 79 | *iehta 80 | *custom 81 | 82 | .. admonition:: .ini Files 83 | 84 | The main action of Alfajor is directed through an :doc:`alfajor.ini ` file. At the 85 | simplest, this can be anywhere on the filesystem (see the --alfajor-config 86 | option in nose) or placed in the same directory as the .py file that 87 | configures the WebBrowser. See `tests/webapp/{__init__.py,alfajor.ini}`. 88 | -------------------------------------------------------------------------------- /docs/examples/webapp.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | import os 8 | import tempfile 9 | 10 | from werkzeug import Response, Request, Template 11 | from werkzeug.exceptions import NotFound, HTTPException 12 | from werkzeug.routing import Map, Rule 13 | 14 | 15 | class WebApp(object): 16 | 17 | url_map = Map([ 18 | Rule('/', endpoint='index'), 19 | Rule('/results', endpoint='results'), 20 | ]) 21 | 22 | def __call__(self, environ, start_response): 23 | request = Request(environ) 24 | urls = self.url_map.bind_to_environ(environ) 25 | try: 26 | endpoint, args = urls.match() 27 | except NotFound, exc: 28 | args = {} 29 | endpoint = '/' 30 | environ['routing_args'] = args 31 | environ['endpoint'] = endpoint 32 | 33 | try: 34 | response = self.template_renderer(request) 35 | except HTTPException, exc: 36 | # ok, maybe it really was a bogus URL. 37 | return exc(environ, start_response) 38 | return response(environ, start_response) 39 | 40 | def template_renderer(self, request): 41 | endpoint = request.environ['endpoint'] 42 | path = '%s/templates/%s.html' % ( 43 | os.path.dirname(__file__), endpoint) 44 | try: 45 | source = open(path).read() 46 | except IOError: 47 | raise NotFound() 48 | template = Template(source) 49 | handler = getattr(self, endpoint, None) 50 | context = dict() 51 | if handler: 52 | handler(request, context) 53 | print context 54 | body = template.render(context) 55 | return Response(body, mimetype='text/html') 56 | 57 | def results(self, request, context): 58 | context.update( 59 | name=request.args.get('name', 'Che'), 60 | ) 61 | 62 | 63 | def webapp(): 64 | return WebApp() 65 | 66 | 67 | def run(bind_address='0.0.0.0', port=8009): 68 | """Run the webapp in a simple server process.""" 69 | from werkzeug import run_simple 70 | print "* Starting on %s:%s" % (bind_address, port) 71 | run_simple(bind_address, port, webapp(), 72 | use_reloader=False, threaded=True) 73 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Alfajor 2 | ------- 3 | 4 | Tasty functional testing. 5 | 6 | Alfajor provides a modern, object-oriented and browser-neutral interface to 7 | HTTP resources. With Alfajor, your Python scripts and test code have a live, 8 | synchronized mirror of the browser's X/HTML DOM, even with DOM changes made on 9 | the client by JavaScript. 10 | 11 | Alfajor provides: 12 | 13 | - A straightforward 'browser' object, with an implementation that 14 | communicates in real-time with live web browsers via Selenium and a fast, 15 | no-javascript implementation via an integrated WSGI gateway 16 | 17 | - Use a specific browser, or, via integration with the 'nose' test runner, 18 | switch out the browser backend via a command line option to your tests. 19 | Firefox, Safari, WSGI- choose which you want on a run-by-run basis. 20 | 21 | - Synchronized access to the page DOM via a rich dialect of lxml, with great 22 | time-saving shortcuts that make tests compact, readable and fun to write. 23 | 24 | - Optional management of server processes under test, allowing them to 25 | transparently start and stop on demand as your tests run. 26 | 27 | - An 'apiclient' with native JSON response support, useful for testing REST 28 | and web api implementations at a fine-grained level. 29 | 30 | - A friendly BSD license. 31 | 32 | Behind the scenes, Alfajor has a well-defined structure that supports plugins 33 | for new browser backends and testing requirements. The following plugins are 34 | already underway or in planning: 35 | 36 | - Windmill 37 | - Selenium 2.0 / WebDriver 38 | - cloud-based Selenium testing services 39 | - py.test integration 40 | 41 | Getting Started 42 | =============== 43 | 44 | This is the alpha-release README. Please, bear with us as we assemble 45 | traditional documentation and tutorial material. Until then... 46 | 47 | To get started quickly, use the Alfajor self-tests to see it in action: 48 | 49 | Setup: 50 | 51 | 1) create and activate a virtualenv (optional) 52 | 2) cd to the top of this distribution 53 | 3) python setup.py develop 54 | 4) easy_install nose 55 | 56 | If you don't have Selenium installed, download Selenium RC. All you need is 57 | the selenium-server.jar, and no configuration is required. Run it with 'java 58 | -jar selenium-server.jar'. 59 | 60 | Action: 61 | 62 | 1) nosetests --help 63 | 64 | After following the steps above, the Alfajor plugin should be available 65 | and listing command-line options for nose. 66 | 67 | 2) nosetests 68 | 69 | You just ran a whole mess of tests against an in-process web app through 70 | a WSGI interface. 71 | 72 | 3) nosetests --browser=firefox 73 | 74 | You just ran the same mess of tests in a real web browser! 75 | 76 | You can try other browser names: safari, etc. 77 | 78 | ------------------------------------------------------------------------------ 79 | 80 | The main action of Alfajor is directed through an alfajor.ini file. At the 81 | simplest, this can be anywhere on the filesystem (see the --alfajor-config 82 | option in nose) or placed in the same directory as the .py file that 83 | configures the WebBrowser. See tests/webapp/{__init__.py,alfajor.ini}. 84 | 85 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | """\ 7 | Alfajor 8 | ------- 9 | 10 | Tasty functional testing. 11 | 12 | Alfajor provides a modern, object-oriented and browser-neutral interface to 13 | HTTP resources. With Alfajor, your Python scripts and test code have a live, 14 | synchronized mirror of the browser's X/HTML DOM, even with DOM changes made on 15 | the client by JavaScript. 16 | 17 | alfajor provides: 18 | 19 | - A straightforward 'browser' object, with an implementation that 20 | communicates in real-time with live web browsers via Selenium and a fast, 21 | no-javascript implementation via an integrated WSGI gateway 22 | 23 | - Use a specific browser, or, via integration with the 'nose' test runner, 24 | switch out the browser backend via a command line option to your tests. 25 | Firefox, Safari, WSGI- choose which you want on a run-by-run basis. 26 | 27 | - Synchronized access to the page DOM via a rich dialect of lxml, with great 28 | time-saving shortcuts that make tests compact, readable and fun to write. 29 | 30 | - Optional management of server processes under test, allowing them to 31 | transparently start and stop on demand as your tests run. 32 | 33 | - An 'apiclient' with native JSON response support, useful for testing REST 34 | and web api implementations at a fine-grained level. 35 | 36 | - A friendly BSD license. 37 | 38 | An `in-development`_ version is also available 39 | 40 | .. _`in-development`: http://github.com/idealist/Alfajor/zipball/master#egg=alfajor-dev 41 | 42 | 43 | """ 44 | 45 | from setuptools import setup, find_packages 46 | 47 | import alfajor 48 | version = alfajor.__version__ 49 | 50 | setup(name="alfajor", 51 | version=version, 52 | packages=find_packages(exclude=[ 53 | '*.tests', '*.tests.*', 'tests.*', 'tests']), 54 | 55 | author='Action Without Borders, Inc.', 56 | author_email='oss@idealist.org', 57 | 58 | description='Tasty functional testing.', 59 | keywords='testing test functional integration browser ajax selenium', 60 | long_description=__doc__, 61 | license='BSD', 62 | url='http://github.com/idealist/Alfajor/', 63 | 64 | classifiers=[ 65 | 'Development Status :: 1 - Planning', 66 | 'Intended Audience :: Developers', 67 | 'License :: OSI Approved :: BSD License', 68 | 'Operating System :: OS Independent', 69 | 'Programming Language :: Python', 70 | 'Programming Language :: Python :: 2', 71 | 'Programming Language :: Python :: 2.5', 72 | 'Programming Language :: Python :: 2.6', 73 | 'Programming Language :: Python :: 2.7', 74 | 'Topic :: Internet :: WWW/HTTP', 75 | 'Topic :: Internet :: WWW/HTTP :: Browsers', 76 | 'Topic :: Software Development :: Testing', 77 | 'Topic :: Software Development :: Quality Assurance', 78 | ], 79 | 80 | entry_points={ 81 | 'console_scripts': [ 82 | 'alfajor-invoke=alfajor.utilities:invoke', 83 | ], 84 | 'nose.plugins.0.10': [ 85 | 'alfajor = alfajor.runners.nose:Alfajor', 86 | ], 87 | }, 88 | 89 | install_requires=[ 90 | 'Werkzeug >= 0.6', 91 | 'lxml', 92 | 'blinker', 93 | ], 94 | 95 | tests_require=[ 96 | 'nose', 97 | ], 98 | ) 99 | -------------------------------------------------------------------------------- /tests/client/webapp.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | import os 8 | import tempfile 9 | 10 | from werkzeug import Response, Request, Template 11 | from werkzeug.exceptions import NotFound, HTTPException 12 | from werkzeug.routing import Map, Rule 13 | 14 | from alfajor._compat import json_dumps as dumps 15 | 16 | 17 | class WebApp(object): 18 | 19 | url_map = Map([ 20 | # Uurls like /form/fill get turned into templates/form_fill.html 21 | # automatically in __call__ and don't need a Rule & endpoint. 22 | # 23 | # We only need Rules and endpoints for alternate mappings or to do 24 | # dynamic processing. 25 | Rule('/', endpoint='index'), 26 | Rule('/json_data', endpoint='json_data'), 27 | ]) 28 | 29 | def __call__(self, environ, start_response): 30 | request = Request(environ) 31 | urls = self.url_map.bind_to_environ(environ) 32 | try: 33 | endpoint, args = urls.match() 34 | except NotFound, exc: 35 | # Convert unknown /path/names into endpoints named path_names 36 | endpoint = request.path.lstrip('/').replace('/', '_') 37 | args = {} 38 | environ['routing_args'] = args 39 | environ['endpoint'] = endpoint 40 | 41 | try: 42 | # endpoints can be methods on this class 43 | handler = getattr(self, endpoint) 44 | except AttributeError: 45 | # or otherwise assumed to be files in templates/.html 46 | handler = self.generic_template_renderer 47 | self.call_count = getattr(self, 'call_count', 0) + 1 48 | try: 49 | response = handler(request) 50 | except HTTPException, exc: 51 | # ok, maybe it really was a bogus URL. 52 | return exc(environ, start_response) 53 | return response(environ, start_response) 54 | 55 | def generic_template_renderer(self, request): 56 | path = '%s/templates/%s.html' % ( 57 | os.path.dirname(__file__), request.environ['endpoint']) 58 | try: 59 | source = open(path).read() 60 | except IOError: 61 | raise NotFound() 62 | template = Template(source) 63 | files = [] 64 | for name, file in request.files.items(): 65 | # Save the uploaded files to tmp storage. 66 | # The calling test should delete the files. 67 | fh, fname = tempfile.mkstemp() 68 | os.close(fh) 69 | file.save(fname) 70 | files.append( 71 | (name, (file.filename, file.content_type, 72 | file.content_length, fname))) 73 | context = dict( 74 | request=request, 75 | request_id=self.call_count, 76 | args=dumps(sorted(request.args.items(multi=True))), 77 | form=dumps(sorted(request.form.items(multi=True))), 78 | data=dumps(sorted(request.args.items(multi=True) + 79 | request.form.items(multi=True))), 80 | files=dumps(sorted(files)), 81 | referrer=request.referrer or '', 82 | #args=.. 83 | ) 84 | body = template.render(context) 85 | return Response(body, mimetype='text/html') 86 | 87 | def json_data(self, request): 88 | body = dumps({'test': 'data'}) 89 | return Response(body, mimetype='application/json') 90 | 91 | 92 | def run(bind_address='0.0.0.0', port=8008): 93 | """Run the webapp in a simple server process.""" 94 | from werkzeug import run_simple 95 | print "* Starting on %s:%s" % (bind_address, port) 96 | run_simple(bind_address, port, WebApp(), 97 | use_reloader=False, threaded=True) 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 by Action Without Borders, Inc., the Alfajor authors and 2 | contributors. All rights reserved. See AUTHORS for details of authorship. 3 | 4 | Alfajor is distributed under under the BSD license (see below), with the 5 | following exceptions: 6 | 7 | - Some code, such as alfajor/browsers/_lxml.py, is derived from lxml. See 8 | docs/licenses/lxml.txt for the license text. 9 | 10 | - The File implementation of the WSGI APIClient is derived from Werkzeug. 11 | See docs/licenses/werkzeug.txt for the license text. 12 | 13 | - A variety of code is derived from Flatland. See docs/licenses/flatland.txt 14 | for the license text. 15 | 16 | - The tests directory distributes a copy of jQuery. See 17 | docs/licenses/jquery.txt for the license text. 18 | 19 | - The Alfajor documentation is distributed under the Berkeley Documentation 20 | License (BDL). See below. 21 | 22 | The BSD license 23 | --------------- 24 | 25 | Redistribution and use in source and binary forms, with or without 26 | modification, are permitted provided that the following conditions are met: 27 | 28 | * Redistributions of source code must retain the above copyright notice, 29 | this list of conditions and the following disclaimer. 30 | 31 | * Redistributions in binary form must reproduce the above copyright notice, 32 | this list of conditions and the following disclaimer in the documentation 33 | and/or other materials provided with the distribution. 34 | 35 | * Neither the names of the authors nor the names of the contributors may be 36 | used to endorse or promote products derived from this software without 37 | specific prior written permission. 38 | 39 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 40 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 41 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 42 | ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 43 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 44 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 45 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 46 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 47 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 48 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 49 | 50 | The Berkeley Documentation License 51 | ---------------------------------- 52 | 53 | Redistribution and use in source (reStructuredText format and ALLCAPS text 54 | files) and 'compiled' forms (PDF, PostScript, HTML, RTF, etc), with or 55 | without modification, are permitted provided that the following conditions 56 | are met: 57 | 58 | * Redistributions of source code (reStructuredText format and ALLCAPS text 59 | files) must retain the above copyright notice, this list of conditions and 60 | the following disclaimer. 61 | 62 | * Redistributions in compiled form (converted to PDF, PostScript, HTML, RTF, 63 | and other formats) must reproduce the above copyright notice, this list of 64 | conditions and the following disclaimer in the documentation and/or other 65 | materials provided with the distribution. 66 | 67 | * The names of the authors and contributors may not be used to endorse or 68 | promote products derived from this documentation without specific prior 69 | written permission. 70 | 71 | THIS DOCUMENTATION IS PROVIDED BY THE AUTHOR AS IS AND ANY EXPRESS OR 72 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 73 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 74 | EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 75 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 76 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 77 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 78 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 79 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS DOCUMENTATION, EVEN IF 80 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 81 | -------------------------------------------------------------------------------- /docs/source/intro/tutorial.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Getting started using Alfajor 3 | ============================= 4 | 5 | 6 | In this tutorial we will be showing you how to use Alfajor to greatly enhance 7 | the tastiness of your functional testing. 8 | 9 | We are assuming you have properly :doc:`installed Alfajor and ran the browser 10 | based test suite to ensure everything is working `. 11 | 12 | 13 | ----------------------- 14 | The Example Application 15 | ----------------------- 16 | 17 | To start with, you are going to need a web application to test. Since we are 18 | nice, we've included an example application in the project for you. You can 19 | find it in ./tests/examples. The application is a simple recommendation 20 | system that under the covers is just an extremely simple `WSGI application`_. 21 | 22 | .. _`WSGI application`: http://wsgi.org 23 | 24 | Let's start by getting to know our example application. You should be able 25 | to start it up using the following command: :: 26 | 27 | $ alfajor-invoke docs.examples.webapp:run 28 | 29 | Now if you point your web browser at http://127.0.0.1:8009, you should see 30 | the silly example application. Try filling in the form a few different ways 31 | and get familiar with the output. 32 | 33 | Alright, now let's press CTRL-c to stop your server, we've got some testing to 34 | do. 35 | 36 | ------------------------------- 37 | Testing the Example Application 38 | ------------------------------- 39 | 40 | So now we've seen the application in all it's glory, we'd better write a few 41 | tests to make sure it is functioning as expected. 42 | 43 | Again, cuzz we are such good guys over here, we've started a little test suite 44 | that you can kick off pretty easily. To run the tests simply type: :: 45 | 46 | $ nosetests docs.examples.test_simple 47 | 48 | So let's go on a line-by-line tour through the nosetest test_name_entry in 49 | tests/examples/test_simple 50 | 51 | .. code-block:: python 52 | 53 | browser.open('/') 54 | 55 | It is time to introduce you to the browser object. It is going to be 56 | available to you somewhat magically throughout each of your tests, all you 57 | need to do is import it from your base testing module. 58 | 59 | The :meth:`~alfajor.browsers.wsgi.WSGI.open` method will attempt to load 60 | the **url** that is passed in. Absolute urls, as shown in the example code, 61 | will work by appending to your *base_url* or *server_url* setting. 62 | 63 | .. admonition:: This magical setup is actually performed using your 64 | alfajor.ini file and some iems that you will place in your test modules 65 | __init__.py file. For more information checkout 66 | `Configuring your test suite `_ 67 | .. todo:: intro/config doesn't exist 68 | 69 | Alright now the browser object has loaded the url, it is ready to be poked at. 70 | 71 | .. code-block::python 72 | 73 | assert 'Alfajor' in browser.document['#mainTitle'].text_content 74 | 75 | The :attr:`~alfajor.browsers.wsgi.WSGI.document` represents the HTMLDocument 76 | element. If you are familiar with `CSS selectors`_ this type of traversal, 77 | should be fairly straightforward. Basically what this is saying is, get the 78 | :class:`~alfajor.browsers._lxml.DOMElement` element on the page with the *id* 79 | attribute of *mainTitle*. Once that is found use the 80 | :attr:`~alfajor.browsers._lxml.DOMElement.text_content` which 81 | returns text in all of the text nodes in between the found tags, to see if our 82 | value is inside. 83 | 84 | .. _`CSS selectors`: http://www.w3.org/TR/2001/CR-css3-selectors-20011113/ 85 | 86 | .. note:: Since the specification of the attribute id states that there can 87 | be only one element with this id, in an HTML document, this lookup will 88 | only return the first occurrence of the id. If you are testing invalid 89 | HTML, consider yourself warned. 90 | 91 | This could very easily be rewritten as such: 92 | 93 | .. code-block::python 94 | 95 | assert 'Alfajor' in browser.document['h1'][0].text_content 96 | 97 | .. admonition:: If you are curious about what more you can do with this 98 | document traversal system, you shoud read the chapter 99 | :doc:`Alfajor flavored lxml `_ 100 | 101 | Okay so the next line we want to enter some data into a form 102 | 103 | .. code-block::python 104 | 105 | browser.document['form input[name="name"]'].value = 'Juan' 106 | 107 | So we get a handle to the input element that we want to add and simply set the 108 | :attr:`value` attribute. 109 | -------------------------------------------------------------------------------- /tests/browser/webapp.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | import os 8 | import tempfile 9 | 10 | from werkzeug import Response, Request, SharedDataMiddleware, Template 11 | from werkzeug.exceptions import NotFound, HTTPException 12 | from werkzeug.routing import Map, Rule 13 | 14 | from alfajor._compat import json_dumps as dumps 15 | 16 | 17 | class WebApp(object): 18 | 19 | url_map = Map([ 20 | # Uurls like /form/fill get turned into templates/form_fill.html 21 | # automatically in __call__ and don't need a Rule & endpoint. 22 | # 23 | # We only need Rules and endpoints for alternate mappings or to do 24 | # dynamic processing. 25 | Rule('/', endpoint='index'), 26 | Rule('/assign-cookie/1', endpoint='assign_cookie'), 27 | Rule('/assign-cookie/2', endpoint='assign_cookies'), 28 | ]) 29 | 30 | def __call__(self, environ, start_response): 31 | request = Request(environ) 32 | urls = self.url_map.bind_to_environ(environ) 33 | try: 34 | endpoint, args = urls.match() 35 | except NotFound, exc: 36 | # Convert unknown /path/names into endpoints named path_names 37 | endpoint = request.path.lstrip('/').replace('/', '_') 38 | args = {} 39 | environ['routing_args'] = args 40 | environ['endpoint'] = endpoint 41 | 42 | try: 43 | # endpoints can be methods on this class 44 | handler = getattr(self, endpoint) 45 | except AttributeError: 46 | # or otherwise assumed to be files in templates/.html 47 | handler = self.generic_template_renderer 48 | self.call_count = getattr(self, 'call_count', 0) + 1 49 | try: 50 | response = handler(request) 51 | except HTTPException, exc: 52 | # ok, maybe it really was a bogus URL. 53 | return exc(environ, start_response) 54 | return response(environ, start_response) 55 | 56 | def generic_template_renderer(self, request): 57 | path = '%s/templates/%s.html' % ( 58 | os.path.dirname(__file__), request.environ['endpoint']) 59 | try: 60 | source = open(path).read() 61 | except IOError: 62 | raise NotFound() 63 | template = Template(source) 64 | files = [] 65 | for name, file in request.files.items(): 66 | # Save the uploaded files to tmp storage. 67 | # The calling test should delete the files. 68 | fh, fname = tempfile.mkstemp() 69 | os.close(fh) 70 | file.save(fname) 71 | files.append( 72 | (name, (file.filename, file.content_type, 73 | file.content_length, fname))) 74 | context = dict( 75 | request=request, 76 | request_id=self.call_count, 77 | args=dumps(sorted(request.args.items(multi=True))), 78 | form=dumps(sorted(request.form.items(multi=True))), 79 | data=dumps(sorted(request.args.items(multi=True) + 80 | request.form.items(multi=True))), 81 | files=dumps(sorted(files)), 82 | referrer=request.referrer or '', 83 | #args=.. 84 | ) 85 | body = template.render(context) 86 | return Response(body, mimetype='text/html') 87 | 88 | def seq_c(self, request): 89 | rsp = self.generic_template_renderer(request) 90 | rsp.status = '301 Redirect' 91 | rsp.location = request.host_url.rstrip('/') + '/seq/d' 92 | return rsp 93 | 94 | def assign_cookie(self, request): 95 | rsp = self.generic_template_renderer(request) 96 | rsp.set_cookie('cookie1', 'value1', path='/') 97 | 98 | if request.args.get('bounce'): 99 | rsp.status = '301 Redirect' 100 | rsp.location = request.args['bounce'] 101 | return rsp 102 | 103 | def assign_cookies(self, request): 104 | rsp = self.generic_template_renderer(request) 105 | rsp.set_cookie('cookie1', 'value1', path='/') 106 | rsp.set_cookie('cookie2', 'value 2', path='/') 107 | if request.args.get('bounce'): 108 | rsp.status = '301 Redirect' 109 | rsp.location = request.args['bounce'] 110 | return rsp 111 | 112 | 113 | def webapp(): 114 | static_path = os.path.join(os.path.dirname(__file__), 'static') 115 | return SharedDataMiddleware(WebApp(), {'/javascript': static_path}) 116 | 117 | 118 | def run(bind_address='0.0.0.0', port=8008): 119 | """Run the webapp in a simple server process.""" 120 | from werkzeug import run_simple 121 | print "* Starting on %s:%s" % (bind_address, port) 122 | run_simple(bind_address, port, webapp(), 123 | use_reloader=False, threaded=True) 124 | -------------------------------------------------------------------------------- /alfajor/browsers/network.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """A browser backend that talks over a network socket to a web server.""" 8 | 9 | from __future__ import absolute_import 10 | from cookielib import Cookie, CookieJar 11 | from logging import getLogger 12 | import urllib2 13 | from urllib import urlencode 14 | from urlparse import urljoin 15 | from time import time 16 | 17 | from blinker import signal 18 | from werkzeug import Headers 19 | 20 | from alfajor.browsers._lxml import DOMMixin, html_parser_for 21 | from alfajor.browsers._waitexpr import WaitExpression 22 | from alfajor.browsers.wsgi import wsgi_elements 23 | from alfajor.utilities import lazy_property 24 | from alfajor._compat import property 25 | 26 | 27 | __all__ = ['Network'] 28 | logger = getLogger('tests.browser') 29 | after_browser_activity = signal('after_browser_activity') 30 | before_browser_activity = signal('before_browser_activity') 31 | 32 | 33 | class Network(DOMMixin): 34 | 35 | capabilities = [ 36 | 'cookies', 37 | 'headers', 38 | ] 39 | 40 | wait_expression = WaitExpression 41 | 42 | user_agent = { 43 | 'browser': 'network', 44 | 'platform': 'python', 45 | 'version': '1.0', 46 | } 47 | 48 | def __init__(self, base_url=None): 49 | # accept additional request headers? (e.g. user agent) 50 | self._base_url = base_url 51 | self.reset() 52 | 53 | def open(self, url, wait_for=None, timeout=0): 54 | """Open web page at *url*.""" 55 | self._open(url) 56 | 57 | def reset(self): 58 | self._referrer = None 59 | self._request_environ = None 60 | self._cookie_jar = CookieJar() 61 | self._opener = urllib2.build_opener( 62 | urllib2.HTTPCookieProcessor(self._cookie_jar) 63 | ) 64 | self.status_code = 0 65 | self.status = '' 66 | self.response = None 67 | self.location = None 68 | self.headers = () 69 | 70 | def wait_for(self, condition, timeout=None): 71 | pass 72 | 73 | def sync_document(self): 74 | """The document is always synced.""" 75 | 76 | _sync_document = DOMMixin.sync_document 77 | 78 | @property 79 | def cookies(self): 80 | if not (self._cookie_jar and self.location): 81 | return {} 82 | request = urllib2.Request(self.location) 83 | policy = self._cookie_jar._policy 84 | 85 | # return ok will only return a cookie if the following attrs are set 86 | # correctly => # "version", "verifiability", "secure", "expires", 87 | # "port", "domain" 88 | return dict((c.name, c.value.strip('"')) 89 | for c in self._cookie_jar if policy.return_ok(c, request)) 90 | 91 | def set_cookie(self, name, value, domain=None, path=None, 92 | session=True, expires=None, port=None): 93 | # Cookie(version, name, value, port, port_specified, 94 | # domain, domain_specified, domain_initial_dot, 95 | # path, path_specified, secure, expires, 96 | # discard, comment, comment_url, rest, 97 | # rfc2109=False): 98 | 99 | cookie = Cookie(0, name, value, port, bool(port), 100 | domain or '', bool(domain), 101 | (domain and domain.startswith('.')), 102 | path or '', bool(path), False, expires, 103 | session, None, None, {}, False) 104 | self._cookie_jar.set_cookie(cookie) 105 | 106 | def delete_cookie(self, name, domain=None, path=None): 107 | try: 108 | self._cookie_jar.clear(domain, path, name) 109 | except KeyError: 110 | pass 111 | 112 | # Internal methods 113 | @lazy_property 114 | def _lxml_parser(self): 115 | return html_parser_for(self, wsgi_elements) 116 | 117 | def _open(self, url, method='GET', data=None, refer=True, 118 | content_type=None): 119 | before_browser_activity.send(self) 120 | open_started = time() 121 | 122 | if data: 123 | data = urlencode(data) 124 | 125 | url = urljoin(self._base_url, url) 126 | if method == 'GET': 127 | if '?' in url: 128 | url, query_string = url.split('?', 1) 129 | else: 130 | query_string = None 131 | 132 | if data: 133 | query_string = data 134 | if query_string: 135 | url = url + '?' + query_string 136 | 137 | request = urllib2.Request(url) 138 | elif method == 'POST': 139 | request = urllib2.Request(url, data) 140 | else: 141 | raise Exception('Unsupported method: %s' % method) 142 | if self._referrer and refer: 143 | request.add_header('Referer', self._referrer) 144 | 145 | logger.info('%s(%s)', url, method) 146 | request_started = time() 147 | 148 | response = self._opener.open(request) 149 | 150 | request_ended = time() 151 | 152 | self.status_code = response.getcode() 153 | self.headers = Headers( 154 | (head.strip().split(': ',1) for head in response.info().headers) 155 | ) 156 | self._referrer = request.get_full_url() 157 | self.location = response.geturl() 158 | self._response = response 159 | self.response = ''.join(list(response)) 160 | self._sync_document() 161 | 162 | open_ended = time() 163 | request_time = request_ended - request_started 164 | 165 | logger.info("Fetched %s in %0.3fsec + %0.3fsec browser overhead", 166 | url, request_time, 167 | open_ended - open_started - request_time) 168 | after_browser_activity.send(self) 169 | 170 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Alfajor documentation build configuration file 4 | 5 | from os import path 6 | import sys 7 | 8 | sys.path.append(path.abspath(path.dirname(__file__) + "../../../")) 9 | 10 | # General configuration 11 | # --------------------- 12 | 13 | # Add any Sphinx extension module names here, as strings. They can be 14 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 15 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 16 | 'sphinx.ext.coverage', 17 | 'sphinx.ext.inheritance_diagram'] 18 | 19 | # Add any paths that contain templates here, relative to this directory. 20 | templates_path = ['_templates'] 21 | 22 | # The suffix of source filenames. 23 | source_suffix = '.rst' 24 | 25 | # The master toctree document. 26 | master_doc = 'index' 27 | 28 | # General substitutions. 29 | project = 'Alfajor' 30 | copyright = '2011, the Alfajor authors and contributors' 31 | 32 | # The default replacements for |version| and |release|, also used in various 33 | # other places throughout the built documents. 34 | # 35 | # The short X.Y version. 36 | version = 'tip' 37 | # The full version, including alpha/beta/rc tags. 38 | release = 'tip' 39 | 40 | # There are two options for replacing |today|: either, you set today to some 41 | # non-false value, then it is used: 42 | #today = '' 43 | # Else, today_fmt is used as the format for a strftime call. 44 | today_fmt = '%B %d, %Y' 45 | 46 | # List of documents that shouldn't be included in the build. 47 | #unused_docs = [] 48 | 49 | # List of directories, relative to source directories, that shouldn't be searched 50 | # for source files. 51 | exclude_dirs = ['doctest'] 52 | 53 | # The reST default role (used for this markup: `text`) to use for all documents. 54 | #default_role = None 55 | 56 | # If true, '()' will be appended to :func: etc. cross-reference text. 57 | #add_function_parentheses = True 58 | 59 | # If true, the current module name will be prepended to all description 60 | # unit titles (such as .. function::). 61 | add_module_names = False 62 | 63 | # If true, sectionauthor and moduleauthor directives will be shown in the 64 | # output. They are ignored by default. 65 | #show_authors = False 66 | 67 | # The name of the Pygments (syntax highlighting) style to use. 68 | pygments_style = 'sphinx' 69 | 70 | # fails to parse :arg foo: in __init__ docs :( 71 | #autoclass_content = 'both' 72 | 73 | # Options for HTML output 74 | # ----------------------- 75 | 76 | # The style sheet to use for HTML and HTML Help pages. A file of that name 77 | # must exist either in Sphinx' static/ path, or in one of the custom paths 78 | # given in html_static_path. 79 | #html_style = 'default.css' 80 | 81 | # The name for this set of Sphinx documents. If None, it defaults to 82 | # " v documentation". 83 | #html_title = None 84 | 85 | # A shorter title for the navigation bar. Default is the same as html_title. 86 | #html_short_title = None 87 | 88 | # The name of an image file (within the static path) to place at the top of 89 | # the sidebar. 90 | #html_logo = None 91 | 92 | # The name of an image file (within the static path) to use as favicon of the 93 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 94 | # pixels large. 95 | #html_favicon = None 96 | 97 | # Add any paths that contain custom static files (such as style sheets) here, 98 | # relative to this directory. They are copied after the builtin static files, 99 | # so a file named "default.css" will overwrite the builtin "default.css". 100 | html_static_path = ['_static'] 101 | 102 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 103 | # using the given strftime format. 104 | html_last_updated_fmt = '%b %d, %Y' 105 | 106 | # If true, SmartyPants will be used to convert quotes and dashes to 107 | # typographically correct entities. 108 | #html_use_smartypants = True 109 | 110 | # Custom sidebar templates, maps document names to template names. 111 | #html_sidebars = {} 112 | 113 | # Additional templates that should be rendered to pages, maps page names to 114 | # template names. 115 | #html_additional_pages = {} 116 | 117 | # If false, no module index is generated. 118 | #html_use_modindex = True 119 | 120 | # If false, no index is generated. 121 | #html_use_index = True 122 | 123 | # If true, the index is split into individual pages for each letter. 124 | #html_split_index = False 125 | 126 | # If true, the reST sources are included in the HTML build as _sources/. 127 | #html_copy_source = True 128 | 129 | # If true, an OpenSearch description file will be output, and all pages will 130 | # contain a tag referring to it. The value of this option must be the 131 | # base URL from which the finished HTML is served. 132 | #html_use_opensearch = '' 133 | 134 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 135 | #html_file_suffix = '' 136 | 137 | # Output file base name for HTML help builder. 138 | htmlhelp_basename = 'alfajordoc' 139 | 140 | 141 | # Options for LaTeX output 142 | # ------------------------ 143 | 144 | # The paper size ('letter' or 'a4'). 145 | #latex_paper_size = 'letter' 146 | 147 | # The font size ('10pt', '11pt' or '12pt'). 148 | #latex_font_size = '10pt' 149 | 150 | # Grouping the document tree into LaTeX files. List of tuples 151 | # (source start file, target name, title, author, document class [howto/manual]). 152 | latex_documents = [ 153 | ('index', 'alfajor.tex', 'Alfajor Documentation', 154 | 'The Alfajor Team', 'manual'), 155 | ] 156 | 157 | # The name of an image file (relative to this directory) to place at the top of 158 | # the title page. 159 | #latex_logo = None 160 | 161 | # For "manual" documents, if this is true, then toplevel headings are parts, 162 | # not chapters. 163 | #latex_use_parts = False 164 | 165 | # Additional stuff for the LaTeX preamble. 166 | #latex_preamble = '' 167 | 168 | # Documents to append as an appendix to all manuals. 169 | #latex_appendices = [] 170 | 171 | # If false, no module index is generated. 172 | #latex_use_modindex = True 173 | 174 | autodoc_member_order = 'groupwise' 175 | -------------------------------------------------------------------------------- /alfajor/browsers/managers.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Bridges between test runners and functional browsers.""" 8 | 9 | from logging import getLogger 10 | 11 | from alfajor.utilities import ServerSubProcess, eval_dotted_path 12 | 13 | 14 | logger = getLogger('alfajor') 15 | 16 | 17 | def _verify_backend_config(config, required_keys): 18 | missing = [key for key in required_keys if key not in config] 19 | if not missing: 20 | return True 21 | missing_keys = ', '.join(missing) 22 | raise RuntimeError("Configuration is missing required keys %s" % 23 | missing_keys) 24 | 25 | 26 | class SeleniumManager(object): 27 | """TODO 28 | 29 | server_url 30 | cmd 31 | ping-address 32 | selenium-server 33 | 34 | """ 35 | 36 | def __init__(self, frontend_name, backend_config, runner_options): 37 | self.browser_type = frontend_name 38 | self.config = backend_config 39 | self.runner_options = runner_options 40 | self.process = None 41 | self.browser = None 42 | self.server_url = self._config('server_url', False) 43 | if not self.server_url: 44 | raise RuntimeError("'server_url' is a required configuration " 45 | "option for the Selenium backend.") 46 | 47 | def _config(self, key, *default): 48 | override = self.runner_options.get(key) 49 | if override: 50 | return override 51 | if key in self.config: 52 | return self.config[key] 53 | if default: 54 | return default[0] 55 | raise LookupError(key) 56 | 57 | def create(self): 58 | from alfajor.browsers.selenium import Selenium 59 | 60 | base_url = self.server_url 61 | if (self._config('without_server', False) or 62 | not self._config('cmd', False)): 63 | logger.debug("Connecting to existing URL %r", base_url) 64 | else: 65 | logger.debug("Starting service....") 66 | self.process = self.start_subprocess() 67 | logger.debug("Service started.") 68 | selenium_server = self._config('selenium-server', 69 | 'http://localhost:4444') 70 | self.browser = Selenium(selenium_server, self.browser_type, base_url) 71 | return self.browser 72 | 73 | def destroy(self): 74 | if self.browser and self.browser.selenium._session_id: 75 | try: 76 | self.browser.selenium.test_complete() 77 | except (KeyboardInterrupt, SystemExit): 78 | raise 79 | except: 80 | pass 81 | if self.process: 82 | self.process.stop() 83 | # avoid irritating __del__ exception on interpreter shutdown 84 | self.process = None 85 | self.browser = None 86 | 87 | def start_subprocess(self): 88 | cmd = self._config('cmd') 89 | ping = self._config('ping-address', None) 90 | 91 | logger.info("Starting server sub process with %s", cmd) 92 | process = ServerSubProcess(cmd, ping) 93 | process.start() 94 | return process 95 | 96 | 97 | class WSGIManager(object): 98 | """Lifecycle manager for global WSGI browsers.""" 99 | 100 | def __init__(self, frontend_name, backend_config, runner_options): 101 | self.config = backend_config 102 | 103 | def create(self): 104 | from alfajor.browsers.wsgi import WSGI 105 | 106 | entry_point = self.config['server-entry-point'] 107 | app = eval_dotted_path(entry_point) 108 | 109 | base_url = self.config.get('base_url') 110 | logger.debug("Created in-process WSGI browser.") 111 | return WSGI(app, base_url) 112 | 113 | def destroy(self): 114 | logger.debug("Destroying in-process WSGI browser.") 115 | 116 | 117 | class NetworkManager(object): 118 | """TODO 119 | 120 | server_url 121 | cmd 122 | ping-address 123 | 124 | """ 125 | 126 | def __init__(self, frontend_name, backend_config, runner_options): 127 | self.config = backend_config 128 | self.runner_options = runner_options 129 | self.process = None 130 | self.browser = None 131 | self.server_url = self._config('server_url', False) 132 | if not self.server_url: 133 | raise RuntimeError("'server_url' is a required configuration " 134 | "option for the Network backend.") 135 | 136 | def _config(self, key, *default): 137 | override = self.runner_options.get(key) 138 | if override: 139 | return override 140 | if key in self.config: 141 | return self.config[key] 142 | if default: 143 | return default[0] 144 | raise LookupError(key) 145 | 146 | def create(self): 147 | from alfajor.browsers.network import Network 148 | 149 | base_url = self.server_url 150 | if (self._config('without_server', False) or 151 | not self._config('cmd', False)): 152 | logger.debug("Connecting to existing URL %r", base_url) 153 | else: 154 | logger.debug("Starting service....") 155 | self.process = self.start_subprocess() 156 | logger.debug("Service started.") 157 | self.browser = Network(base_url) 158 | return self.browser 159 | 160 | def destroy(self): 161 | if self.process: 162 | self.process.stop() 163 | # avoid irritating __del__ exception on interpreter shutdown 164 | self.process = None 165 | self.browser = None 166 | 167 | def start_subprocess(self): 168 | cmd = self._config('cmd') 169 | ping = self._config('ping-address', None) 170 | 171 | logger.info("Starting server sub process with %s", cmd) 172 | process = ServerSubProcess(cmd, ping) 173 | process.start() 174 | return process 175 | 176 | 177 | class ZeroManager(object): 178 | """Lifecycle manager for global Zero browsers.""" 179 | 180 | def __init__(self, frontend_name, backend_config, runner_options): 181 | pass 182 | 183 | def create(self): 184 | from alfajor.browsers.zero import Zero 185 | logger.debug("Creating Zero browser.") 186 | return Zero() 187 | 188 | def destroy(self): 189 | logger.debug("Destroying Zero browser.") 190 | -------------------------------------------------------------------------------- /tests/browser/test_browser.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | import time 7 | 8 | from nose.tools import raises 9 | 10 | from . import browser, browser_test, screenshot_fails 11 | 12 | 13 | @browser_test() 14 | def test_simple(): 15 | browser.open('/') 16 | 17 | if 'status' in browser.capabilities: 18 | assert browser.status_code == 200 19 | assert browser.status == '200 OK' 20 | if 'headers' in browser.capabilities: 21 | assert 'text/html' in browser.headers['Content-Type'] 22 | assert not browser.cookies 23 | 24 | # This is generally not a safe assertion... the browser could (and does) 25 | # normalize the returned html in some fashion. 26 | assert browser.response == ('' 27 | '

hi there

') 28 | 29 | assert browser.document.cssselect('p')[0].text == 'hi there' 30 | 31 | 32 | @browser_test() 33 | def test_reset(): 34 | # TODO: flesh this out when cookie querying is working and has 35 | # test coverage. until then, just verify that the method doesn't 36 | # explode. 37 | browser.open('/') 38 | 39 | 40 | @browser_test() 41 | def test_user_agent(): 42 | browser.open('/') 43 | ua = browser.user_agent 44 | assert ua['browser'] != 'unknown' 45 | 46 | 47 | @browser_test() 48 | def test_traversal(): 49 | browser.open('/seq/a') 50 | a_id = browser.document['#request_id'].text 51 | assert browser.cssselect('title')[0].text == 'seq/a' 52 | assert browser.location.endswith('/seq/a') 53 | assert not browser.cssselect('p.referrer')[0].text 54 | 55 | browser.cssselect('a')[0].click(wait_for='page') 56 | b_id = browser.document['#request_id'].text 57 | assert a_id != b_id 58 | assert browser.cssselect('title')[0].text == 'seq/b' 59 | assert browser.location.endswith('/seq/b') 60 | assert '/seq/a' in browser.cssselect('p.referrer')[0].text 61 | 62 | # bounce through a redirect 63 | browser.cssselect('a')[0].click(wait_for='page') 64 | d_id = browser.document['#request_id'].text 65 | assert d_id != b_id 66 | assert browser.cssselect('title')[0].text == 'seq/d' 67 | assert browser.location.endswith('/seq/d') 68 | assert '/seq/b' in browser.cssselect('p.referrer')[0].text 69 | 70 | 71 | @browser_test() 72 | def _test_single_cookie(bounce): 73 | browser.open('/') 74 | assert not browser.cookies 75 | 76 | if bounce: 77 | landing_page = browser.location 78 | browser.open('/assign-cookie/1?bounce=%s' % landing_page) 79 | else: 80 | browser.open('/assign-cookie/1') 81 | 82 | assert browser.cookies == {'cookie1': 'value1'} 83 | 84 | browser.reset() 85 | assert not browser.cookies 86 | 87 | browser.open('/') 88 | assert not browser.cookies 89 | 90 | 91 | @browser_test() 92 | def test_single_cookie(): 93 | yield _test_single_cookie, False 94 | yield _test_single_cookie, True 95 | 96 | 97 | @browser_test() 98 | def _test_multiple_cookies(bounce): 99 | browser.open('/') 100 | assert not browser.cookies 101 | 102 | if bounce: 103 | landing_page = browser.location 104 | browser.open('/assign-cookie/2?bounce=%s' % landing_page) 105 | else: 106 | browser.open('/assign-cookie/2') 107 | 108 | assert browser.cookies == {'cookie1': 'value1', 109 | 'cookie2': 'value 2'} 110 | 111 | browser.reset() 112 | assert not browser.cookies 113 | 114 | browser.open('/') 115 | assert not browser.cookies 116 | 117 | 118 | @browser_test() 119 | def test_multiple_cookies(): 120 | yield _test_multiple_cookies, False 121 | yield _test_multiple_cookies, True 122 | 123 | 124 | @browser_test() 125 | def test_wait_for(): 126 | # bare minimum no side-effects call browser.wait_for 127 | browser.wait_for('duration', 1) 128 | 129 | 130 | @browser_test() 131 | def test_wait_for_duration(): 132 | if 'selenium' in browser.capabilities: 133 | start = time.time() 134 | browser.open('/waitfor', wait_for='duration', timeout=1000) 135 | duration = time.time() - start 136 | assert duration >= 1 137 | 138 | 139 | @browser_test() 140 | def test_wait_for_element(): 141 | if 'selenium' in browser.capabilities: 142 | browser.open('/waitfor') 143 | browser.cssselect('a#appender')[0].click( 144 | wait_for='element:css=#expected_p', timeout=3000) 145 | assert browser.cssselect('#expected_p') 146 | 147 | 148 | @browser_test() 149 | @raises(AssertionError) 150 | def test_wait_for_element_not_found(): 151 | if 'selenium' in browser.capabilities: 152 | browser.open('/waitfor') 153 | browser.wait_for('element:css=#unexisting', timeout=10) 154 | else: 155 | raise AssertionError('Ignore if not selenium') 156 | 157 | 158 | @browser_test() 159 | def test_wait_for_element_not_present(): 160 | if 'selenium' in browser.capabilities: 161 | browser.open('/waitfor') 162 | assert browser.cssselect('#removeme') 163 | browser.cssselect('#remover')[0].click( 164 | wait_for='!element:css=#removeme', timeout=3000) 165 | assert not browser.cssselect('#removeme') 166 | 167 | 168 | @browser_test() 169 | def test_wait_for_ajax(): 170 | if 'selenium' in browser.capabilities: 171 | browser.open('/waitfor') 172 | browser.cssselect('#ajaxappender')[0].click( 173 | wait_for='ajax', timeout=3000) 174 | assert len(browser.cssselect('.ajaxAdded')) == 3 175 | 176 | 177 | @browser_test() 178 | def test_wait_for_js(): 179 | if 'selenium' in browser.capabilities: 180 | browser.open('/waitfor') 181 | browser.cssselect('#counter')[0].click( 182 | wait_for='js:window.exampleCount==100;', timeout=3000) 183 | 184 | 185 | @browser_test() 186 | def test_set_cookie(): 187 | if 'cookies' in browser.capabilities: 188 | browser.open('/') 189 | 190 | browser.set_cookie('foo', 'bar') 191 | browser.set_cookie('py', 'py', 'localhost.local', port='8008') 192 | browser.set_cookie('green', 'frog', 193 | session=False, expires=time.time() + 3600) 194 | assert 'foo' in browser.cookies 195 | assert 'py' in browser.cookies 196 | assert 'green' in browser.cookies 197 | 198 | 199 | @browser_test() 200 | @screenshot_fails('test_screenshot.png') 201 | def test_screenshot(): 202 | if 'javascript' not in browser.capabilities: 203 | return 204 | browser.open('http://www.google.com') 205 | assert False 206 | -------------------------------------------------------------------------------- /alfajor/browsers/_waitexpr.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Compound wait_for expression support.""" 8 | 9 | __all__ = 'WaitExpression', 'SeleniumWaitExpression' 10 | 11 | OR = object() 12 | 13 | 14 | class WaitExpression(object): 15 | """Generic wait_for expression generator and compiler. 16 | 17 | Expression objects chain in a jQuery/SQLAlchemy-esque fashion:: 18 | 19 | expr = (browser.wait_expression(). 20 | element_present('#druid'). 21 | ajax_complete()) 22 | 23 | Or can be configured at instantiation: 24 | 25 | expr = browser.wait_expression(['element_present', '#druid'], 26 | ['ajax_complete']) 27 | 28 | Expression components are and-ed (&&) together. To or (||), separate 29 | components with :meth:`or_`:: 30 | 31 | element_present('#druid').or_().ajax_complete() 32 | 33 | The expression object can be supplied to any operation which accepts 34 | a ``wait_for`` argument. 35 | 36 | """ 37 | 38 | def __init__(self, *expressions): 39 | for spec in expressions: 40 | directive = spec[0] 41 | args = spec[1:] 42 | getattr(self, directive)(*args) 43 | 44 | def or_(self): 45 | """Combine the next expression with an OR instead of default AND.""" 46 | return self 47 | 48 | def element_present(self, finder): 49 | """True if *finder* is present on the page. 50 | 51 | :param finder: a CSS selector or document element instance 52 | 53 | """ 54 | return self 55 | 56 | def element_not_present(self, expr): 57 | """True if *finder* is not present on the page. 58 | 59 | :param finder: a CSS selector or document element instance 60 | 61 | """ 62 | return self 63 | 64 | def evaluate_element(self, finder, expr): 65 | """True if *finder* is present on the page and evaluated by *expr*. 66 | 67 | :param finder: a CSS selector or document element instance 68 | 69 | :param expr: literal JavaScript text; should evaluate to true or 70 | false. The variable ``element`` will hold the *finder* DOM element, 71 | and ``window`` is the current window. 72 | 73 | """ 74 | return self 75 | 76 | def ajax_pending(self): 77 | """True if jQuery ajax requests are pending.""" 78 | return self 79 | 80 | def ajax_complete(self): 81 | """True if no jQuery ajax requests are pending.""" 82 | return self 83 | 84 | def __unicode__(self): 85 | """The rendered value of the expression.""" 86 | return u'' 87 | 88 | 89 | class SeleniumWaitExpression(WaitExpression): 90 | """Compound wait_for expression compiler for Selenium browsers.""" 91 | 92 | def __init__(self, *expressions): 93 | self._expressions = [] 94 | WaitExpression.__init__(self, *expressions) 95 | 96 | def or_(self): 97 | self._expressions.append(OR) 98 | return self 99 | 100 | def element_present(self, finder): 101 | js = self._is_element_present('element_present', finder, 'true') 102 | self._expressions.append(js) 103 | return self 104 | 105 | def element_not_present(self, finder): 106 | js = self._is_element_present('element_not_present', finder, 'false') 107 | self._expressions.append(js) 108 | return self 109 | 110 | def evaluate_element(self, finder, expr): 111 | locator = to_locator(finder) 112 | log = evaluation_log('evaluate_element', 'result', locator, expr) 113 | js = """\ 114 | (function () { 115 | var element; 116 | try { 117 | element = selenium.browserbot.findElement('%s'); 118 | } catch (e) { 119 | element = null; 120 | }; 121 | var result = false; 122 | if (element != null) 123 | result = %s; 124 | %s 125 | return result; 126 | })()""" % (js_quote(locator), expr, log) 127 | self._expressions.append(js) 128 | return self 129 | 130 | def ajax_pending(self): 131 | js = """\ 132 | (function() { 133 | var pending = window.jQuery && window.jQuery.active != 0; 134 | %s 135 | return pending; 136 | })()""" % predicate_log('ajax_pending', 'complete') 137 | self._expressions.append(js) 138 | return self 139 | 140 | def ajax_complete(self): 141 | js = """\ 142 | (function() { 143 | var complete = window.jQuery ? window.jQuery.active == 0 : true; 144 | %s 145 | return complete; 146 | })()""" % predicate_log('ajax_complete', 'complete') 147 | self._expressions.append(js) 148 | return self 149 | 150 | def _is_element_present(self, label, finder, result): 151 | locator = to_locator(finder) 152 | log = evaluation_log(label, 'found', locator) 153 | return u"""\ 154 | (function () { 155 | var found = true; 156 | try { 157 | selenium.browserbot.findElement('%s'); 158 | } catch (e) { 159 | found = false; 160 | }; 161 | %s 162 | return found == %s; 163 | })()""" % (js_quote(locator), log, result) 164 | 165 | def __unicode__(self): 166 | last = None 167 | components = [] 168 | for expr in self._expressions: 169 | if expr is OR: 170 | components.append(u'||') 171 | else: 172 | if last not in (None, OR): 173 | components.append(u'&&') 174 | components.append(expr) 175 | last = expr 176 | predicate = u' '.join(components).replace('\n', ' ') 177 | return predicate 178 | 179 | 180 | def js_quote(string): 181 | """Prepare a string for use in a 'single quoted' JS literal.""" 182 | string = string.replace('\\', r'\\') 183 | string = string.replace('\'', r'\'') 184 | return string 185 | 186 | 187 | def to_locator(expr): 188 | """Convert a css selector or document element into a selenium locator.""" 189 | if isinstance(expr, basestring): 190 | return 'css=' + expr 191 | elif hasattr(expr, '_locator'): 192 | return expr._locator 193 | else: 194 | raise RuntimeError("Unknown page element %r" % expr) 195 | 196 | 197 | def predicate_log(label, result_variable): 198 | """Return JS for logging a boolean result test in the Selenium console.""" 199 | js = "LOG.info('wait_for %s ==' + %s);" % ( 200 | js_quote(label), result_variable) 201 | return js 202 | 203 | 204 | def evaluation_log(label, result_variable, *args): 205 | """Return JS for logging an expression eval in the Selenium console.""" 206 | inner = ', '.join(map(js_quote, args)) 207 | js = "LOG.info('wait_for %s(%s)=' + %s);" % ( 208 | js_quote(label), inner, result_variable) 209 | return js 210 | -------------------------------------------------------------------------------- /alfajor/runners/nose.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Integration with the 'nose' test runner.""" 8 | 9 | from __future__ import absolute_import 10 | from base64 import b64decode 11 | from logging import getLogger 12 | from optparse import OptionGroup 13 | from os import path 14 | 15 | from nose.plugins.base import Plugin 16 | 17 | from alfajor._management import ManagerLookupError, new_manager 18 | 19 | 20 | logger = getLogger('nose.plugins') 21 | 22 | 23 | class Alfajor(Plugin): 24 | 25 | name = 'alfajor' 26 | enabled = True # FIXME 27 | alfajor_enabled_screenshot = False 28 | 29 | def __init__(self): 30 | Plugin.__init__(self) 31 | self._contexts = [] 32 | 33 | def options(self, parser, env): 34 | group = OptionGroup(parser, "Alfajor options") 35 | group.add_option('-B', '--browser', 36 | dest='alfajor_browser_frontend', 37 | metavar='ALFAJOR_BROWSER', 38 | default=env.get('ALFAJOR_BROWSER'), 39 | help='Run functional tests with ALFAJOR_BROWSER ' 40 | '[ALFAJOR_BROWSER]') 41 | group.add_option('--alfajor-apiclient', 42 | dest='alfajor_apiclient_frontend', 43 | metavar='ALFAJOR_BROWSER', 44 | default=env.get('ALFAJOR_BROWSER'), 45 | help='Run functional tests with ALFAJOR_BROWSER ' 46 | '[ALFAJOR_BROWSER]') 47 | group.add_option('--alfajor-config', 48 | dest='alfajor_ini_file', 49 | metavar='ALFAJOR_CONFIG', 50 | default=env.get('ALFAJOR_CONFIG'), 51 | help='Specify the name of your configuration file,' 52 | 'which can be any path on the system. Defaults to' 53 | 'alfajor.ini' 54 | '[ALFAJOR_CONFIG]') 55 | parser.add_option_group(group) 56 | 57 | group = OptionGroup(parser, "Alfajor Selenium backend options") 58 | group.add_option('--without-server', 59 | dest='alfajor_without_server', 60 | metavar='WITHOUT_SERVER', 61 | action='store_true', 62 | default=env.get('ALFAJOR_WITHOUT_SERVER', False), 63 | help='Run functional tests against an already ' 64 | 'running web server rather than start a new server ' 65 | 'process.' 66 | '[ALFAJOR_EXTERNAL_SERVER]') 67 | group.add_option('--server-url', 68 | dest='alfajor_server_url', 69 | metavar='SERVER_URL', 70 | default=env.get('ALFAJOR_SERVER_URL', None), 71 | help='Run functional tests against this URL, ' 72 | 'overriding all file-based configuration.' 73 | '[ALFAJOR_SERVER_URL]') 74 | parser.add_option_group(group) 75 | 76 | group = OptionGroup(parser, "Alfajor Screenshot Options") 77 | group.add_option( 78 | "--screenshot", action="store_true", 79 | dest="alfajor_enabled_screenshot", 80 | default=env.get('ALFAJOR_SCREENSHOT', False), 81 | help="Take screenshots of failed pages") 82 | group.add_option( 83 | "--screenshot-dir", 84 | dest="alfajor_screenshot_dir", 85 | default=env.get('ALFAJOR_SCREENSHOT_DIR', ''), 86 | help="Dir to store screenshots") 87 | parser.add_option_group(group) 88 | 89 | def configure(self, options, config): 90 | Plugin.configure(self, options, config) 91 | alfajor_options = {} 92 | for key, value in vars(options).iteritems(): 93 | if key.startswith('alfajor_'): 94 | short = key[len('alfajor_'):] 95 | alfajor_options[short] = value 96 | self.options = alfajor_options 97 | 98 | def startContext(self, context): 99 | try: 100 | setups = context.__alfajor_setup__ 101 | except AttributeError: 102 | return 103 | if not setups: 104 | return 105 | managers = set() 106 | 107 | logger.info("Processing alfajor functional browsing for context %r", 108 | context.__name__) 109 | 110 | for declaration in setups: 111 | configuration = declaration.configuration 112 | logger.info("Enabling alfajor %s in configuration %s", 113 | declaration.tool, configuration) 114 | 115 | try: 116 | manager = new_manager(declaration, self.options, logger) 117 | except ManagerLookupError, exc: 118 | logger.warn("Skipping setup of %s in context %r: %r", 119 | declaration.tool, context, exc.args[0]) 120 | continue 121 | managers.add((manager, declaration)) 122 | declaration.proxy._factory = manager.create 123 | if managers: 124 | self._contexts.append((context, managers)) 125 | 126 | def stopContext(self, context): 127 | # self._contexts is a list of tuples, [0] is the context key 128 | if self._contexts and context == self._contexts[-1][0]: 129 | key, managers = self._contexts.pop(-1) 130 | for manager, declaration in managers: 131 | manager.destroy() 132 | declaration.proxy._instance = None 133 | declaration.proxy._factory = None 134 | 135 | def addError(self, test, err): 136 | self.screenshotIfEnabled(test) 137 | 138 | def addFailure(self, test, err): 139 | self.screenshotIfEnabled(test) 140 | 141 | def screenshotIfEnabled(self, test): 142 | if self.options['enabled_screenshot']: 143 | selenium = self._getSelenium() 144 | if selenium: 145 | self.screenshot(selenium, test) 146 | 147 | def _getSelenium(self): 148 | """Get the selenium instance for this test if one exists. 149 | 150 | Otherwise return None. 151 | """ 152 | assert self._contexts 153 | contexts, managers = self._contexts[-1] 154 | for manager, declaration in managers: 155 | instance = declaration.proxy._instance 156 | if hasattr(instance, 'selenium'): 157 | return instance.selenium 158 | return None 159 | 160 | def screenshot(self, selenium, test): 161 | img = selenium.capture_entire_page_screenshot_to_string() 162 | test_name = test.id().split('.')[-1] 163 | directory = self.options['screenshot_dir'] 164 | output_file = open('/'.join( 165 | [path.abspath(directory), test_name + '.png']), "w") 166 | output_file.write(b64decode(img)) 167 | output_file.close() 168 | -------------------------------------------------------------------------------- /alfajor/utilities.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Utilities useful for managing functional browsers and HTTP clients.""" 8 | 9 | import inspect 10 | import sys 11 | import time 12 | 13 | __all__ = ['ServerSubProcess', 'eval_dotted_path', 'invoke'] 14 | 15 | 16 | def _import(module_name): 17 | """Import a module by name.""" 18 | local_name = module_name.split('.')[-1] 19 | return __import__(module_name, {}, {}, local_name) 20 | 21 | 22 | def _import_some(dotted_path): 23 | """Import as much of dotted.path as possible, returning module and 24 | remainder.""" 25 | steps = list(dotted_path.split('.')) 26 | modname = [steps.pop(0)] 27 | mod = _import(modname[0]) 28 | while steps: 29 | try: 30 | mod = _import('.'.join(modname + steps[:1])) 31 | except ImportError: 32 | break 33 | else: 34 | modname.append(steps.pop(0)) 35 | return mod, '.'.join(steps) 36 | 37 | 38 | def eval_dotted_path(string): 39 | """module.member.member or module.module:evaled.in.module""" 40 | 41 | if ':' not in string: 42 | mod, expr = _import_some(string) 43 | else: 44 | modname, expr = string.split(':', 1) 45 | mod = _import(modname) 46 | if expr: 47 | return eval(expr, mod.__dict__) 48 | else: 49 | return mod 50 | 51 | 52 | class lazy_property(object): 53 | """An efficient, memoized @property.""" 54 | 55 | def __init__(self, fn): 56 | self.fn = fn 57 | self.__name__ = fn.func_name 58 | self.__doc__ = fn.__doc__ 59 | 60 | def __get__(self, obj, cls): 61 | if obj is None: 62 | return None 63 | obj.__dict__[self.__name__] = result = self.fn(obj) 64 | return result 65 | 66 | 67 | def to_pairs(dictlike): 68 | """Yield (key, value) pairs from any dict-like object. 69 | 70 | Implements an optimized version of the dict.update() definition of 71 | "dictlike". 72 | 73 | """ 74 | if hasattr(dictlike, 'items'): 75 | return dictlike.items() 76 | elif hasattr(dictlike, 'keys'): 77 | return [(key, dictlike[key]) for key in dictlike.keys()] 78 | else: 79 | return [(key, value) for key, value in dictlike] 80 | 81 | 82 | def _optargs_to_kwargs(args): 83 | """Convert --bar-baz=quux --xyzzy --no-squiz to kwargs-compatible pairs. 84 | 85 | E.g., [('bar_baz', 'quux'), ('xyzzy', True), ('squiz', False)] 86 | 87 | """ 88 | kwargs = [] 89 | for arg in args: 90 | if not arg.startswith('--'): 91 | raise RuntimeError("Unknown option %r" % arg) 92 | elif '=' in arg: 93 | key, value = arg.split('=', 1) 94 | key = key[2:].replace('-', '_') 95 | if value.isdigit(): 96 | value = int(value) 97 | elif arg.startswith('--no-'): 98 | key, value = arg[5:].replace('-', '_'), False 99 | else: 100 | key, value = arg[2:].replace('-', '_'), True 101 | kwargs.append((key, value)) 102 | return kwargs 103 | 104 | 105 | def invoke(): 106 | """Load and execute a Python function from the command line. 107 | 108 | Functions may be specified in dotted-path/eval syntax, in which case the 109 | expression should evaluate to a callable function: 110 | 111 | module.name:pythoncode.to.eval 112 | 113 | Or by module name alone, in which case the function 'main' is invoked in 114 | the named module. 115 | 116 | module.name 117 | 118 | If configuration files are provided, they will be read and all items from 119 | [defaults] will be passed to the function as keyword arguments. 120 | 121 | """ 122 | def croak(msg): 123 | print >> sys.stderr, msg 124 | sys.exit(1) 125 | usage = "Usage: %s module.name OR module:callable" 126 | 127 | target, args = None, [] 128 | try: 129 | for arg in sys.argv[1:]: 130 | if arg.startswith('-'): 131 | args.append(arg) 132 | else: 133 | if target: 134 | raise RuntimeError 135 | target = arg 136 | if not target: 137 | raise RuntimeError 138 | except RuntimeError: 139 | croak(usage + "\n" + inspect.cleandoc(invoke.__doc__)) 140 | clean = _optargs_to_kwargs(args) 141 | kwargs = dict(clean) 142 | 143 | try: 144 | hook = eval_dotted_path(target) 145 | except (NameError, ImportError), exc: 146 | croak("Could not invoke %r: %r" % (target, exc)) 147 | 148 | if isinstance(hook, type(sys)) and hasattr(hook, 'main'): 149 | hook = hook.main 150 | if not callable(hook): 151 | croak("Entrypoint %r is not a callable function or " 152 | "module with a main() function.") 153 | 154 | retval = hook(**kwargs) 155 | sys.exit(retval) 156 | 157 | 158 | class ServerSubProcess(object): 159 | """Starts and stops subprocesses.""" 160 | 161 | def __init__(self, cmd, ping=None): 162 | self.cmd = cmd 163 | self.process = None 164 | if not ping: 165 | self.host = self.port = None 166 | else: 167 | if ':' in ping: 168 | self.host, port = ping.split(':', 1) 169 | self.port = int(port) 170 | else: 171 | self.host = ping 172 | self.port = 80 173 | 174 | def start(self): 175 | """Start the process.""" 176 | import shlex 177 | from subprocess import Popen, PIPE, STDOUT 178 | 179 | if self.process: 180 | raise RuntimeError("Process already started.") 181 | if self.host and self.network_ping(): 182 | raise RuntimeError("A process is already running on port %s" % 183 | self.port) 184 | 185 | if isinstance(self.cmd, basestring): 186 | cmd = shlex.split(self.cmd) 187 | else: 188 | cmd = self.cmd 189 | process = Popen(cmd, stdout=PIPE, stderr=STDOUT, close_fds=True) 190 | 191 | if not self.host: 192 | time.sleep(0.35) 193 | if process.poll(): 194 | output = process.communicate()[0] 195 | raise RuntimeError("Did not start server! Woe!\n" + output) 196 | self.process = process 197 | return 198 | 199 | start = time.time() 200 | while process.poll() is None and time.time() - start < 15: 201 | if self.network_ping(): 202 | break 203 | else: 204 | output = process.communicate()[0] 205 | raise RuntimeError("Did not start server! Woe!\n" + output) 206 | self.process = process 207 | 208 | def stop(self): 209 | """Stop the process.""" 210 | if not self.process: 211 | return 212 | try: 213 | self.process.terminate() 214 | except AttributeError: 215 | import os 216 | import signal 217 | os.kill(self.process.pid, signal.SIGQUIT) 218 | for i in xrange(20): 219 | if self.process.poll() is not None: 220 | break 221 | time.sleep(0.1) 222 | else: 223 | try: 224 | self.process.kill() 225 | except AttributeError: 226 | import os 227 | os.kill(self.process.pid, signal.SIGKILL) 228 | self.process = None 229 | 230 | def network_ping(self): 231 | """Return True if the :attr:`host` accepts connects on :attr:`port`.""" 232 | import socket 233 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 234 | try: 235 | sock.connect((self.host, self.port)) 236 | sock.shutdown(socket.SHUT_RDWR) 237 | except (IOError, socket.error): 238 | return False 239 | else: 240 | return True 241 | finally: 242 | del sock 243 | -------------------------------------------------------------------------------- /alfajor/_management.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Routines for discovering and preparing backend managers.""" 8 | import inspect 9 | from logging import getLogger 10 | from os import path 11 | 12 | from alfajor.utilities import eval_dotted_path 13 | from alfajor._config import Configuration 14 | 15 | 16 | __all__ = [ 17 | 'APIClient', 18 | 'ManagerLookupError', 19 | 'WebBrowser', 20 | 'new_manager', 21 | ] 22 | 23 | _default_logger = getLogger('alfajor') 24 | 25 | managers = { 26 | 'browser': { 27 | 'selenium': 'alfajor.browsers.managers:SeleniumManager', 28 | 'wsgi': 'alfajor.browsers.managers:WSGIManager', 29 | 'network': 'alfajor.browsers.managers:NetworkManager', 30 | 'zero': 'alfajor.browsers.managers:ZeroManager', 31 | }, 32 | 'apiclient': { 33 | 'wsgi': 'alfajor.apiclient:WSGIClientManager', 34 | }, 35 | } 36 | 37 | 38 | try: 39 | import pkg_resources 40 | except ImportError: 41 | pass 42 | else: 43 | for tool in 'browser', 'apiclient': 44 | group = 'alfajor.' + tool 45 | for entrypoint in pkg_resources.iter_entry_points(group=group): 46 | try: 47 | entry = entrypoint.load() 48 | except Exception, exc: 49 | _default_logger.error("Error loading %s: %s", entrypoint, exc) 50 | else: 51 | managers[tool][entrypoint.name] = entry 52 | 53 | 54 | class ManagerLookupError(Exception): 55 | """Raised if a declaration could not be resolved.""" 56 | 57 | 58 | def new_manager(declaration, runner_options, logger=None): 59 | try: 60 | factory = _ManagerFactory(declaration, runner_options, logger) 61 | return factory.get_instance() 62 | except (KeyboardInterrupt, SystemExit): 63 | raise 64 | except Exception, exc: 65 | raise ManagerLookupError(exc) 66 | 67 | 68 | class _DeferredProxy(object): 69 | """Fronts for another, created-on-demand instance.""" 70 | 71 | def __init__(self): 72 | self._factory = None 73 | self._instance = None 74 | 75 | def _get_instance(self): 76 | if self._instance is not None: # pragma: nocover 77 | return self._instance 78 | if self._factory is None: 79 | raise RuntimeError("%s is not configured." % type(self).__name__) 80 | self._instance = instance = self._factory() 81 | return instance 82 | 83 | def __getattr__(self, key): 84 | if self._instance is None: 85 | instance = self._get_instance() 86 | else: 87 | instance = self._instance 88 | return getattr(instance, key) 89 | 90 | def configure_in_scope(self, configuration='default', default_target=None, 91 | ini_file=None): 92 | namespace = inspect.stack()[1][0].f_globals 93 | setups = namespace.setdefault('__alfajor_setup__', []) 94 | configuration = Declaration(proxy=self, 95 | configuration=configuration, 96 | default_target=default_target, 97 | ini_file=ini_file, 98 | tool=self.tool, 99 | declared_in=namespace.get('__file__')) 100 | setups.append(configuration) 101 | 102 | 103 | class WebBrowser(_DeferredProxy): 104 | """A web browser for functional tests. 105 | 106 | Acts as a shell around a specific backend browser implementation, 107 | allowing a browser instance to be imported into a test module's 108 | namespace before configuration has been processed. 109 | 110 | """ 111 | tool = 'browser' 112 | 113 | def __contains__(self, needle): 114 | browser = self._get_instance() 115 | return needle in browser 116 | 117 | 118 | class APIClient(_DeferredProxy): 119 | """A wire-level HTTP client for functional tests. 120 | 121 | Acts as a shell around a demand-loaded backend implementation, allowing a 122 | client instance to be imported into a test module's namespace before 123 | configuration has been processed. 124 | """ 125 | tool = 'apiclient' 126 | 127 | 128 | class Declaration(object): 129 | 130 | def __init__(self, proxy, configuration, default_target, ini_file, 131 | tool, declared_in): 132 | self.proxy = proxy 133 | self.configuration = configuration 134 | self.default_target = default_target 135 | self.ini_file = ini_file 136 | self.tool = tool 137 | self.declared_in = declared_in 138 | 139 | 140 | class _ManagerFactory(object): 141 | """Encapsulates the process of divining and loading a backend manager.""" 142 | _configs = {} 143 | 144 | def __init__(self, declaration, runner_options, logger=None): 145 | self.declaration = declaration 146 | self.runner_options = runner_options 147 | self.logger = logger or _default_logger 148 | self.config = self._get_configuration(declaration) 149 | self.name = declaration.configuration 150 | 151 | def get_instance(self): 152 | """Return a ready to instantiate backend manager callable. 153 | 154 | Will raise errors if problems are encountered during discovery. 155 | 156 | """ 157 | frontend_name = self._get_frontend_name() 158 | backend_name = self._get_backend_name(frontend_name) 159 | tool = self.declaration.tool 160 | 161 | try: 162 | manager_factory = self._load_backend(tool, backend_name) 163 | except KeyError: 164 | raise KeyError("No known backend %r in configuration %r" % ( 165 | backend_name, self.config.source)) 166 | 167 | backend_config = self.config.get_section( 168 | self.name, template='%(name)s+%(tool)s.%(backend)s', 169 | tool=tool, backend=backend_name, 170 | logger=self.logger, fallback='default') 171 | 172 | return manager_factory(frontend_name, 173 | backend_config, 174 | self.runner_options) 175 | 176 | def _get_configuration(self, declaration): 177 | """Return a Configuration applicable to *declaration*. 178 | 179 | Configuration may come from a declaration option, a runner option 180 | or the default. 181 | 182 | """ 183 | # --alfajor-config overrides any config data in code 184 | if self.runner_options['ini_file']: 185 | finder = self.runner_options['ini_file'] 186 | # if not configured in code, look for 'alfajor.ini' or a declared path 187 | # relative to the file the declaration was made in. 188 | else: 189 | finder = path.abspath( 190 | path.join(path.dirname(declaration.declared_in), 191 | (declaration.ini_file or 'alfajor.ini'))) 192 | # TODO: empty config 193 | try: 194 | return self._configs[finder] 195 | except KeyError: 196 | config = Configuration(finder) 197 | self._configs[finder] = config 198 | return config 199 | 200 | def _get_frontend_name(self): 201 | """Return the frontend requested by the runner or declaration.""" 202 | runner_override = self.declaration.tool + '_frontend' 203 | frontend = self.runner_options.get(runner_override) 204 | if not frontend: 205 | frontend = self.declaration.default_target 206 | if not frontend: 207 | frontend = 'default' 208 | return frontend 209 | 210 | def _get_backend_name(self, frontend): 211 | """Return the backend name for *frontend*.""" 212 | if frontend == 'default': 213 | defaults = self.config.get_section('default-targets', default={}) 214 | key = '%s+%s' % (self.declaration.configuration, 215 | self.declaration.tool) 216 | if key not in defaults: 217 | key = 'default+%s' % (self.declaration.tool,) 218 | try: 219 | frontend = defaults[key] 220 | except KeyError: 221 | raise LookupError("No default target declared.") 222 | mapping = self.config.get_section(self.name, fallback='default') 223 | try: 224 | return mapping[frontend] 225 | except KeyError: 226 | return mapping['*'] 227 | 228 | def _load_backend(self, tool, backend): 229 | """Load a *backend* callable for *tool*. 230 | 231 | Consults the [tool.backends] section of the active configuration 232 | first for a "tool = evalable.dotted:path" entry. If not found, 233 | looks in the process-wide registry of built-in and pkg_resources 234 | managed backends. 235 | 236 | A config entry will override an equivalently named process entry. 237 | 238 | """ 239 | point_of_service_managers = self.config.get_section( 240 | '%(tool)s.backends', default={}, logger=self.logger, 241 | tool=tool) 242 | try: 243 | entry = point_of_service_managers[backend] 244 | except KeyError: 245 | pass 246 | else: 247 | if callable(entry): 248 | return entry 249 | else: 250 | return eval_dotted_path(entry) 251 | 252 | entry = managers[tool][backend] 253 | if callable(entry): 254 | return entry 255 | fn = eval_dotted_path(entry) 256 | managers[tool].setdefault(backend, fn) 257 | return fn 258 | -------------------------------------------------------------------------------- /alfajor/apiclient.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """A low-level HTTP client suitable for testing APIs.""" 8 | import copy 9 | from cStringIO import StringIO 10 | import dummy_threading 11 | from cookielib import DefaultCookiePolicy 12 | from logging import DEBUG, getLogger 13 | import mimetypes 14 | from urllib import urlencode 15 | from urlparse import urlparse, urlunparse 16 | from wsgiref.util import request_uri 17 | 18 | from werkzeug import BaseResponse, Headers, create_environ, run_wsgi_app 19 | from werkzeug.test import _TestCookieJar, encode_multipart 20 | 21 | from alfajor.utilities import eval_dotted_path 22 | from alfajor._compat import json_loads as loads 23 | 24 | 25 | logger = getLogger(__name__) 26 | 27 | _json_content_types = set([ 28 | 'application/json', 29 | 'application/x-javascript', 30 | 'text/javascript', 31 | 'text/x-javascript', 32 | 'text/x-json', 33 | ]) 34 | 35 | 36 | class WSGIClientManager(object): 37 | """Lifecycle manager for global api clients.""" 38 | 39 | def __init__(self, frontend_name, backend_config, runner_options): 40 | self.config = backend_config 41 | 42 | def create(self): 43 | from alfajor.apiclient import APIClient 44 | 45 | entry_point = self.config['server-entry-point'] 46 | app = eval_dotted_path(entry_point) 47 | 48 | base_url = self.config.get('base_url') 49 | logger.debug("Created in-process WSGI api client rooted at %s.", 50 | base_url) 51 | return APIClient(app, base_url=base_url) 52 | 53 | def destroy(self): 54 | logger.debug("Destroying in-process WSGI api client.") 55 | 56 | 57 | class APIClient(object): 58 | 59 | def __init__(self, application, state=None, base_url=None): 60 | self.application = application 61 | self.state = state or _APIClientState(application) 62 | self.base_url = base_url 63 | 64 | def open(self, path='/', base_url=None, query_string=None, method='GET', 65 | data=None, input_stream=None, content_type=None, 66 | content_length=0, errors_stream=None, multithread=False, 67 | multiprocess=False, run_once=False, environ_overrides=None, 68 | buffered=True): 69 | 70 | parsed = urlparse(path) 71 | if parsed.scheme: 72 | if base_url is None: 73 | base_url = parsed.scheme + '://' + parsed.netloc 74 | if query_string is None: 75 | query_string = parsed.query 76 | path = parsed.path 77 | 78 | if (input_stream is None and 79 | data is not None and 80 | method in ('PUT', 'POST')): 81 | input_stream, content_length, content_type = \ 82 | self._prep_input(input_stream, data, content_type) 83 | 84 | if base_url is None: 85 | base_url = self.base_url or self.state.base_url 86 | 87 | environ = create_environ(path, base_url, query_string, method, 88 | input_stream, content_type, content_length, 89 | errors_stream, multithread, 90 | multiprocess, run_once) 91 | 92 | current_state = self.state 93 | current_state.prepare_environ(environ) 94 | if environ_overrides: 95 | environ.update(environ_overrides) 96 | 97 | logger.info("%s %s" % (method, request_uri(environ))) 98 | rv = run_wsgi_app(self.application, environ, buffered=buffered) 99 | 100 | response = _APIClientResponse(*rv) 101 | response.state = new_state = current_state.copy() 102 | new_state.process_response(response, environ) 103 | return response 104 | 105 | def get(self, *args, **kw): 106 | """:meth:`open` as a GET request.""" 107 | kw['method'] = 'GET' 108 | return self.open(*args, **kw) 109 | 110 | def post(self, *args, **kw): 111 | """:meth:`open` as a POST request.""" 112 | kw['method'] = 'POST' 113 | return self.open(*args, **kw) 114 | 115 | def head(self, *args, **kw): 116 | """:meth:`open` as a HEAD request.""" 117 | kw['method'] = 'HEAD' 118 | return self.open(*args, **kw) 119 | 120 | def put(self, *args, **kw): 121 | """:meth:`open` as a PUT request.""" 122 | kw['method'] = 'PUT' 123 | return self.open(*args, **kw) 124 | 125 | def delete(self, *args, **kw): 126 | """:meth:`open` as a DELETE request.""" 127 | kw['method'] = 'DELETE' 128 | return self.open(*args, **kw) 129 | 130 | def wrap_file(self, fd, filename=None, mimetype=None): 131 | """Wrap a file for use in POSTing or PUTing. 132 | 133 | :param fd: a file name or file-like object 134 | :param filename: file name to send in the HTTP request 135 | :param mimetype: mime type to send, guessed if not supplied. 136 | """ 137 | return File(fd, filename, mimetype) 138 | 139 | def _prep_input(self, input_stream, data, content_type): 140 | if isinstance(data, basestring): 141 | assert content_type is not None, 'content type required' 142 | else: 143 | need_multipart = False 144 | pairs = [] 145 | debugging = logger.isEnabledFor(DEBUG) 146 | for key, value in _to_pairs(data): 147 | if isinstance(value, basestring): 148 | if isinstance(value, unicode): 149 | value = str(value) 150 | if debugging: 151 | logger.debug("%r=%r" % (key, value)) 152 | pairs.append((key, value)) 153 | continue 154 | need_multipart = True 155 | if isinstance(value, tuple): 156 | pairs.append((key, File(*value))) 157 | elif isinstance(value, dict): 158 | pairs.append((key, File(**value))) 159 | elif not isinstance(value, File): 160 | pairs.append((key, File(value))) 161 | else: 162 | pairs.append((key, value)) 163 | if need_multipart: 164 | boundary, data = encode_multipart(pairs) 165 | if content_type is None: 166 | content_type = 'multipart/form-data; boundary=' + \ 167 | boundary 168 | else: 169 | data = urlencode(pairs) 170 | logger.debug('data: ' + data) 171 | if content_type is None: 172 | content_type = 'application/x-www-form-urlencoded' 173 | content_length = len(data) 174 | input_stream = StringIO(data) 175 | return input_stream, content_length, content_type 176 | 177 | 178 | class _APIClientResponse(object): 179 | state = None 180 | 181 | @property 182 | def client(self): 183 | """A new client born from this response. 184 | 185 | The client will have access to any cookies that were sent as part 186 | of this response & send this response's URL as a referrer. 187 | 188 | Each access to this property returns an independent client with its 189 | own copy of the cookie jar. 190 | 191 | """ 192 | state = self.state 193 | return APIClient(application=state.application, state=state) 194 | 195 | status_code = BaseResponse.status_code 196 | 197 | @property 198 | def request_uri(self): 199 | """The source URI for this response.""" 200 | return request_uri(self.state.source_environ) 201 | 202 | @property 203 | def is_json(self): 204 | """True if the response is JSON and the HTTP status was 200.""" 205 | return (self.status_code == 200 and 206 | self.headers.get('Content-Type', '') in _json_content_types) 207 | 208 | @property 209 | def json(self): 210 | """The response parsed as JSON. 211 | 212 | No attempt is made to ensure the response is valid or even looks 213 | like JSON before parsing. 214 | """ 215 | return loads(self.response) 216 | 217 | def __init__(self, app_iter, status, headers): 218 | self.headers = Headers(headers) 219 | if isinstance(status, (int, long)): 220 | self.status_code = status # sets .status as well 221 | else: 222 | self.status = status 223 | 224 | if isinstance(app_iter, basestring): 225 | self.response = app_iter 226 | else: 227 | self.response = ''.join(app_iter) 228 | if 'Content-Length' not in self.headers: 229 | self.headers['Content-Length'] = len(self.response) 230 | 231 | 232 | class _APIClientState(object): 233 | default_base_url = 'http://localhost' 234 | 235 | def __init__(self, application): 236 | self.application = application 237 | self.cookie_jar = _CookieJar() 238 | self.auth = None 239 | self.referrer = None 240 | 241 | @property 242 | def base_url(self): 243 | if not self.referrer: 244 | return self.default_base_url 245 | url = urlparse(self.referrer) 246 | return urlunparse(url[:2] + ('', '', '', '')) 247 | 248 | def copy(self): 249 | fork = copy.copy(self) 250 | fork.cookie_jar = self.cookie_jar.copy() 251 | return fork 252 | 253 | def prepare_environ(self, environ): 254 | if self.referrer: 255 | environ['HTTP_REFERER'] = self.referrer 256 | if len(self.cookie_jar): 257 | self.cookie_jar.inject_wsgi(environ) 258 | environ.setdefault('REMOTE_ADDR', '127.0.0.1') 259 | 260 | def process_response(self, response, request_environ): 261 | headers = response.headers 262 | if 'Set-Cookie' in headers or 'Set-Cookie2' in headers: 263 | self.cookie_jar.extract_wsgi(request_environ, headers) 264 | self.referrer = request_uri(request_environ) 265 | self.source_environ = request_environ 266 | 267 | 268 | # lifted from werkzeug 0.4 269 | class File(object): 270 | """Wraps a file descriptor or any other stream so that `encode_multipart` 271 | can get the mimetype and filename from it. 272 | """ 273 | 274 | def __init__(self, fd, filename=None, mimetype=None): 275 | if isinstance(fd, basestring): 276 | if filename is None: 277 | filename = fd 278 | fd = file(fd, 'rb') 279 | try: 280 | self.stream = StringIO(fd.read()) 281 | finally: 282 | fd.close() 283 | else: 284 | self.stream = fd 285 | if filename is None: 286 | if not hasattr(fd, 'name'): 287 | raise ValueError('no filename for provided') 288 | filename = fd.name 289 | if mimetype is None: 290 | mimetype = mimetypes.guess_type(filename)[0] 291 | self.filename = filename 292 | self.mimetype = mimetype or 'application/octet-stream' 293 | 294 | def __getattr__(self, name): 295 | return getattr(self.stream, name) 296 | 297 | def __repr__(self): 298 | return '<%s %r>' % (self.__class__.__name__, self.filename) 299 | 300 | 301 | class _CookieJar(_TestCookieJar): 302 | """A lock-less, wsgi-friendly CookieJar that can clone itself.""" 303 | 304 | def __init__(self, policy=None): 305 | if policy is None: 306 | policy = DefaultCookiePolicy() 307 | self._policy = policy 308 | self._cookies = {} 309 | self._cookies_lock = dummy_threading.RLock() 310 | 311 | def copy(self): 312 | fork = copy.copy(self) 313 | fork._cookies = copy.deepcopy(self._cookies) 314 | return fork 315 | 316 | 317 | # taken from flatland 318 | def _to_pairs(dictlike): 319 | """Yield (key, value) pairs from any dict-like object. 320 | 321 | Implements an optimized version of the dict.update() definition of 322 | "dictlike". 323 | 324 | """ 325 | if hasattr(dictlike, 'items'): 326 | return dictlike.items() 327 | elif hasattr(dictlike, 'keys'): 328 | return [(key, dictlike[key]) for key in dictlike.keys()] 329 | else: 330 | return [(key, value) for key, value in dictlike] 331 | -------------------------------------------------------------------------------- /alfajor/browsers/wsgi.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """An in-process browser that acts as a WSGI server.""" 8 | 9 | from __future__ import absolute_import 10 | import cookielib 11 | from cookielib import Cookie 12 | import dummy_threading 13 | from cStringIO import StringIO 14 | from logging import getLogger 15 | import os.path 16 | from urlparse import urljoin, urlparse, urlunparse 17 | from time import time 18 | import urllib2 19 | from wsgiref.util import request_uri 20 | 21 | from blinker import signal 22 | from werkzeug import ( 23 | BaseResponse, 24 | FileStorage, 25 | MultiDict, 26 | create_environ, 27 | parse_cookie, 28 | run_wsgi_app, 29 | url_encode, 30 | ) 31 | from werkzeug.test import encode_multipart 32 | 33 | from alfajor.browsers._lxml import ( 34 | ButtonElement, 35 | DOMElement, 36 | DOMMixin, 37 | FormElement, 38 | InputElement, 39 | SelectElement, 40 | TextareaElement, 41 | html_parser_for, 42 | ) 43 | from alfajor.browsers._waitexpr import WaitExpression 44 | from alfajor.utilities import lazy_property, to_pairs 45 | from alfajor._compat import property 46 | 47 | 48 | __all__ = ['WSGI'] 49 | logger = getLogger('tests.browser') 50 | after_browser_activity = signal('after_browser_activity') 51 | before_browser_activity = signal('before_browser_activity') 52 | 53 | 54 | class WSGI(DOMMixin): 55 | 56 | capabilities = [ 57 | 'in-process', 58 | 'cookies', 59 | 'headers', 60 | 'status', 61 | ] 62 | 63 | wait_expression = WaitExpression 64 | 65 | _wsgi_server = { 66 | 'multithread': False, 67 | 'multiprocess': False, 68 | 'run_once': False, 69 | } 70 | 71 | user_agent = { 72 | 'browser': 'wsgi', 73 | 'platform': 'python', 74 | 'version': '1.0', 75 | } 76 | 77 | def __init__(self, wsgi_app, base_url=None): 78 | # accept additional request headers? (e.g. user agent) 79 | self._wsgi_app = wsgi_app 80 | self._base_url = base_url 81 | self._referrer = None 82 | self._request_environ = None 83 | self._cookie_jar = CookieJar() 84 | self._charset = 'utf-8' 85 | self.status_code = 0 86 | self.status = '' 87 | self.response = None 88 | self.headers = () 89 | 90 | def open(self, url, wait_for=None, timeout=0): 91 | """Open web page at *url*.""" 92 | self._open(url, refer=False) 93 | 94 | def reset(self): 95 | self._cookie_jar = CookieJar() 96 | 97 | @property 98 | def location(self): 99 | if not self._request_environ: 100 | return None 101 | return request_uri(self._request_environ) 102 | 103 | def wait_for(self, condition, timeout=None): 104 | pass 105 | 106 | def sync_document(self): 107 | """The document is always synced.""" 108 | 109 | _sync_document = DOMMixin.sync_document 110 | 111 | @property 112 | def cookies(self): 113 | if not (self._cookie_jar and self.location): 114 | return {} 115 | request = urllib2.Request(self.location) 116 | policy = self._cookie_jar._policy 117 | policy._now = int(time()) 118 | 119 | # return ok will only return a cookie if the following attrs are set 120 | # correctly => # "version", "verifiability", "secure", "expires", 121 | # "port", "domain" 122 | return dict((c.name, c.value.strip('"')) 123 | for c in self._cookie_jar if policy.return_ok(c, request)) 124 | 125 | def set_cookie(self, name, value, domain=None, path=None, 126 | session=True, expires=None, port=None, request=None): 127 | """ 128 | :param expires: Seconds from epoch 129 | :param port: must match request port 130 | :param domain: the fqn of your server hostname 131 | """ 132 | # Cookie(version, name, value, port, port_specified, 133 | # domain, domain_specified, domain_initial_dot, 134 | # path, path_specified, secure, expires, 135 | # discard, comment, comment_url, rest, 136 | # rfc2109=False): 137 | cookie = Cookie(0, name, value, port, bool(port), 138 | domain or '', bool(domain), 139 | (domain and domain.startswith('.')), 140 | path or '', bool(path), False, expires, 141 | session, None, None, {}, False) 142 | self._cookie_jar.set_cookie(cookie) 143 | 144 | def delete_cookie(self, name, domain=None, path=None): 145 | try: 146 | self._cookie_jar.clear(domain, path, name) 147 | except KeyError: 148 | pass 149 | 150 | # Internal methods 151 | @lazy_property 152 | def _lxml_parser(self): 153 | return html_parser_for(self, wsgi_elements) 154 | 155 | def _open(self, url, method='GET', data=None, refer=True, content_type=None): 156 | before_browser_activity.send(self) 157 | open_started = time() 158 | environ = self._create_environ(url, method, data, refer, content_type) 159 | # keep a copy, the app may mutate the environ 160 | request_environ = dict(environ) 161 | 162 | logger.info('%s(%s) == %s', method, url, request_uri(environ)) 163 | request_started = time() 164 | rv = run_wsgi_app(self._wsgi_app, environ) 165 | response = BaseResponse(*rv) 166 | # TODO: 167 | # response.make_sequence() # werkzeug 0.6+ 168 | # For now, must: 169 | response.response = list(response.response) 170 | if hasattr(rv[0], 'close'): 171 | rv[0].close() 172 | # end TODO 173 | 174 | # request is complete after the app_iter (rv[0]) has been fully read + 175 | # closed down. 176 | request_ended = time() 177 | 178 | self._request_environ = request_environ 179 | self._cookie_jar.extract_from_werkzeug(response, environ) 180 | self.status_code = response.status_code 181 | # Automatically follow redirects 182 | if 301 <= self.status_code <= 302: 183 | logger.debug("Redirect to %s", response.headers['Location']) 184 | after_browser_activity.send(self) 185 | self._open(response.headers['Location']) 186 | return 187 | # redirects report the original referrer 188 | self._referrer = request_uri(environ) 189 | self.status = response.status 190 | self.headers = response.headers 191 | # TODO: unicodify 192 | self.response = response.data 193 | self._sync_document() 194 | 195 | # TODO: what does a http-equiv redirect report for referrer? 196 | if 'meta[http-equiv=refresh]' in self.document: 197 | refresh = self.document['meta[http-equiv=refresh]'][0] 198 | if 'content' in refresh.attrib: 199 | parts = refresh.get('content').split(';url=', 1) 200 | if len(parts) == 2: 201 | logger.debug("HTTP-EQUIV Redirect to %s", parts[1]) 202 | after_browser_activity.send(self) 203 | self._open(parts[1]) 204 | return 205 | 206 | open_ended = time() 207 | request_time = request_ended - request_started 208 | logger.info("Fetched %s in %0.3fsec + %0.3fsec browser overhead", 209 | url, request_time, 210 | open_ended - open_started - request_time) 211 | after_browser_activity.send(self) 212 | 213 | def _create_environ(self, url, method, data, refer, content_type=None): 214 | """Return an environ to request *url*, including cookies.""" 215 | environ_args = dict(self._wsgi_server, method=method) 216 | base_url = self._referrer if refer else self._base_url 217 | environ_args.update(self._canonicalize_url(url, base_url)) 218 | environ_args.update(self._prep_input(method, data, content_type)) 219 | environ = create_environ(**environ_args) 220 | if refer and self._referrer: 221 | environ['HTTP_REFERER'] = self._referrer 222 | environ.setdefault('REMOTE_ADDR', '127.0.0.1') 223 | self._cookie_jar.export_to_environ(environ) 224 | return environ 225 | 226 | def _canonicalize_url(self, url, base_url): 227 | """Return fully qualified URL components formatted for environ.""" 228 | if '?' in url: 229 | url, query_string = url.split('?', 1) 230 | else: 231 | query_string = None 232 | 233 | canonical = {'query_string': query_string} 234 | 235 | # canonicalize against last request (add host/port, resolve 236 | # relative paths) 237 | if base_url: 238 | url = urljoin(base_url, url) 239 | 240 | parsed = urlparse(url) 241 | if not parsed.scheme: 242 | raise RuntimeError( 243 | "No base url available for resolving relative url %r" % url) 244 | 245 | canonical['path'] = urlunparse(( 246 | '', '', parsed.path, parsed.params, '', '')) 247 | canonical['base_url'] = urlunparse(( 248 | parsed.scheme, parsed.netloc, '', '', '', '')) 249 | return canonical 250 | 251 | def _prep_input(self, method, data, content_type): 252 | """Return encoded and packed POST data.""" 253 | if data is None or method != 'POST': 254 | prepped = { 255 | 'input_stream': None, 256 | 'content_length': None, 257 | 'content_type': None, 258 | } 259 | if method == 'GET' and data: 260 | qs = MultiDict() 261 | for key, value in to_pairs(data): 262 | qs.setlistdefault(key).append(value) 263 | prepped['query_string'] = url_encode(qs) 264 | return prepped 265 | else: 266 | payload = url_encode(MultiDict(to_pairs(data))) 267 | content_type = 'application/x-www-form-urlencoded' 268 | return { 269 | 'input_stream': StringIO(payload), 270 | 'content_length': len(payload), 271 | 'content_type': content_type 272 | } 273 | 274 | 275 | def _wrap_file(filename, content_type): 276 | """Open the file *filename* and wrap in a FileStorage object.""" 277 | assert os.path.isfile(filename), "File does not exist." 278 | return FileStorage( 279 | stream=open(filename, 'rb'), 280 | filename=os.path.basename(filename), 281 | content_type=content_type 282 | ) 283 | 284 | 285 | class FormElement(FormElement): 286 | """A
that can be submitted.""" 287 | 288 | def submit(self, wait_for=None, timeout=0, _extra_values=()): 289 | """Submit the form's values. 290 | 291 | Equivalent to hitting 'return' in a browser form: the data is 292 | submitted without the submit button's key/value pair. 293 | 294 | """ 295 | if _extra_values and hasattr(_extra_values, 'items'): 296 | _extra_values = _extra_values.items() 297 | 298 | values = self.form_values() 299 | values.extend(_extra_values) 300 | method = self.method or 'GET' 301 | if self.action: 302 | action = self.action 303 | elif self.browser._referrer: 304 | action = urlparse(self.browser._referrer).path 305 | else: 306 | action = '/' 307 | self.browser._open(action, method=method, data=values, 308 | content_type=self.get('enctype')) 309 | 310 | 311 | class InputElement(InputElement): 312 | """An tag.""" 313 | 314 | # Toss aside checkbox code present in the base lxml @value 315 | @property 316 | def value(self): 317 | return self.get('value') 318 | 319 | @value.setter 320 | def value(self, value): 321 | self.set('value', value) 322 | 323 | @value.deleter 324 | def value(self): 325 | if 'value' in self.attrib: 326 | del self.attrib['value'] 327 | 328 | def click(self, wait_for=None, timeout=None): 329 | if self.checkable: 330 | self.checked = not self.checked 331 | return 332 | if self.type != 'submit': 333 | super(InputElement, self).click(wait_for, timeout) 334 | return 335 | for element in self.iterancestors(): 336 | if element.tag == 'form': 337 | break 338 | else: 339 | # Not in a form: clicking does nothing. 340 | # TODO: probably not true 341 | return 342 | extra = () 343 | if 'name' in self.attrib: 344 | extra = [[self.attrib['name'], self.attrib.get('value', 'Submit')]] 345 | element.submit(wait_for=wait_for, timeout=timeout, _extra_values=extra) 346 | 347 | 348 | class ButtonElement(object): 349 | """Buttons that can be .click()ed.""" 350 | 351 | def click(self, wait_for=None, timeout=0): 352 | # TODO: process type=submit|reset|button? 353 | for element in self.iterancestors(): 354 | if element.tag == 'form': 355 | break 356 | else: 357 | # Not in a form: clicking does nothing. 358 | return 359 | pairs = [] 360 | name = self.attrib.get('name', False) 361 | if name: 362 | pairs.append((name, self.attrib.get('value', ''))) 363 | return element.submit(_extra_values=pairs) 364 | 365 | 366 | class LinkElement(object): 367 | """Links that can be .click()ed.""" 368 | 369 | def click(self, wait_for=None, timeout=0): 370 | try: 371 | link = self.attrib['href'] 372 | except AttributeError: 373 | pass 374 | else: 375 | self.browser._open(link, 'GET') 376 | 377 | 378 | wsgi_elements = { 379 | '*': DOMElement, 380 | 'a': LinkElement, 381 | 'button': ButtonElement, 382 | 'form': FormElement, 383 | 'input': InputElement, 384 | 'select': SelectElement, 385 | 'textarea': TextareaElement, 386 | } 387 | 388 | 389 | class CookieJar(cookielib.CookieJar): 390 | """A lock-less CookieJar that can clone itself.""" 391 | 392 | def __init__(self, policy=None): 393 | if policy is None: 394 | policy = cookielib.DefaultCookiePolicy() 395 | self._policy = policy 396 | self._cookies = {} 397 | self._cookies_lock = dummy_threading.RLock() 398 | 399 | def export_to_environ(self, environ): 400 | if len(self): 401 | u_request = _WSGI_urllib2_request(environ) 402 | self.add_cookie_header(u_request) 403 | 404 | def extract_from_werkzeug(self, response, request_environ): 405 | headers = response.headers 406 | if 'Set-Cookie' in headers or 'Set-Cookie2' in headers: 407 | u_response = _Werkzeug_urlib2_response(response) 408 | u_request = _WSGI_urllib2_request(request_environ) 409 | self.extract_cookies(u_response, u_request) 410 | 411 | 412 | class _Duck(object): 413 | """Has arbitrary attributes assigned at construction time.""" 414 | 415 | def __init__(self, **kw): 416 | for attr, value in kw.iteritems(): 417 | setattr(self, attr, value) 418 | 419 | 420 | class _Werkzeug_urlib2_response(object): 421 | __slots__ = 'response', 422 | 423 | def __init__(self, response): 424 | self.response = response 425 | 426 | def info(self): 427 | return _Duck(getallmatchingheaders=self.response.headers.getlist, 428 | getheaders=self.response.headers.getlist) 429 | 430 | 431 | class _WSGI_urllib2_request(object): 432 | 433 | def __init__(self, environ): 434 | self.environ = environ 435 | self.url = request_uri(self.environ) 436 | self.url_parts = urlparse(self.url) 437 | 438 | def get_full_url(self): 439 | return self.url 440 | 441 | def get_host(self): 442 | return self.url_parts.hostname 443 | 444 | def get_type(self): 445 | return self.url_parts.scheme 446 | 447 | def is_unverifiable(self): 448 | return False 449 | 450 | def get_origin_req_host(self): 451 | raise Exception('fixme need previous request') 452 | 453 | def has_header(self, header): 454 | key = header.replace('-', '_').upper() 455 | return key in self.environ or 'HTTP_%s' % key in self.environ 456 | 457 | def get_header(self, header): 458 | return self.environ.get('HTTP_%s' % header.replace('-', '_').upper()) 459 | 460 | def header_items(self): 461 | items = [] 462 | for key, value in self.environ.iteritems(): 463 | if ((key.startswith('HTTP_') or key.startswith('CONTENT_')) and 464 | isinstance(value, basestring)): 465 | if key.startswith('HTTP_'): 466 | key = key[5:] 467 | key = key.replace('_', '-').title() 468 | items.append((key, value)) 469 | return items 470 | 471 | def add_unredirected_header(self, key, value): 472 | if key == 'Cookie': 473 | self.environ['HTTP_COOKIE'] = "%s: %s" % (key, value) 474 | -------------------------------------------------------------------------------- /tests/browser/test_forms.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | from alfajor._compat import json_loads as loads 8 | 9 | from nose.tools import eq_, raises 10 | 11 | from . import browser 12 | 13 | 14 | def test_get(): 15 | for index in 0, 1, 2: 16 | browser.open('/form/methods') 17 | assert browser.document['#get_data'].text == '[]' 18 | data = { 19 | 'first_name': 'Tester', 20 | 'email': 'tester@tester.com', 21 | } 22 | form = browser.document.forms[index] 23 | form.fill(data) 24 | form.submit(wait_for='page') 25 | get = loads(browser.document['#get_data'].text) 26 | post = loads(browser.document['#post_data'].text) 27 | assert get == [['email', 'tester@tester.com'], 28 | ['first_name', 'Tester']] 29 | assert not post 30 | 31 | 32 | def test_get_qs_append(): 33 | browser.open('/form/methods?stuff=already&in=querystring') 34 | form = browser.document.forms[3] 35 | form.submit(wait_for='page') 36 | get = loads(browser.document['#get_data'].text) 37 | post = loads(browser.document['#post_data'].text) 38 | assert sorted(get) == [['email', ''], ['first_name', '']] 39 | assert post == [] 40 | 41 | browser.open('/form/methods?stuff=already&in=querystring') 42 | form = browser.document.forms[3] 43 | form.fill({'email': 'snorgle'}) 44 | form.submit(wait_for='page') 45 | get = loads(browser.document['#get_data'].text) 46 | post = loads(browser.document['#post_data'].text) 47 | assert sorted(get) == [['email', 'snorgle'], ['first_name', '']] 48 | assert post == [] 49 | 50 | 51 | def test_post(): 52 | browser.open('/form/methods') 53 | assert browser.document['#post_data'].text == '[]' 54 | data = { 55 | 'first_name': 'Tester', 56 | 'email': 'tester@tester.com', 57 | } 58 | form = browser.document.forms[4] 59 | form.fill(data) 60 | form.submit(wait_for='page') 61 | get = loads(browser.document['#get_data'].text) 62 | post = loads(browser.document['#post_data'].text) 63 | assert not get 64 | assert sorted(post) == [['email', 'tester@tester.com'], 65 | ['first_name', 'Tester']] 66 | 67 | 68 | def test_post_qs_append(): 69 | browser.open('/form/methods?x=y') 70 | assert browser.document['#post_data'].text == '[]' 71 | data = { 72 | 'first_name': 'Tester', 73 | 'email': 'tester@tester.com', 74 | } 75 | form = browser.document.forms[5] 76 | form.fill(data) 77 | form.submit(wait_for='page') 78 | get = loads(browser.document['#get_data'].text) 79 | post = loads(browser.document['#post_data'].text) 80 | assert sorted(get) == [['x', 'y']] 81 | assert sorted(post) == [['email', 'tester@tester.com'], 82 | ['first_name', 'Tester']] 83 | 84 | browser.open('/form/methods?x=y&email=a') 85 | assert browser.document['#post_data'].text == '[]' 86 | data = { 87 | 'first_name': 'Tester', 88 | 'email': 'tester@tester.com', 89 | } 90 | form = browser.document.forms[5] 91 | form.fill(data) 92 | form.submit(wait_for='page') 93 | get = loads(browser.document['#get_data'].text) 94 | post = loads(browser.document['#post_data'].text) 95 | assert sorted(get) == [['email', 'a'], ['x', 'y']] 96 | assert sorted(post) == [['email', 'tester@tester.com'], 97 | ['first_name', 'Tester']] 98 | 99 | 100 | def test_submit_buttonless(): 101 | for idx in 0, 1: 102 | browser.open('/form/submit') 103 | browser.document.forms[idx].submit(wait_for='page') 104 | data = loads(browser.document['#data'].text) 105 | assert data == [['search', '']] 106 | 107 | 108 | def test_nameless_submit_button(): 109 | for idx in 2, 3: 110 | browser.open('/form/submit') 111 | button = browser.document.forms[idx]['input[type=submit]'][0] 112 | button.click(wait_for='page') 113 | data = loads(browser.document['#data'].text) 114 | assert data == [['search', '']] 115 | 116 | 117 | def test_named_submit_button(): 118 | for idx in 4, 5, 6: 119 | browser.open('/form/submit') 120 | assert browser.document['#method'].text == 'GET' 121 | button = browser.document.forms[idx]['input[type=submit]'][0] 122 | button.click(wait_for='page') 123 | assert browser.document['#method'].text == 'POST' 124 | data = loads(browser.document['#data'].text) 125 | assert sorted(data) == [['search', ''], ['submitA', 'SubmitA']] 126 | 127 | 128 | def test_valueless_submit_button(): 129 | browser.open('/form/submit') 130 | button = browser.document.forms[7]['input[type=submit]'][0] 131 | button.click(wait_for='page') 132 | data = loads(browser.document['#data'].text) 133 | assert len(data) == 2 134 | data = dict(data) 135 | assert data['search'] == '' 136 | # the value sent is browser implementation specific. could be 137 | # Submit or Submit Query or ... 138 | assert data['submitA'] and data['submitA'] != '' 139 | 140 | 141 | def test_multielement_submittal(): 142 | browser.open('/form/submit') 143 | assert browser.document['#method'].text == 'GET' 144 | 145 | browser.document.forms[8].submit(wait_for='page') 146 | assert browser.document['#method'].text == 'POST' 147 | data = loads(browser.document['#data'].text) 148 | assert sorted(data) == [['x', ''], ['y', '']] 149 | 150 | browser.open('/form/submit') 151 | assert browser.document['#method'].text == 'GET' 152 | button = browser.document.forms[8]['input[type=submit]'][0] 153 | button.click(wait_for='page') 154 | assert browser.document['#method'].text == 'POST' 155 | data = loads(browser.document['#data'].text) 156 | assert sorted(data) == [['submitA', 'SubmitA'], ['x', ''], ['y', '']] 157 | 158 | 159 | def test_textarea(): 160 | browser.open('/form/textareas') 161 | browser.document.forms[0].submit(wait_for='page') 162 | data = loads(browser.document['#data'].text_content) 163 | assert data == [['ta', '']] 164 | 165 | browser.document.forms[0]['textarea'][0].value = 'foo\r\nbar' 166 | browser.document.forms[0].submit(wait_for='page') 167 | data = loads(browser.document['#data'].text_content) 168 | assert data == [['ta', 'foo\r\nbar']] 169 | 170 | textarea = browser.document.forms[0]['textarea'][0] 171 | textarea.enter('baz') 172 | # NOTE: Webkit Selenium Browsers seem to trim the string on returned 173 | # values (get). Therefore do not end this test with a whitespace char. 174 | textarea.enter('\r\nquuX\r\nY') 175 | textarea.enter('\x08\x08\x08x') 176 | browser.document.forms[0].submit(wait_for='page') 177 | data = loads(browser.document['#data'].text_content) 178 | assert data == [['ta', 'baz\r\nquux']] 179 | 180 | def test_multipart_simple(): 181 | if 'upload' not in browser.capabilities: 182 | return 183 | 184 | browser.open('/form/multipart') 185 | data = loads(browser.document['#data'].text_content) 186 | assert data == [] 187 | 188 | browser.document.forms[0]['input[name=search]'][0].value = 'foobar' 189 | browser.document.forms[0].submit(wait_for='page') 190 | data = loads(browser.document['#data'].text_content) 191 | assert data == [['search', 'foobar']] 192 | 193 | 194 | def test_formless_submit_button(): 195 | browser.open('/form/submit') 196 | assert browser.document['#method'].text == 'GET' 197 | request_id = browser.document['#request_id'].text 198 | 199 | browser.document['#floater'].click() 200 | assert browser.document['#request_id'].text == request_id 201 | 202 | 203 | def test_select_default_initial_empty(): 204 | browser.open('/form/select') 205 | browser.document.forms[0]['input[type=submit]'][0].click() 206 | data = loads(browser.document['#data'].text) 207 | assert data == [['sel', '']] 208 | 209 | 210 | def _test_select(form_num, fieldname, value, expected_return): 211 | """Repeat tests with multiple lxml `` element. 362 | 363 | You can add to this set-like option to select an option, or remove 364 | to unselect the option. 365 | """ 366 | 367 | def __init__(self, select): 368 | self.select = select 369 | 370 | def options(self): 371 | """ 372 | Iterator of all the ``