[Checkins] SVN: zc.testbrowser/trunk/ initial import of already-in-progress zc.testbrowser with "real"

Benji York benji at zope.com
Wed Sep 19 05:34:25 EDT 2007


Log message for revision 79746:
  initial import of already-in-progress zc.testbrowser with "real"
  

Changed:
  _U  zc.testbrowser/trunk/
  A   zc.testbrowser/trunk/README.txt
  A   zc.testbrowser/trunk/buildout.cfg
  A   zc.testbrowser/trunk/setup.py
  A   zc.testbrowser/trunk/src/
  A   zc.testbrowser/trunk/src/zc/
  A   zc.testbrowser/trunk/src/zc/__init__.py
  A   zc.testbrowser/trunk/src/zc/testbrowser/
  A   zc.testbrowser/trunk/src/zc/testbrowser/README.txt
  A   zc.testbrowser/trunk/src/zc/testbrowser/__init__.py
  A   zc.testbrowser/trunk/src/zc/testbrowser/browser.py
  A   zc.testbrowser/trunk/src/zc/testbrowser/dummymodules.py
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/__init__.py
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/controls.html
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/forms.html
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/ftesting.zcml
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/index.html
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/navigate.html
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/notitle.html
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/oneform.html
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/target.html
  A   zc.testbrowser/trunk/src/zc/testbrowser/ftests/zope3logo.gif
  A   zc.testbrowser/trunk/src/zc/testbrowser/headers.txt
  A   zc.testbrowser/trunk/src/zc/testbrowser/interfaces.py
  A   zc.testbrowser/trunk/src/zc/testbrowser/real.js
  A   zc.testbrowser/trunk/src/zc/testbrowser/real.py
  A   zc.testbrowser/trunk/src/zc/testbrowser/real.txt
  A   zc.testbrowser/trunk/src/zc/testbrowser/screen-shots.txt
  A   zc.testbrowser/trunk/src/zc/testbrowser/tests.py

-=-

Property changes on: zc.testbrowser/trunk
___________________________________________________________________
Name: svn:ignore
   + develop-eggs
bin
parts
.installed.cfg



Added: zc.testbrowser/trunk/README.txt
===================================================================
--- zc.testbrowser/trunk/README.txt	                        (rev 0)
+++ zc.testbrowser/trunk/README.txt	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,22 @@
+Overview
+========
+
+The zc.testbrowser package provides web user agents (browsers) with
+programmatic interfaces designed to be used for testing web applications,
+especially in conjunction with doctests.  This project originates in the Zope 3
+community, but is not Zope-specific.
+
+There are currently three type of testbrowser provided.  One for accessing web
+sites via HTTP (zc.testbrowser.browser), one that controls a Firefox web
+browser (zc.testbrowser.real), and one for directly accessing a Zope 3
+application (zope.testbrowser.testing, available seperately).
+
+
+Changes
+=======
+
+1.0 (unreleased)
+----------------
+
+First release under new name (non Zope-specific code extracted from
+zope.testbrowser)


Property changes on: zc.testbrowser/trunk/README.txt
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/buildout.cfg
===================================================================
--- zc.testbrowser/trunk/buildout.cfg	                        (rev 0)
+++ zc.testbrowser/trunk/buildout.cfg	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,24 @@
+[buildout]
+develop = .
+parts = test
+versions = versions
+index = http://download.zope.org/ppix/
+
+[test]
+recipe = zc.recipe.testrunner
+defaults = ['-1', '--auto-color']
+eggs = zc.testbrowser
+
+[versions]
+ClientForm = 0.2.7
+mechanize = 0.1.7b
+setuptools = 0.6c7
+simplejson = 1.7.1
+zc.buildout = 1.0.0b30
+zc.recipe.egg = 1.0.0b6
+zc.recipe.testrunner = 1.0.0b8
+zope.event = 3.4.0
+zope.i18nmessageid = 3.4.0
+zope.interface = 3.4.0
+zope.schema = 3.4.0b1dev-r77624
+zope.testing = 3.5.1

Added: zc.testbrowser/trunk/setup.py
===================================================================
--- zc.testbrowser/trunk/setup.py	                        (rev 0)
+++ zc.testbrowser/trunk/setup.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,55 @@
+##############################################################################
+#
+# Copyright (c) 2006 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+import os
+from setuptools import setup, find_packages
+
+long_description = (
+    '.. contents::\n\n'
+    + open('README.txt').read()
+    + '\n\n'
+    + open(os.path.join('src', 'zc', 'testbrowser', 'README.txt')).read()
+    )
+
+setup(
+    name = 'zc.testbrowser',
+    version = '3.4.2dev',
+    url = 'http://pypi.python.org/pypi/zc.testbrowser',
+    license = 'ZPL 2.1',
+    description = 'Programmable browser for functional black-box tests',
+    author = 'Zope Corporation and Contributors',
+    author_email = 'zope3-dev at zope.org',
+    long_description = long_description,
+    classifiers=['Environment :: Web Environment',
+                 'Intended Audience :: Developers',
+                 'License :: OSI Approved :: Zope Public License',
+                 'Programming Language :: Python',
+                 'Topic :: Software Development :: Testing',
+                 'Topic :: Internet :: WWW/HTTP',
+                 ],
+
+    packages = find_packages('src'),
+    package_dir = {'': 'src'},
+    namespace_packages = ['zc',],
+    tests_require = ['zope.testing'],
+    install_requires = [
+        'ClientForm',
+        'mechanize',
+        'setuptools',
+        'simplejson',
+        'zope.interface',
+        'zope.schema',
+        ],
+    include_package_data = True,
+    zip_safe = False,
+    )


Property changes on: zc.testbrowser/trunk/setup.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/__init__.py
===================================================================
--- zc.testbrowser/trunk/src/zc/__init__.py	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/__init__.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,7 @@
+# this is a namespace package
+try:
+    import pkg_resources
+    pkg_resources.declare_namespace(__name__)
+except ImportError:
+    import pkgutil
+    __path__ = pkgutil.extend_path(__path__, __name__)


Property changes on: zc.testbrowser/trunk/src/zc/__init__.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/README.txt
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/README.txt	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/README.txt	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,1068 @@
+Detailed Documentation
+======================
+
+Before being of much interest, we need to open a web page.  ``Browser``
+instances have a ``base`` attribute that sets the URL from which ``open``ed
+URLs are relative.  This lets you target tests at servers running in various,
+or even variable locations (like using randomly chosen ports).
+
+    >>> browser = Browser()
+    >>> browser.base = 'http://localhost:%s/' % TEST_PORT
+    >>> browser.open('index.html')
+    >>> browser.url
+    'http://localhost:.../index.html'
+
+Once you have opened a web page initially, best practice for writing
+testbrowser doctests suggests using 'click' to navigate further (as discussed
+below), except in unusual circumstances.
+
+The test browser complies with the IBrowser interface; see
+``zc.testbrowser.interfaces`` for full details on the interface.
+
+    >>> import zc.testbrowser.interfaces
+    >>> from zope.interface.verify import verifyObject
+    >>> zc.testbrowser.interfaces.IBrowser.providedBy(browser)
+    True
+
+
+Page Contents
+-------------
+
+The contents of the current page are available:
+
+    >>> browser.contents
+    '...<h1>Simple Page</h1>...'
+
+Note: Unfortunately, ellipsis (...) cannot be used at the beginning of the
+output (this is a limitation of doctest).
+
+Making assertions about page contents is easy.
+
+    >>> '<h1>Simple Page</h1>' in browser.contents
+    True
+
+
+Checking for HTML
+-----------------
+
+Not all URLs return HTML.  Of course our simple page does:
+
+    >>> browser.isHtml
+    True
+
+But if we load an image (or other binary file), we do not get HTML:
+
+    >>> browser.open('zope3logo.gif')
+    >>> browser.isHtml
+    False
+
+
+HTML Page Title
+----------------
+
+Another useful helper property is the title:
+
+    >>> browser.open('index.html')
+    >>> browser.title
+    'Simple Page'
+
+If a page does not provide a title, it is simply ``None``:
+
+    >>> browser.open('notitle.html')
+    >>> browser.title
+
+However, if the output is not HTML, then an error will occur trying to access
+the title:
+
+    >>> browser.open('zope3logo.gif')
+    >>> browser.title
+    Traceback (most recent call last):
+    ...
+    BrowserStateError: not viewing HTML
+
+
+Navigation and Link Objects
+---------------------------
+
+If you want to simulate clicking on a link, get the link and call its `click`
+method.  In the `navigate.html` file there are several links set up to
+demonstrate the capabilities of the link objects and their `click` method.
+
+The simplest way to get a link is via the anchor text.  In other words
+the text you would see in a browser:
+
+    >>> browser.open('navigate.html')
+    >>> browser.contents
+    '...<a href="target.html">Link Text</a>...'
+    >>> link = browser.getLink('Link Text')
+    >>> link
+    <Link text='Link Text' url='http://localhost:.../target.html'>
+
+Link objects comply with the ILink interface.
+
+    >>> verifyObject(zc.testbrowser.interfaces.ILink, link)
+    True
+
+Links expose several attributes for easy access.
+
+    >>> link.text
+    'Link Text'
+
+Links can be "clicked" and the browser will navigate to the referenced URL.
+
+    >>> link.click()
+    >>> browser.url
+    'http://localhost:.../target.html'
+    >>> browser.contents
+    '...This page is the target of a link...'
+
+When finding a link by its text, whitespace is normalized.
+
+    >>> browser.open('navigate.html')
+    >>> browser.contents
+    '...> Link Text \n    with     Whitespace\tNormalization (and parens) </...'
+    >>> link = browser.getLink('Link Text with Whitespace Normalization '
+    ...                        '(and parens)')
+    >>> link
+    <Link text='Link Text with Whitespace Normalization (and parens)'...>
+    >>> link.text
+    'Link Text with Whitespace Normalization (and parens)'
+    >>> link.click()
+    >>> browser.url
+    'http://localhost:.../target.html'
+
+When a link text matches more than one link, by default the first one is
+chosen. You can, however, specify the index of the link and thus retrieve a
+later matching link:
+
+    >>> browser.open('navigate.html')
+    >>> browser.getLink('Link Text')
+    <Link text='Link Text' ...>
+
+    >>> browser.getLink('Link Text', index=1)
+    <Link text='Link Text with Whitespace Normalization (and parens)' ...>
+
+Note that clicking a link object after its browser page has expired will
+generate an error.
+
+    >>> link.click()
+    Traceback (most recent call last):
+    ...
+    ExpiredError
+
+You can also find links by URL,
+
+    >>> browser.open('navigate.html')
+    >>> browser.getLink(url='target.html').click()
+    >>> browser.url
+    'http://localhost:.../target.html'
+
+or its id:
+
+    >>> browser.open('navigate.html')
+    >>> browser.contents
+    '...<a href="target.html" id="anchorid">By Anchor Id</a>...'
+
+    >>> browser.getLink(id='anchorid').click()
+    >>> browser.url
+    'http://localhost:.../target.html'
+
+You thought we were done here? Not so quickly.  The `getLink` method also
+supports image maps, though not by specifying the coordinates, but using the
+area's id:
+
+    >>> browser.open('navigate.html')
+    >>> link = browser.getLink(id='zope3')
+    >>> link.click()
+    >>> browser.url
+    'http://localhost:.../target.html'
+
+Getting a nonexistent link raises an exception.
+
+    >>> browser.open('navigate.html')
+    >>> browser.getLink('This does not exist')
+    Traceback (most recent call last):
+    ...
+    LinkNotFoundError
+
+
+Other Navigation
+----------------
+
+Like in any normal browser, you can reload a page:
+
+    >>> browser.open('index.html')
+    >>> browser.url
+    'http://localhost:.../index.html'
+    >>> browser.reload()
+    >>> browser.url
+    'http://localhost:.../index.html'
+
+You can also go back:
+
+    >>> browser.open('notitle.html')
+    >>> browser.url
+    'http://localhost:.../notitle.html'
+    >>> browser.goBack()
+    >>> browser.url
+    'http://localhost:.../index.html'
+
+
+Controls
+--------
+
+One of the most important features of the browser is the ability to inspect
+and fill in values for the controls of input forms.  To do so, let's first open
+a page that has a bunch of controls:
+
+    >>> browser.open('controls.html')
+
+Obtaining a Control
+~~~~~~~~~~~~~~~~~~~
+
+You look up browser controls with the 'getControl' method.  The default first
+argument is 'label', and looks up the form on the basis of any associated
+label.
+
+    >>> control = browser.getControl('Text Control')
+    >>> control
+    <Control name='text-value' type='text'>
+    >>> browser.getControl(label='Text Control') # equivalent
+    <Control name='text-value' type='text'>
+
+If you request a control that doesn't exist, the code raises a LookupError:
+
+    >>> browser.getControl('Does Not Exist')
+    Traceback (most recent call last):
+    ...
+    LookupError: label 'Does Not Exist'
+
+If you request a control with an ambiguous lookup, the code raises an
+AmbiguityError.
+
+    >>> browser.getControl('Ambiguous Control')
+    Traceback (most recent call last):
+    ...
+    AmbiguityError: label 'Ambiguous Control'
+
+This is also true if an option in a control is ambiguous in relation to
+the control itself.
+
+    >>> browser.getControl('Sub-control Ambiguity')
+    Traceback (most recent call last):
+    ...
+    AmbiguityError: label 'Sub-control Ambiguity'
+
+Ambiguous controls may be specified using an index value.  We use the control's
+value attribute to show the two controls; this attribute is properly introduced
+below.
+
+    >>> browser.getControl('Ambiguous Control', index=0)
+    <Control name='ambiguous-control-name' type='text'>
+    >>> browser.getControl('Ambiguous Control', index=0).value
+    'First'
+    >>> browser.getControl('Ambiguous Control', index=1).value
+    'Second'
+    >>> browser.getControl('Sub-control Ambiguity', index=0)
+    <ListControl name='ambiguous-subcontrol' type='select'>
+    >>> browser.getControl('Sub-control Ambiguity', index=1).optionValue
+    'ambiguous'
+
+Label searches are against stripped, whitespace-normalized, no-tag versions of
+the text. Text applied to searches is also stripped and whitespace normalized.
+The search finds results if the text search finds the whole words of your
+text in a label.  Thus, for instance, a search for 'Add' will match the label
+'Add a Client' but not 'Address'.  Case is honored.
+
+    >>> browser.getControl('Label Needs Whitespace Normalization')
+    <Control name='label-needs-normalization' type='text'>
+    >>> browser.getControl('label needs whitespace normalization')
+    Traceback (most recent call last):
+    ...
+    LookupError: label 'label needs whitespace normalization'
+    >>> browser.getControl(' Label  Needs Whitespace    ')
+    <Control name='label-needs-normalization' type='text'>
+    >>> browser.getControl('Whitespace')
+    <Control name='label-needs-normalization' type='text'>
+    >>> browser.getControl('hitespace')
+    Traceback (most recent call last):
+    ...
+    LookupError: label 'hitespace'
+    >>> browser.getControl('[non word characters should not confuse]')
+    <Control name='non-word-characters' type='text'>
+
+Multiple labels can refer to the same control (simply because that is possible
+in the HTML 4.0 spec).
+
+    >>> browser.getControl('Multiple labels really')
+    <Control name='two-labels' type='text'>
+    >>> browser.getControl('really are possible')
+    <Control name='two-labels' type='text'>
+    >>> browser.getControl('really') # OK: ambiguous labels, but not ambiguous control
+    <Control name='two-labels' type='text'>
+
+A label can be connected with a control using the 'for' attribute and also by
+containing a control.
+
+    >>> browser.getControl(
+    ...     'Labels can be connected by containing their respective fields')
+    <Control name='contained-in-label' type='text'>
+
+Get also accepts one other search argument, 'name'.  Only one of 'label' and
+'name' may be used at a time.  The 'name' keyword searches form field names.
+
+    >>> browser.getControl(name='text-value')
+    <Control name='text-value' type='text'>
+    >>> browser.getControl(name='ambiguous-control-name')
+    Traceback (most recent call last):
+    ...
+    AmbiguityError: name 'ambiguous-control-name'
+    >>> browser.getControl(name='does-not-exist')
+    Traceback (most recent call last):
+    ...
+    LookupError: name 'does-not-exist'
+    >>> browser.getControl(name='ambiguous-control-name', index=1).value
+    'Second'
+
+Combining 'label' and 'name' raises a ValueError, as does supplying neither of
+them.
+
+    >>> browser.getControl(label='Ambiguous Control', name='ambiguous-control-name')
+    Traceback (most recent call last):
+    ...
+    ValueError: Supply one and only one of "label" and "name" as arguments
+    >>> browser.getControl()
+    Traceback (most recent call last):
+    ...
+    ValueError: Supply one and only one of "label" and "name" as arguments
+
+Radio and checkbox fields are unusual in that their labels and names may point
+to different objects: names point to logical collections of radio buttons or
+checkboxes, but labels may only be used for individual choices within the
+logical collection.  This means that obtaining a radio button by label gets a
+different object than obtaining the radio collection by name.  Select options
+may also be searched by label.
+
+    >>> browser.getControl(name='radio-value')
+    <ListControl name='radio-value' type='radio'>
+    >>> browser.getControl('Zwei')
+    <ItemControl name='radio-value' type='radio' optionValue='2' selected=True>
+    >>> browser.getControl('One')
+    <ItemControl name='multi-checkbox-value' type='checkbox' optionValue='1' selected=True>
+    >>> browser.getControl('Tres')
+    <ItemControl name='single-select-value' type='select' optionValue='3' selected=False>
+
+Characteristics of controls and subcontrols are discussed below.
+
+Control Objects
+~~~~~~~~~~~~~~~
+
+Controls provide IControl.
+
+    >>> ctrl = browser.getControl('Text Control')
+    >>> ctrl
+    <Control name='text-value' type='text'>
+    >>> verifyObject(zc.testbrowser.interfaces.IControl, ctrl)
+    True
+
+They have several useful attributes:
+
+  - the name as which the control is known to the form:
+
+    >>> ctrl.name
+    'text-value'
+
+  - the value of the control, which may also be set:
+
+    >>> ctrl.value
+    'Some Text'
+    >>> ctrl.value = 'More Text'
+    >>> ctrl.value
+    'More Text'
+
+  - the type of the control:
+
+    >>> ctrl.type
+    'text'
+
+  - a flag describing whether the control is disabled:
+
+    >>> ctrl.disabled
+    False
+
+  - and a flag to tell us whether the control can have multiple values:
+
+    >>> ctrl.multiple
+    False
+
+Additionally, controllers for select, radio, and checkbox provide IListControl.
+These fields have four other attributes and an additional method:
+
+    >>> ctrl = browser.getControl('Multiple Select Control')
+    >>> ctrl
+    <ListControl name='multi-select-value' type='select'>
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    True
+    >>> verifyObject(zc.testbrowser.interfaces.IListControl, ctrl)
+    True
+
+  - 'options' lists all available value options.
+
+    >>> ctrl.options
+    ['1', '2', '3']
+
+  - 'displayOptions' lists all available options by label.  The 'label'
+    attribute on an option has precedence over its contents, which is why
+    our last option is 'Third' in the display.
+
+    >>> ctrl.displayOptions
+    ['Un', 'Deux', 'Third']
+
+  - 'displayValue' lets you get and set the displayed values of the control
+    of the select box, rather than the actual values.
+
+    >>> ctrl.value
+    []
+    >>> ctrl.displayValue
+    []
+    >>> ctrl.displayValue = ['Un', 'Deux']
+    >>> ctrl.displayValue
+    ['Un', 'Deux']
+    >>> ctrl.value
+    ['1', '2']
+
+  - 'controls' gives you a list of the subcontrol objects in the control
+    (subcontrols are discussed below).
+
+    >>> ctrl.controls
+    [<ItemControl name='multi-select-value' type='select' optionValue='1' selected=True>,
+     <ItemControl name='multi-select-value' type='select' optionValue='2' selected=True>,
+     <ItemControl name='multi-select-value' type='select' optionValue='3' selected=False>]
+
+  - The 'getControl' method lets you get subcontrols by their label or their value.
+
+    >>> ctrl.getControl('Un')
+    <ItemControl name='multi-select-value' type='select' optionValue='1' selected=True>
+    >>> ctrl.getControl('Deux')
+    <ItemControl name='multi-select-value' type='select' optionValue='2' selected=True>
+    >>> ctrl.getControl('Trois') # label attribute
+    <ItemControl name='multi-select-value' type='select' optionValue='3' selected=False>
+    >>> ctrl.getControl('Third') # contents
+    <ItemControl name='multi-select-value' type='select' optionValue='3' selected=False>
+    >>> browser.getControl('Third') # ambiguous in the browser, so useful
+    Traceback (most recent call last):
+    ...
+    AmbiguityError: label 'Third'
+
+Finally, submit controls provide ISubmitControl, and image controls provide
+IImageSubmitControl, which extents ISubmitControl.  These both simply add a
+'click' method.  For image submit controls, you may also provide a coordinates
+argument, which is a tuple of (x, y).  These submit the forms, and are
+demonstrated below as we examine each control individually.
+
+ItemControl Objects
+~~~~~~~~~~~~~~~~~~~
+
+As introduced briefly above, using labels to obtain elements of a logical
+radio button or checkbox collection returns item controls, which are parents.
+Manipulating the value of these controls affects the parent control.
+
+    >>> browser.getControl(name='radio-value').value
+    ['2']
+    >>> browser.getControl('Zwei').optionValue # read-only.
+    '2'
+    >>> browser.getControl('Zwei').selected
+    True
+    >>> verifyObject(zc.testbrowser.interfaces.IItemControl,
+    ...     browser.getControl('Zwei'))
+    True
+    >>> browser.getControl('Ein').selected = True
+    >>> browser.getControl('Ein').selected
+    True
+    >>> browser.getControl('Zwei').selected
+    False
+    >>> browser.getControl(name='radio-value').value
+    ['1']
+    >>> browser.getControl('Ein').selected = False
+    >>> browser.getControl(name='radio-value').value
+    []
+    >>> browser.getControl('Zwei').selected = True
+
+Checkbox collections behave similarly, as shown below.
+
+Controls with subcontrols--
+
+Various Controls
+~~~~~~~~~~~~~~~~
+
+The various types of controls are demonstrated here.
+
+  - Text Control
+
+    The text control we already introduced above.
+
+  - Password Control
+
+    >>> ctrl = browser.getControl('Password Control')
+    >>> ctrl
+    <Control name='password-value' type='password'>
+    >>> verifyObject(zc.testbrowser.interfaces.IControl, ctrl)
+    True
+    >>> ctrl.value
+    'Password'
+    >>> ctrl.value = 'pass now'
+    >>> ctrl.value
+    'pass now'
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    False
+
+  - Hidden Control
+
+    >>> ctrl = browser.getControl(name='hidden-value')
+    >>> ctrl
+    <Control name='hidden-value' type='hidden'>
+    >>> verifyObject(zc.testbrowser.interfaces.IControl, ctrl)
+    True
+    >>> ctrl.value
+    'Hidden'
+    >>> ctrl.value = 'More Hidden'
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    False
+
+  - Text Area Control
+
+    >>> ctrl = browser.getControl('Text Area Control')
+    >>> ctrl
+    <Control name='textarea-value' type='textarea'>
+    >>> verifyObject(zc.testbrowser.interfaces.IControl, ctrl)
+    True
+    >>> ctrl.value
+    '        Text inside\n        area!\n      '
+    >>> ctrl.value = 'A lot of\n text.'
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    False
+
+  - File Control
+
+    File controls are used when a form has a file-upload field.
+    To specify data, call the add_file method, passing:
+
+    - A file-like object
+
+    - a content type, and
+
+    - a file name
+
+    >>> ctrl = browser.getControl('File Control')
+    >>> ctrl
+    <Control name='file-value' type='file'>
+    >>> verifyObject(zc.testbrowser.interfaces.IControl, ctrl)
+    True
+    >>> ctrl.value is None
+    True
+    >>> import cStringIO
+
+    >>> ctrl.add_file(cStringIO.StringIO('File contents'),
+    ...               'text/plain', 'test.txt')
+
+    The file control (like the other controls) also knows if it is disabled
+    or if it can have multiple values.
+
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    False
+
+  - Selection Control (Single-Valued)
+
+    >>> ctrl = browser.getControl('Single Select Control')
+    >>> ctrl
+    <ListControl name='single-select-value' type='select'>
+    >>> verifyObject(zc.testbrowser.interfaces.IListControl, ctrl)
+    True
+    >>> ctrl.value
+    ['1']
+    >>> ctrl.value = ['2']
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    False
+    >>> ctrl.options
+    ['1', '2', '3']
+    >>> ctrl.displayOptions
+    ['Uno', 'Dos', 'Third']
+    >>> ctrl.displayValue
+    ['Dos']
+    >>> ctrl.displayValue = ['Tres']
+    >>> ctrl.displayValue
+    ['Third']
+    >>> ctrl.displayValue = ['Dos']
+    >>> ctrl.displayValue
+    ['Dos']
+    >>> ctrl.displayValue = ['Third']
+    >>> ctrl.displayValue
+    ['Third']
+    >>> ctrl.value
+    ['3']
+
+  - Selection Control (Multi-Valued)
+
+    This was already demonstrated in the introduction to control objects above.
+
+  - Checkbox Control (Single-Valued; Unvalued)
+
+    >>> ctrl = browser.getControl(name='single-unvalued-checkbox-value')
+    >>> ctrl
+    <ListControl name='single-unvalued-checkbox-value' type='checkbox'>
+    >>> verifyObject(zc.testbrowser.interfaces.IListControl, ctrl)
+    True
+    >>> ctrl.value
+    True
+    >>> ctrl.value = False
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    True
+    >>> ctrl.options
+    [True]
+    >>> ctrl.displayOptions
+    ['Single Unvalued Checkbox']
+    >>> ctrl.displayValue
+    []
+    >>> verifyObject(
+    ...     zc.testbrowser.interfaces.IItemControl,
+    ...     browser.getControl('Single Unvalued Checkbox'))
+    True
+    >>> browser.getControl('Single Unvalued Checkbox').optionValue
+    'on'
+    >>> browser.getControl('Single Unvalued Checkbox').selected
+    False
+    >>> ctrl.displayValue = ['Single Unvalued Checkbox']
+    >>> ctrl.displayValue
+    ['Single Unvalued Checkbox']
+    >>> browser.getControl('Single Unvalued Checkbox').selected
+    True
+    >>> browser.getControl('Single Unvalued Checkbox').selected = False
+    >>> browser.getControl('Single Unvalued Checkbox').selected
+    False
+    >>> ctrl.displayValue
+    []
+    >>> browser.getControl(
+    ...     name='single-disabled-unvalued-checkbox-value').disabled
+    True
+
+  - Checkbox Control (Single-Valued, Valued)
+
+    >>> ctrl = browser.getControl(name='single-valued-checkbox-value')
+    >>> ctrl
+    <ListControl name='single-valued-checkbox-value' type='checkbox'>
+    >>> verifyObject(zc.testbrowser.interfaces.IListControl, ctrl)
+    True
+    >>> ctrl.value
+    ['1']
+    >>> ctrl.value = []
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    True
+    >>> ctrl.options
+    ['1']
+    >>> ctrl.displayOptions
+    ['Single Valued Checkbox']
+    >>> ctrl.displayValue
+    []
+    >>> verifyObject(
+    ...     zc.testbrowser.interfaces.IItemControl,
+    ...     browser.getControl('Single Valued Checkbox'))
+    True
+    >>> browser.getControl('Single Valued Checkbox').selected
+    False
+    >>> browser.getControl('Single Valued Checkbox').optionValue
+    '1'
+    >>> ctrl.displayValue = ['Single Valued Checkbox']
+    >>> ctrl.displayValue
+    ['Single Valued Checkbox']
+    >>> browser.getControl('Single Valued Checkbox').selected
+    True
+    >>> browser.getControl('Single Valued Checkbox').selected = False
+    >>> browser.getControl('Single Valued Checkbox').selected
+    False
+    >>> ctrl.displayValue
+    []
+
+  - Checkbox Control (Multi-Valued)
+
+    >>> ctrl = browser.getControl(name='multi-checkbox-value')
+    >>> ctrl
+    <ListControl name='multi-checkbox-value' type='checkbox'>
+    >>> verifyObject(zc.testbrowser.interfaces.IListControl, ctrl)
+    True
+    >>> ctrl.value
+    ['1', '3']
+    >>> ctrl.value = ['1', '2']
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    True
+    >>> ctrl.options
+    ['1', '2', '3']
+    >>> ctrl.displayOptions
+    ['One', 'Two', 'Three']
+    >>> ctrl.displayValue
+    ['One', 'Two']
+    >>> ctrl.displayValue = ['Two']
+    >>> ctrl.value
+    ['2']
+    >>> browser.getControl('Two').optionValue
+    '2'
+    >>> browser.getControl('Two').selected
+    True
+    >>> verifyObject(zc.testbrowser.interfaces.IItemControl,
+    ...     browser.getControl('Two'))
+    True
+    >>> browser.getControl('Three').selected = True
+    >>> browser.getControl('Three').selected
+    True
+    >>> browser.getControl('Two').selected
+    True
+    >>> ctrl.value
+    ['2', '3']
+    >>> browser.getControl('Two').selected = False
+    >>> ctrl.value
+    ['3']
+    >>> browser.getControl('Three').selected = False
+    >>> ctrl.value
+    []
+
+  - Radio Control
+
+    This is how you get a radio button based control:
+
+    >>> ctrl = browser.getControl(name='radio-value')
+
+    This shows the existing value of the control, as it was in the
+    HTML received from the server:
+
+    >>> ctrl.value
+    ['2']
+
+    We can then unselect it:
+
+    >>> ctrl.value = []
+    >>> ctrl.value
+    []
+
+    We can also reselect it:
+
+    >>> ctrl.value = ['2']
+    >>> ctrl.value
+    ['2']
+
+    displayValue shows the text the user would see next to the
+    control:
+
+    >>> ctrl.displayValue
+    ['Zwei']
+
+    This is just unit testing:
+
+    >>> ctrl
+    <ListControl name='radio-value' type='radio'>
+    >>> verifyObject(zc.testbrowser.interfaces.IListControl, ctrl)
+    True
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    False
+    >>> ctrl.options
+    ['1', '2', '3']
+    >>> ctrl.displayOptions
+    ['Ein', 'Zwei', 'Drei']
+    >>> ctrl.displayValue = ['Ein']
+    >>> ctrl.value
+    ['1']
+    >>> ctrl.displayValue
+    ['Ein']
+
+    The radio control subcontrols were illustrated above.
+
+  - Image Control
+
+    >>> ctrl = browser.getControl(name='image-value')
+    >>> ctrl
+    <ImageControl name='image-value' type='image'>
+    >>> verifyObject(zc.testbrowser.interfaces.IImageSubmitControl, ctrl)
+    True
+    >>> ctrl.value
+    ''
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    False
+
+  - Submit Control
+
+    >>> ctrl = browser.getControl(name='submit-value')
+    >>> ctrl
+    <SubmitControl name='submit-value' type='submit'>
+    >>> browser.getControl('Submit This') # value of submit button is a label
+    <SubmitControl name='submit-value' type='submit'>
+    >>> browser.getControl('Standard Submit Control') # label tag is legal
+    <SubmitControl name='submit-value' type='submit'>
+    >>> browser.getControl('Submit') # multiple labels, but same control
+    <SubmitControl name='submit-value' type='submit'>
+    >>> verifyObject(zc.testbrowser.interfaces.ISubmitControl, ctrl)
+    True
+    >>> ctrl.value
+    'Submit This'
+    >>> ctrl.disabled
+    False
+    >>> ctrl.multiple
+    False
+
+Using Submitting Controls
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Both the submit and image type should be clickable and submit the form:
+
+    >>> browser.getControl('Text Control').value = 'Other Text'
+    >>> browser.getControl('Submit').click()
+    >>> browser.contents
+    "...'text-value': ['Other Text']..."
+
+Note that if you click a submit object after the associated page has expired,
+you will get an error.
+
+    >>> browser.open('controls.html')
+    >>> ctrl = browser.getControl('Submit')
+    >>> ctrl.click()
+    >>> ctrl.click()
+    Traceback (most recent call last):
+    ...
+    ExpiredError
+
+All the above also holds true for the image control:
+
+    >>> browser.open('controls.html')
+    >>> browser.getControl('Text Control').value = 'Other Text'
+    >>> browser.getControl(name='image-value').click()
+    >>> browser.contents
+    "...'text-value': ['Other Text']..."
+
+    >>> browser.open('controls.html')
+    >>> ctrl = browser.getControl(name='image-value')
+    >>> ctrl.click()
+    >>> ctrl.click()
+    Traceback (most recent call last):
+    ...
+    ExpiredError
+
+But when sending an image, you can also specify the coordinate you clicked:
+
+    >>> browser.open('controls.html')
+    >>> browser.getControl(name='image-value').click((50,25))
+    >>> browser.contents
+    "...'image-value.x': ['50']...'image-value.y': ['25']..."
+
+Forms
+-----
+
+Because pages can have multiple forms with like-named controls, it is sometimes
+necessary to access forms by name or id.  The browser's `forms` attribute can
+be used to do so.  The key value is the form's name or id.  If more than one
+form has the same name or id, the first one will be returned.
+
+    >>> browser.open('forms.html')
+    >>> form = browser.getForm(name='one')
+
+Form instances conform to the IForm interface.
+
+    >>> verifyObject(zc.testbrowser.interfaces.IForm, form)
+    True
+
+The form exposes several attributes related to forms:
+
+  - The name of the form:
+
+    >>> form.name
+    'one'
+
+  - The id of the form:
+
+    >>> form.id
+    '1'
+
+  - The action (target URL) when the form is submitted:
+
+    >>> form.action
+    'http://localhost:.../forms.html'
+
+  - The method (HTTP verb) used to transmit the form data:
+
+    >>> form.method
+    'POST'
+
+  - The encoding type of the form data:
+
+    >>> form.enctype
+    'application/x-www-form-urlencoded'
+
+Besides those attributes, you have also a couple of methods.  Like for the
+browser, you can get control objects, but limited to the current form...
+
+    >>> form.getControl(name='text-value')
+    <Control name='text-value' type='text'>
+
+...and submit the form.
+
+    >>> form.submit('Submit')
+    >>> browser.contents
+    "...'text-value': ['First Text']..."
+
+Submitting also works without specifying a control, as shown below, which is
+it's primary reason for existing in competition with the control submission
+discussed above.
+
+Now let me show you briefly that looking up forms is sometimes important.  In
+the `forms.html` template, we have four forms all having a text control named
+`text-value`.  Now, if I use the browser's `get` method,
+
+    >>> browser.open('forms.html')
+    >>> browser.getControl(name='text-value')
+    Traceback (most recent call last):
+    ...
+    AmbiguityError: name 'text-value'
+    >>> browser.getControl('Text Control')
+    Traceback (most recent call last):
+    ...
+    AmbiguityError: label 'Text Control'
+
+I'll always get an ambiguous form field.  I can use the index argument, or
+with the `getForm` method I can disambiguate by searching only within a given
+form:
+
+    >>> form = browser.getForm('2')
+    >>> form.getControl(name='text-value').value
+    'Second Text'
+    >>> form.submit('Submit')
+    >>> browser.contents
+    "...'text-value': ['Second Text']..."
+    >>> browser.open('forms.html')
+    >>> form = browser.getForm('2')
+    >>> form.getControl('Submit').click()
+    >>> browser.contents
+    "...'text-value': ['Second Text']..."
+    >>> browser.open('forms.html')
+    >>> browser.getForm('3').getControl('Text Control').value
+    'Third Text'
+
+The last form on the page does not have a name, an id, or a submit button.
+Working with it is still easy, thanks to a index attribute that guarantees
+order.  (Forms without submit buttons are sometimes useful for JavaScript.)
+
+    >>> form = browser.getForm(index=3)
+    >>> form.submit()
+    >>> browser.contents
+    "...'text-value': ['Fourth Text']..."
+
+If a form is requested that does not exists, an exception will be raised.
+
+    >>> browser.open('forms.html')
+    >>> form = browser.getForm('does-not-exist')
+    Traceback (most recent call last):
+    LookupError
+
+If the HTML page contains only one form, no arguments to `getForm` are
+needed:
+
+    >>> browser.open('oneform.html')
+    >>> browser.getForm()
+    <zc.testbrowser...Form object at ...>
+
+If the HTML page contains more than one form, `index` is needed to
+disambiguate if no other arguments are provided:
+
+    >>> browser.open('forms.html')
+    >>> browser.getForm()
+    Traceback (most recent call last):
+    ValueError: if no other arguments are given, index is required.
+
+
+Performance Testing
+-------------------
+
+Browser objects keep up with how much time each request takes.  This can be
+used to ensure a particular request's performance is within a tolerable range.
+Be very careful using raw seconds, cross-machine differences can be huge,
+pystones is usually a better choice.
+
+    >>> browser.open('index.html')
+    >>> browser.lastRequestSeconds < 10 # really big number for safety
+    True
+    >>> browser.lastRequestPystones < 10000 # really big number for safety
+    True
+
+
+Hand-Holding
+------------
+
+Instances of the various objects ensure that users don't set incorrect
+instance attributes accidentally.
+
+    >>> browser.nonexistant = None
+    Traceback (most recent call last):
+    ...
+    AttributeError: 'Browser' object has no attribute 'nonexistant'
+
+    >>> form.nonexistant = None
+    Traceback (most recent call last):
+    ...
+    AttributeError: 'Form' object has no attribute 'nonexistant'
+
+    >>> control.nonexistant = None
+    Traceback (most recent call last):
+    ...
+    AttributeError: 'Control' object has no attribute 'nonexistant'
+
+    >>> link.nonexistant = None
+    Traceback (most recent call last):
+    ...
+    AttributeError: 'Link' object has no attribute 'nonexistant'
+
+
+Fixed Bugs
+----------
+
+This section includes tests for bugs that were found and then fixed that don't
+fit into the more documentation-centric sections above.
+
+Spaces in URL
+~~~~~~~~~~~~~
+
+When URLs have spaces in them, they're handled correctly (before the bug was
+fixed, you'd get "ValueError: too many values to unpack"):
+
+    >>> browser.open('navigate.html')
+    >>> browser.getLink('Spaces in the URL').click()
+
+.goBack() Truncation
+~~~~~~~~~~~~~~~~~~~~
+
+The .goBack() method used to truncate the .contents.
+
+    >>> browser.open('navigate.html')
+    >>> actual_length = len(browser.contents)
+
+    >>> browser.open('navigate.html')
+    >>> browser.open('index.html')
+    >>> browser.goBack()
+    >>> len(browser.contents) == actual_length
+    True


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/README.txt
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/__init__.py
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/__init__.py	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/__init__.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,13 @@
+##############################################################################
+#
+# Copyright (c) 2005 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/__init__.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/browser.py
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/browser.py	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/browser.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,698 @@
+##############################################################################
+#
+# Copyright (c) 2005 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+__docformat__ = "reStructuredText"
+
+from cStringIO import StringIO
+import ClientForm
+import mechanize
+import operator
+import re
+import sys
+import time
+import urllib2
+import urlparse
+import zc.testbrowser.interfaces
+import zope.interface
+
+RegexType = type(re.compile(''))
+_compress_re = re.compile(r"\s+")
+compressText = lambda text: _compress_re.sub(' ', text.strip())
+
+def disambiguate(intermediate, msg, index):
+    if intermediate:
+        if index is None:
+            if len(intermediate) > 1:
+                raise ClientForm.AmbiguityError(msg)
+            else:
+                return intermediate[0]
+        else:
+            try:
+                return intermediate[index]
+            except KeyError:
+                msg = '%s index %d' % (msg, index)
+    raise LookupError(msg)
+
+def controlFactory(control, form, browser):
+    if isinstance(control, ClientForm.Item):
+        # it is a subcontrol
+        return ItemControl(control, form, browser)
+    else:
+        t = control.type
+        if t in ('checkbox', 'select', 'radio'):
+            return ListControl(control, form, browser)
+        elif t in ('submit', 'submitbutton'):
+            return SubmitControl(control, form, browser)
+        elif t=='image':
+            return ImageControl(control, form, browser)
+        else:
+            return Control(control, form, browser)
+
+def any(items):
+    return bool(sum([bool(i) for i in items]))
+
+def onlyOne(items, description):
+    total = sum([bool(i) for i in items])
+    if total == 0 or total > 1:
+        raise ValueError(
+            "Supply one and only one of %s as arguments" % description)
+
+def zeroOrOne(items, description):
+    if sum([bool(i) for i in items]) > 1:
+        raise ValueError(
+            "Supply no more than one of %s as arguments" % description)
+
+
+class SetattrErrorsMixin(object):
+    _enable_setattr_errors = False
+
+    def __setattr__(self, name, value):
+        if self._enable_setattr_errors:
+            # cause an attribute error if the attribute doesn't already exist
+            getattr(self, name)
+
+        # set the value
+        object.__setattr__(self, name, value)
+
+
+class PystoneTimer(object):
+    start_time = 0
+    end_time = 0
+    _pystones_per_second = None
+
+    @property
+    def pystonesPerSecond(self):
+        """How many pystones are equivalent to one second on this machine"""
+
+        # deferred import as workaround for Zope 2 testrunner issue:
+        # http://www.zope.org/Collectors/Zope/2268
+        from test import pystone
+        if self._pystones_per_second == None:
+            self._pystones_per_second = pystone.pystones(pystone.LOOPS/10)[1]
+        return self._pystones_per_second
+
+    def _getTime(self):
+        if sys.platform.startswith('win'):
+            # Windows' time.clock gives us high-resolution wall-time
+            return time.clock()
+        else:
+            # everyone else uses time.time
+            return time.time()
+
+    def start(self):
+        """Begin a timing period"""
+        self.start_time = self._getTime()
+        self.end_time = None
+
+    def stop(self):
+        """End a timing period"""
+        self.end_time = self._getTime()
+
+    @property
+    def elapsedSeconds(self):
+        """Elapsed time from calling `start` to calling `stop` or present time
+
+        If `stop` has been called, the timing period stopped then, otherwise
+        the end is the current time.
+        """
+        if self.end_time is None:
+            end_time = self._getTime()
+        else:
+            end_time = self.end_time
+        return end_time - self.start_time
+
+    @property
+    def elapsedPystones(self):
+        """Elapsed pystones in timing period
+
+        See elapsed_seconds for definition of timing period.
+        """
+        return self.elapsedSeconds * self.pystonesPerSecond
+
+
+class Browser(SetattrErrorsMixin):
+    """A web user agent."""
+    zope.interface.implements(zc.testbrowser.interfaces.IBrowser)
+
+    base = None
+    _contents = None
+    _counter = 0
+
+    def __init__(self, url=None, mech_browser=None):
+        if mech_browser is None:
+            mech_browser = mechanize.Browser()
+        self.mech_browser = mech_browser
+        self.timer = PystoneTimer()
+        self.raiseHttpErrors = True
+        self._enable_setattr_errors = True
+
+        if url is not None:
+            self.open(url)
+
+    @property
+    def url(self):
+        return self.mech_browser.geturl()
+
+    @property
+    def isHtml(self):
+        return self.mech_browser.viewing_html()
+
+    @property
+    def title(self):
+        return self.mech_browser.title()
+
+    @property
+    def contents(self):
+        if self._contents is not None:
+            return self._contents
+        response = self.mech_browser.response()
+        old_location = response.tell()
+        response.seek(0)
+        self._contents = response.read()
+        response.seek(old_location)
+        return self._contents
+
+    @property
+    def headers(self):
+        return self.mech_browser.response().info()
+
+    @apply
+    def handleErrors():
+        header_key = 'X-zope-handle-errors'
+
+        def get(self):
+            headers = self.mech_browser.addheaders
+            return dict(headers).get(header_key, True)
+
+        def set(self, value):
+            headers = self.mech_browser.addheaders
+            current_value = get(self)
+            if current_value == value:
+                return
+            if header_key in dict(headers):
+                headers.remove((header_key, current_value))
+            headers.append((header_key, value))
+
+        return property(get, set)
+
+    def open(self, url, data=None):
+        if self.base is not None:
+            url = urlparse.urljoin(self.base, url)
+        self._start_timer()
+        try:
+            try:
+                self.mech_browser.open(url, data)
+            except urllib2.HTTPError, e:
+                if e.code >= 200 and e.code <= 299:
+                    # 200s aren't really errors
+                    pass
+                elif self.raiseHttpErrors:
+                    raise
+        finally:
+            self._stop_timer()
+            self._changed()
+
+        # if the headers don't have a status, I suppose there can't be an error
+        if 'Status' in self.headers:
+            code, msg = self.headers['Status'].split(' ', 1)
+            code = int(code)
+            if self.raiseHttpErrors and code >= 400:
+                raise urllib2.HTTPError(url, code, msg, self.headers, fp=None)
+
+    def _start_timer(self):
+        self.timer.start()
+
+    def _stop_timer(self):
+        self.timer.stop()
+
+    @property
+    def lastRequestPystones(self):
+        return self.timer.elapsedPystones
+
+    @property
+    def lastRequestSeconds(self):
+        return self.timer.elapsedSeconds
+
+    def reload(self):
+        self._start_timer()
+        self.mech_browser.reload()
+        self._stop_timer()
+        self._changed()
+
+    def goBack(self, count=1):
+        self._start_timer()
+        self.mech_browser.back(count)
+        self._stop_timer()
+        self._changed()
+
+    def addHeader(self, key, value):
+        self.mech_browser.addheaders.append( (key, value) )
+
+    def getLink(self, text=None, url=None, id=None, index=0):
+        if id is not None:
+            def predicate(link):
+                return dict(link.attrs).get('id') == id
+            args = {'predicate': predicate}
+        else:
+            if isinstance(text, RegexType):
+                text_regex = text
+            elif text is not None:
+                text_regex = re.compile(re.escape(text), re.DOTALL)
+            else:
+                text_regex = None
+
+            if isinstance(url, RegexType):
+                url_regex = url
+            elif url is not None:
+                url_regex = re.compile(re.escape(url), re.DOTALL)
+            else:
+                url_regex = None
+            args = {'text_regex': text_regex, 'url_regex': url_regex}
+        args['nr'] = index
+        return Link(self.mech_browser.find_link(**args), self)
+
+    def _findByLabel(self, label, forms, include_subcontrols=False):
+        # forms are iterable of mech_forms
+        matches = re.compile(r'(^|\b|\W)%s(\b|\W|$)'
+                             % re.escape(compressText(label))).search
+        found = []
+        for f in forms:
+            for control in f.controls:
+                phantom = control.type in ('radio', 'checkbox')
+                if not phantom:
+                    for l in control.get_labels():
+                        if matches(l.text):
+                            found.append((control, f))
+                            break
+                if include_subcontrols and (
+                    phantom or control.type=='select'):
+
+                    for i in control.items:
+                        for l in i.get_labels():
+                            if matches(l.text):
+                                found.append((i, f))
+                                found_one = True
+                                break
+
+        return found
+
+    def _findByName(self, name, forms):
+        found = []
+        for f in forms:
+            for control in f.controls:
+                if control.name==name:
+                    found.append((control, f))
+        return found
+
+    def getControl(self, label=None, name=None, index=None):
+        intermediate, msg = self._get_all_controls(
+            label, name, self.mech_browser.forms(), include_subcontrols=True)
+        control, form = disambiguate(intermediate, msg, index)
+        return controlFactory(control, form, self)
+
+    def _get_all_controls(self, label, name, forms, include_subcontrols=False):
+        onlyOne([label, name], '"label" and "name"')
+
+        if label is not None:
+            res = self._findByLabel(label, forms, include_subcontrols)
+            msg = 'label %r' % label
+        elif name is not None:
+            res = self._findByName(name, forms)
+            msg = 'name %r' % name
+        return res, msg
+
+    def getForm(self, id=None, name=None, action=None, index=None):
+        zeroOrOne([id, name, action], '"id", "name", and "action"')
+
+        matching_forms = []
+        for form in self.mech_browser.forms():
+            if ((id is not None and form.attrs.get('id') == id)
+            or (name is not None and form.name == name)
+            or (action is not None and re.search(action, str(form.action)))
+            or id == name == action == None):
+                matching_forms.append(form)
+
+        if index is None and not any([id, name, action]):
+            if len(matching_forms) == 1:
+                index = 0
+            else:
+                raise ValueError(
+                    'if no other arguments are given, index is required.')
+
+        form = disambiguate(matching_forms, '', index)
+        self.mech_browser.form = form
+        return Form(self, form)
+
+    def _clickSubmit(self, form, control, coord):
+        labels = control.get_labels()
+        if labels:
+            label = labels[0].text
+        else:
+            label = None
+        self._start_timer()
+        self.mech_browser.open(form.click(
+            id=control.id, name=control.name, label=label, coord=coord))
+        self._stop_timer()
+
+    def _changed(self):
+        self._counter += 1
+        self._contents = None
+
+
+class Link(SetattrErrorsMixin):
+    zope.interface.implements(zc.testbrowser.interfaces.ILink)
+
+    def __init__(self, link, browser):
+        self.mech_link = link
+        self.browser = browser
+        self._browser_counter = self.browser._counter
+        self._enable_setattr_errors = True
+
+    def click(self):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        self.browser._start_timer()
+        self.browser.mech_browser.follow_link(self.mech_link)
+        self.browser._stop_timer()
+        self.browser._changed()
+
+    @property
+    def url(self):
+        return self.mech_link.absolute_url
+
+    @property
+    def text(self):
+        return self.mech_link.text
+
+    @property
+    def tag(self):
+        return self.mech_link.tag
+
+    @property
+    def attrs(self):
+        return dict(self.mech_link.attrs)
+
+    def __repr__(self):
+        return "<%s text=%r url=%r>" % (
+            self.__class__.__name__, self.text, self.url)
+
+
+class Control(SetattrErrorsMixin):
+    """A control of a form."""
+    zope.interface.implements(zc.testbrowser.interfaces.IControl)
+
+    _enable_setattr_errors = False
+
+    def __init__(self, control, form, browser):
+        self.mech_control = control
+        self.mech_form = form
+        self.browser = browser
+        self._browser_counter = self.browser._counter
+
+        if self.mech_control.type == 'file':
+            self.filename = None
+            self.content_type = None
+
+        # for some reason ClientForm thinks we shouldn't be able to modify
+        # hidden fields, but while testing it is sometimes very important
+        if self.mech_control.type == 'hidden':
+            self.mech_control.readonly = False
+
+        # disable addition of further attributes
+        self._enable_setattr_errors = True
+
+    @property
+    def disabled(self):
+        return bool(getattr(self.mech_control, 'disabled', False))
+
+    @property
+    def type(self):
+        return getattr(self.mech_control, 'type', None)
+
+    @property
+    def name(self):
+        return getattr(self.mech_control, 'name', None)
+
+    @property
+    def multiple(self):
+        return bool(getattr(self.mech_control, 'multiple', False))
+
+    @apply
+    def value():
+
+        def fget(self):
+            if (self.type == 'checkbox' and
+                len(self.mech_control.items) == 1 and
+                self.mech_control.items[0].name == 'on'):
+                return self.mech_control.items[0].selected
+            return self.mech_control.value
+
+        def fset(self, value):
+            if self._browser_counter != self.browser._counter:
+                raise zc.testbrowser.interfaces.ExpiredError
+            if self.mech_control.type == 'file':
+                self.mech_control.add_file(value,
+                                           content_type=self.content_type,
+                                           filename=self.filename)
+            elif self.type == 'checkbox' and len(self.mech_control.items) == 1:
+                self.mech_control.items[0].selected = bool(value)
+            else:
+                self.mech_control.value = value
+        return property(fget, fset)
+
+    def add_file(self, file, content_type, filename):
+        if not self.mech_control.type == 'file':
+            raise TypeError("Can't call add_file on %s controls"
+                            % self.mech_control.type)
+        if isinstance(file, str):
+            file = StringIO(file)
+        self.mech_control.add_file(file, content_type, filename)
+
+    def clear(self):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        self.mech_control.clear()
+
+    def __repr__(self):
+        return "<%s name=%r type=%r>" % (
+            self.__class__.__name__, self.name, self.type)
+
+
+class ListControl(Control):
+    zope.interface.implements(zc.testbrowser.interfaces.IListControl)
+
+    @apply
+    def displayValue():
+        # not implemented for anything other than select;
+        # would be nice if ClientForm implemented for checkbox and radio.
+        # attribute error for all others.
+
+        def fget(self):
+            return self.mech_control.get_value_by_label()
+
+        def fset(self, value):
+            if self._browser_counter != self.browser._counter:
+                raise zc.testbrowser.interfaces.ExpiredError
+            self.mech_control.set_value_by_label(value)
+
+        return property(fget, fset)
+
+    @property
+    def displayOptions(self):
+        res = []
+        for item in self.mech_control.items:
+            if not item.disabled:
+                for label in item.get_labels():
+                    if label.text:
+                        res.append(label.text)
+                        break
+                else:
+                    res.append(None)
+        return res
+
+    @property
+    def options(self):
+        if (self.type == 'checkbox' and len(self.mech_control.items) == 1 and
+            self.mech_control.items[0].name == 'on'):
+            return [True]
+        return [i.name for i in self.mech_control.items if not i.disabled]
+
+    @property
+    def disabled(self):
+        if self.type == 'checkbox' and len(self.mech_control.items) == 1:
+            return bool(getattr(self.mech_control.items[0], 'disabled', False))
+        return bool(getattr(self.mech_control, 'disabled', False))
+
+    @property
+    def controls(self):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        res = [controlFactory(i, self.mech_form, self.browser) for i in
+                self.mech_control.items]
+        for s in res:
+            s.__dict__['control'] = self
+        return res
+
+    def getControl(self, label=None, value=None, index=None):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+
+        onlyOne([label, value], '"label" and "value"')
+
+        if label is not None:
+            options = self.mech_control.get_items(label=label)
+            msg = 'label %r' % label
+        elif value is not None:
+            options = self.mech_control.get_items(name=value)
+            msg = 'value %r' % value
+        res = controlFactory(
+            disambiguate(options, msg, index), self.mech_form, self.browser)
+        res.__dict__['control'] = self
+        return res
+
+
+class SubmitControl(Control):
+    zope.interface.implements(zc.testbrowser.interfaces.ISubmitControl)
+
+    def click(self):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        self.browser._clickSubmit(self.mech_form, self.mech_control, (1,1))
+        self.browser._changed()
+
+
+class ImageControl(Control):
+    zope.interface.implements(zc.testbrowser.interfaces.IImageSubmitControl)
+
+    def click(self, coord=(1,1)):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        self.browser._clickSubmit(self.mech_form, self.mech_control, coord)
+        self.browser._changed()
+
+
+class ItemControl(SetattrErrorsMixin):
+    zope.interface.implements(zc.testbrowser.interfaces.IItemControl)
+
+    def __init__(self, item, form, browser):
+        self.mech_item = item
+        self.mech_form = form
+        self.browser = browser
+        self._browser_counter = self.browser._counter
+        self._enable_setattr_errors = True
+
+    @property
+    def control(self):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        res = controlFactory(
+            self.mech_item._control, self.mech_form, self.browser)
+        self.__dict__['control'] = res
+        return res
+
+    @property
+    def disabled(self):
+        return self.mech_item.disabled
+
+    @apply
+    def selected():
+
+        def fget(self):
+            return self.mech_item.selected
+
+        def fset(self, value):
+            if self._browser_counter != self.browser._counter:
+                raise zc.testbrowser.interfaces.ExpiredError
+            self.mech_item.selected = value
+
+        return property(fget, fset)
+
+    @property
+    def optionValue(self):
+        return self.mech_item.attrs.get('value')
+
+    def click(self):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        self.mech_item.selected = not self.mech_item.selected
+
+    def __repr__(self):
+        return "<%s name=%r type=%r optionValue=%r selected=%r>" % (
+            self.__class__.__name__, self.mech_item._control.name,
+            self.mech_item._control.type, self.optionValue, self.mech_item.selected)
+
+
+class Form(SetattrErrorsMixin):
+    """HTML Form"""
+    zope.interface.implements(zc.testbrowser.interfaces.IForm)
+
+    def __init__(self, browser, form):
+        """Initialize the Form
+
+        browser - a Browser instance
+        form - a ClientForm instance
+        """
+        self.browser = browser
+        self.mech_form = form
+        self._browser_counter = self.browser._counter
+        self._enable_setattr_errors = True
+
+    @property
+    def action(self):
+        return self.mech_form.action
+
+    @property
+    def method(self):
+        return self.mech_form.method
+
+    @property
+    def enctype(self):
+        return self.mech_form.enctype
+
+    @property
+    def name(self):
+        return self.mech_form.name
+
+    @property
+    def id(self):
+        return self.mech_form.attrs.get('id')
+
+    def submit(self, label=None, name=None, index=None, coord=(1,1)):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        form = self.mech_form
+        if label is not None or name is not None:
+            intermediate, msg = self.browser._get_all_controls(
+                label, name, (form,))
+            intermediate = [
+                (control, form) for (control, form) in intermediate if
+                control.type in ('submit', 'submitbutton', 'image')]
+            control, form = disambiguate(intermediate, msg, index)
+            self.browser._clickSubmit(form, control, coord)
+        else: # JavaScript sort of submit
+            if index is not None or coord != (1,1):
+                raise ValueError(
+                    'May not use index or coord without a control')
+            request = self.mech_form._switch_click("request", urllib2.Request)
+            self.browser._start_timer()
+            self.browser.mech_browser.open(request)
+            self.browser._stop_timer()
+        self.browser._changed()
+
+    def getControl(self, label=None, name=None, index=None):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        intermediate, msg = self.browser._get_all_controls(
+            label, name, (self.mech_form,), include_subcontrols=True)
+        control, form = disambiguate(intermediate, msg, index)
+        return controlFactory(control, form, self.browser)


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/browser.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/dummymodules.py
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/dummymodules.py	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/dummymodules.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,15 @@
+class DummyInterfaceModule(object):
+    Interface = object
+
+    def __getattr__(self, name):
+        return lambda *args, **kws: None
+
+interface = DummyInterfaceModule()
+
+class DummySchemaModule(object):
+    def __getattr__(self, name):
+        return lambda *args, **kws: interface.Attribute('')
+
+schema = DummySchemaModule()
+
+


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/dummymodules.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/__init__.py
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/__init__.py	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/__init__.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1 @@
+# Make a package.


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/ftests/__init__.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/controls.html
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/controls.html	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/controls.html	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,200 @@
+<html>
+  <body>
+
+    <h1>Controls Tests</h1>
+
+    <form action="controls.html" method="post">
+
+      <div>
+        <label for="text-value">Text Control</label>
+        <em tal:condition="request/text-value|nothing"
+            tal:content="request/text-value"></em>
+        <input type="text" name="text-value" id="text-value" 
+               value="Some Text" />
+      </div>
+
+      <div>
+        <label for="password-value">Password Control</label>
+        <em tal:condition="request/password-value|nothing"
+            tal:content="request/password-value"></em>
+        <input type="password" name="password-value" id="password-value"
+               value="Password" />
+      </div>
+
+      <div>
+        <label for="hidden-value">Hidden Control</label> (label: hee hee)
+        <em tal:condition="request/hidden-value|nothing"
+            tal:content="request/hidden-value"></em>
+        <input type="hidden" name="hidden-value" id="hidden-value"
+               value="Hidden" />
+      </div>
+
+      <div>
+        <label for="textarea-value">Text Area Control</label>
+        <em tal:condition="request/textarea-value|nothing"
+            tal:content="request/textarea-value"></em>
+        <textarea name="textarea-value" id="textarea-value">
+          Text inside
+          area!
+        </textarea>
+      </div>
+
+      <div>
+        <label for="file-value">File Control</label>
+        <em tal:condition="request/file-value|nothing"
+            tal:content="request/file-value"></em>
+        <input type="file" name="file-value" id="file-value" />
+      </div>
+
+      <div>
+        <label for="single-select-value">Single Select Control</label>
+        <em tal:condition="request/single-select-value|nothing"
+            tal:content="request/single-select-value"></em>
+        <select name="single-select-value" id="single-select-value">
+          <option value="1">Uno</option>
+          <option value="2">Dos</option>
+          <option value="3" label="Third">Tres</option>
+        </select>
+      </div>
+
+      <div>
+        <label for="multi-select-value">Multiple Select Control</label>
+        <em tal:condition="request/multi-select-value|nothing"
+            tal:content="request/multi-select-value"></em>
+        <select name="multi-select-value" id="multi-select-value"
+                multiple="multiple">
+          <option value="1">Un</option>
+          <option value="2">Deux</option>
+          <option value="3" label="Third">Trois</option>
+        </select>
+      </div>
+
+      <div>
+        <em tal:condition="request/single-unvalued-checkbox-value|nothing"
+            tal:content="request/single-unvalued-checkbox-value"></em>
+        <input type="checkbox" name="single-unvalued-checkbox-value" 
+               id="single-unvalued-checkbox" checked="checked" />
+        <label for="single-unvalued-checkbox">Single Unvalued Checkbox</label>
+      </div>
+
+      <div>
+        <em tal:condition="
+            request/single-disabled-unvalued-checkbox-value|nothing"
+            tal:content="request/single-disabled-unvalued-checkbox-value"></em>
+        <input type="checkbox" name="single-disabled-unvalued-checkbox-value" 
+               id="single-disabled-unvalued-checkbox" checked="checked"
+               disabled="disabled" />
+        <label for="single-disabled-unvalued-checkbox">
+          Single Disabled Unvalued Checkbox
+        </label>
+      </div>
+
+      <div>
+        <em tal:condition="request/single-valued-checkbox-value|nothing"
+            tal:content="request/single-valued-checkbox-value"></em>
+        <label><input type="checkbox" name="single-valued-checkbox-value" 
+                      value="1" checked="checked" />Single Valued Checkbox
+        </label>
+      </div>
+
+      <div>
+        (Multi checkbox: options have the labels)
+        <em tal:condition="request/multi-checkbox-value|nothing"
+            tal:content="request/multi-checkbox-value"></em>
+        <label><input type="checkbox" name="multi-checkbox-value" value="1" 
+                      checked="checked" /> One</label>
+        <input type="checkbox" name="multi-checkbox-value" value="2" 
+               id="multi-checkbox-value-2" />
+        <label for="multi-checkbox-value-2">Two</label>
+        <label><input type="checkbox" name="multi-checkbox-value" value="3" 
+                      id="multi-checkbox-value-3" checked="checked" />Three
+        </label>
+        <label for="multi-checkbox-value-3">Third</label>
+      </div>
+
+      <div>
+        (Radio: options have the labels)
+        <em tal:condition="request/radio-value|nothing"
+            tal:content="request/radio-value"></em>
+        <label><input type="radio" name="radio-value" value="1" />Ein</label>
+        <input type="radio" name="radio-value" id="radio-value-2" value="2"
+               checked="checked" />
+        <label for="radio-value-2">Zwei</label>
+        <label><input type="radio" name="radio-value" id="radio-value-3"
+                      value="3" /> Drei</label>
+        <label for="radio-value-3">Third</label>
+      </div>
+
+      <div>
+        <label for="image-value">Image Control</label>
+        <em tal:condition="request/image-value.x|nothing"
+            tal:content="request/image-value.x"></em>
+        <em tal:condition="request/image-value.y|nothing"
+            tal:content="request/image-value.y"></em>
+        <input type="image" name="image-value" id="image-value"
+               src="zope3logo.gif" />
+      </div>
+
+      <div>
+        <label for="submit-value">Standard Submit Control</label>
+        <em tal:condition="request/submit-value|nothing"
+            tal:content="request/submit-value"></em>
+        <input type="submit" name="submit-value" id="submit-value"
+               value="Submit This" />
+      </div>
+
+      <div>
+        <label for="ambiguous-control-name">Ambiguous Control</label>
+        <input type="text" name="ambiguous-control-name"
+               id="ambiguous-control-name" value="First" />
+      </div>
+
+      <div>
+        <label for="ambiguous-control-name">Ambiguous Control</label>
+        <input type="text" name="ambiguous-control-name"
+               id="ambiguous-control-name" value="Second" />
+      </div>
+
+      <div>
+        <label for="label-needs-normalization">  The Label
+          Needs Whitespace Normalization
+          Badly  </label>
+        <input type="text" name="label-needs-normalization"
+               id="label-needs-normalization" />
+      </div>
+
+      <div>
+        <label for="non-word-characters">*[non word characters should not
+          confuse]</label>
+        <input type="text" name="non-word-characters"
+               id="non-word-characters" />
+      </div>
+
+      <div>
+        <label for="two-labels">Multiple labels really</label>
+        <label for="two-labels">really are possible</label>
+        <input type="text" name="two-labels"
+               id="two-labels" />
+      </div>
+
+      <div>
+        <label>Labels can be connected by containing their respective fields
+          <input type="text" name="contained-in-label" />
+        </label>
+      </div>
+
+      <div>
+        If you have a select field with a label that overlaps with one of its
+        options' labels, that is ambiguous.
+        <label for="ambiguous-subcontrol">Sub-control Ambiguity</label>
+        <select name="ambiguous-subcontrol" id="ambiguous-subcontrol">
+          <option value="">(none)</option>
+          <option value="ambiguous">Sub-control Ambiguity Exemplified</option>
+        </select>
+      </div>
+
+
+    </form>
+
+  </body>
+</html>

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/forms.html
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/forms.html	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/forms.html	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,39 @@
+<html>
+  <body>
+
+    <h1>Forms Tests</h1>
+
+    <em tal:condition="request/text-value|nothing"
+        tal:content="request/text-value" />
+
+    <form id="1" name="one" action="forms.html" method="post">
+      <input type="text" name="text-value" value="First Text" />
+      <input type="image" name="image-1" src="zope3logo.gif" />
+      <input type="submit" name="submit-1" value="Submit" />
+    </form>
+
+    <form id="2" name="two" action="forms.html" method="post">
+      <input type="text" name="text-value" value="Second Text" />
+      <input type="submit" name="submit-2" value="Submit" />
+    </form>
+
+    <form id="3" name="three" action="forms.html" method="post">
+      <label for="text-value-3">Text Control</label>
+      <input type="text" name="text-value" id="text-value-3"
+             value="Third Text" />
+      <input type="submit" name="submit-3" value="Submit" />
+    </form>
+
+    <form action="forms.html" method="post">
+      <label for="text-value-4">Text Control</label>
+      <input type="text" name="text-value" id="text-value-4"
+             value="Fourth Text" />
+      <em tal:condition="python: 'hidden-4' in request.form and
+                                 'submit-4' not in request.form"
+        >Submitted without the submit button.</em>
+      <input type="submit" name="submit-4" value="Don't Submit Me" />
+      <input type="hidden" name="hidden-4" value="marker" />
+    </form>
+
+  </body>
+</html>

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/ftesting.zcml
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/ftesting.zcml	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/ftesting.zcml	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,38 @@
+<configure 
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:browser="http://namespaces.zope.org/browser"
+    i18n_domain="zope"
+    package="zope.testbrowser"
+    >
+
+  <!-- This file is the equivalent of site.zcml and it is -->
+  <!-- used for functional testing setup -->
+
+  <include package="zope.app.zcmlfiles" />
+  <include package="zope.app.authentication" />
+
+  <!-- Principals -->
+
+  <unauthenticatedPrincipal
+      id="zope.anybody"
+      title="Unauthenticated User" />
+
+
+  <include package="zope.app.securitypolicy" file="meta.zcml"/>
+
+  <securityPolicy
+    component="zope.app.securitypolicy.zopepolicy.ZopeSecurityPolicy" />
+
+  <role id="zope.Anonymous" title="Everybody"
+                 description="All users have this role implicitly" />
+
+  <!-- Replace the following directive if you don't want public access -->
+  <grant permission="zope.View"
+                  role="zope.Anonymous" />
+
+
+  <browser:resourceDirectory
+      name="testbrowser"
+      directory="ftests" />
+
+</configure>


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/ftests/ftesting.zcml
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/index.html
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/index.html	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/index.html	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,8 @@
+<html>
+  <head>
+    <title>Simple Page</title>
+  </head>
+  <body>
+    <h1>Simple Page</h1>
+  </body>
+</html>

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/navigate.html
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/navigate.html	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/navigate.html	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,35 @@
+<html>
+  <body>
+
+    <h1>Navigation Tests</h1>
+
+    <p tal:condition="request/message|nothing">
+      Message: <em tal:content="request/message">Message</em>
+    </p>
+
+    <a href="target.html">Link Text</a>
+
+    <a href="target.html"> Link Text 
+    with     Whitespace	Normalization (and parens) </a>
+
+    <a href="target.html" id="anchorid">By Anchor Id</a>
+
+    <a href="target.html">Spaces in the URL</a>
+
+    <form action="navigate.html" method="post">
+      <input name="message" value="By Form Submit" />
+      <input type="submit" name="submit-form" value="Submit" />
+    </form>
+
+    <img src="./zope3logo.gif" usemap="#zope3logo" />
+    <map name="zope3logo">
+      <area shape="rect" alt="Zope3"
+            href="target.html" id="zope3" title="Zope 3"
+            coords="44,7,134,33" />
+      <area shape="circle" alt="Logo"
+            href="navigate.html?message=Logo" id="logo" title="Logo"
+            coords="23,21,18" />
+    </map>
+
+  </body>
+</html>

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/notitle.html
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/notitle.html	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/notitle.html	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,5 @@
+<html>
+  <body>
+    <h1>No Title</h1>
+  </body>
+</html>
\ No newline at end of file

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/oneform.html
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/oneform.html	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/oneform.html	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,14 @@
+<html>
+  <body>
+
+    <h1>Single Form Tests</h1>
+
+    <form id="1" name="one" action="forms.html"
+          enctype="multipart/form-data" method="post">
+      <input type="text" name="text-value" value="First Text" />
+      <input type="image" name="image-1" src="zope3logo.gif" />
+      <input type="submit" name="submit-1" value="Submit" />
+    </form>
+
+  </body>
+</html>

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/target.html
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/ftests/target.html	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/ftests/target.html	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,8 @@
+<html>
+  <head>
+    <title>Target Page</title>
+  </head>
+  <body>
+    This page is the target of a link.
+  </body>
+</html>

Added: zc.testbrowser/trunk/src/zc/testbrowser/ftests/zope3logo.gif
===================================================================
(Binary files differ)


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/ftests/zope3logo.gif
___________________________________________________________________
Name: svn:mime-type
   + application/octet-stream

Added: zc.testbrowser/trunk/src/zc/testbrowser/headers.txt
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/headers.txt	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/headers.txt	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,25 @@
+Headers
+-------
+
+As you can see, the `contents` of the browser does not return any HTTP
+headers.  The headers are accessible via a separate attribute, which is an
+``httplib.HTTPMessage`` instance (httplib is a part of Python's standard
+library):
+
+    >>> browser.base = 'http://localhost:%s/' % TEST_PORT
+    >>> browser.open('index.html')
+    >>> browser.headers
+    <httplib.HTTPMessage instance...>
+
+The headers can be accessed as a string:
+
+    >>> print browser.headers
+    Server: BaseHTTP
+    Date: Mon, 17 Sep 2007 10:05:42 GMT
+    Connection: close
+    Content-type: text/html
+
+Or as a mapping:
+
+    >>> browser.headers['content-type']
+    'text/html'


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/headers.txt
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/interfaces.py
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/interfaces.py	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/interfaces.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,354 @@
+##############################################################################
+#
+# Copyright (c) 2005 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+__docformat__ = "reStructuredText"
+
+from zope import interface, schema
+
+
+class IBrowser(interface.Interface):
+    """A Programmatic Web Browser."""
+
+    url = schema.URI(
+        title=u"URL",
+        description=u"The URL the browser is currently showing.",
+        required=True)
+
+    headers = schema.Field(
+        title=u"Headers",
+        description=(u"Headers of the HTTP response; a "
+                     "``httplib.HTTPMessage``."),
+        required=True)
+
+    contents = schema.Text(
+        title=u"Contents",
+        description=u"The complete response body of the HTTP request.",
+        required=True)
+
+    isHtml = schema.Bool(
+        title=u"Is HTML",
+        description=u"Tells whether the output is HTML or not.",
+        required=True)
+
+    title = schema.TextLine(
+        title=u"Base",
+        description=u"Base URL for opening relative paths",
+        required=False)
+
+    title = schema.TextLine(
+        title=u"Title",
+        description=u"Title of the displayed page",
+        required=False)
+
+    handleErrors = schema.Bool(
+        title=u"Handle Errors",
+        description=(u"Describes whether server-side errors will be handled "
+                     u"by the publisher. If set to ``False``, the error will "
+                     u"progress all the way to the test, which is good for "
+                     u"debugging."),
+        default=True,
+        required=True)
+
+    def addHeader(key, value):
+        """Adds a header to each HTTP request.
+
+        Adding additional headers can be useful in many ways, from setting the
+        credentials token to specifying the browser identification string.
+        """
+
+    def open(url, data=None):
+        """Open a URL in the browser.
+
+        The URL must be fully qualified unless a ``base`` has been provided.
+
+        The ``data`` argument describes the data that will be sent as the body
+        of the request.
+        """
+
+    def reload():
+        """Reload the current page.
+
+        Like a browser reload, if the past request included a form submission,
+        the form data will be resubmitted."""
+
+    def goBack(count=1):
+        """Go back in history by a certain amount of visisted pages.
+
+        The ``count`` argument specifies how far to go back. It is set to 1 by
+        default.
+        """
+
+    def getLink(text=None, url=None, id=None):
+        """Return an ILink from the page.
+
+        The link is found by the arguments of the method.  One or more may be
+        used together.
+
+          o ``text`` -- A regular expression trying to match the link's text,
+            in other words everything between <a> and </a> or the value of the
+            submit button.
+
+          o ``url`` -- The URL the link is going to. This is either the
+            ``href`` attribute of an anchor tag or the action of a form.
+
+          o ``id`` -- The id attribute of the anchor tag submit button.
+        """
+
+    lastRequestSeconds = schema.Field(
+        title=u"Seconds to Process Last Request",
+        description=(
+        u"""Return how many seconds (or fractions) the last request took.
+
+        The values returned have the same resolution as the results from
+        ``time.clock``.
+        """),
+        required=True,
+        readonly=True)
+
+    lastRequestPystones = schema.Field(
+        title=
+            u"Approximate System-Independent Effort of Last Request (Pystones)",
+        description=(
+        u"""Return how many pystones the last request took.
+
+        This number is found by multiplying the number of pystones/second at
+        which this system benchmarks and the result of ``lastRequestSeconds``.
+        """),
+        required=True,
+        readonly=True)
+
+    def getControl(label=None, name=None, index=None):
+        """Get a control from the page.
+
+        Only one of ``label`` and ``name`` may be provided.  ``label``
+        searches form labels (including submit button values, per the HTML 4.0
+        spec), and ``name`` searches form field names.
+
+        Label value is searched as case-sensitive whole words within
+        the labels for each control--that is, a search for 'Add' will match
+        'Add a contact' but not 'Address'.  A word is defined as one or more
+        alphanumeric characters or the underline.
+
+        If no values are found, the code raises a LookupError.
+
+        If ``index`` is None (the default) and more than one field matches the
+        search, the code raises an AmbiguityError.  If an index is provided,
+        it is used to choose the index from the ambiguous choices.  If the
+        index does not exist, the code raises a LookupError.
+        """
+
+    def getForm(id=None, name=None, action=None, index=None):
+        """Get a form from the page.
+
+        Zero or one of ``id``, ``name``, and ``action`` may be provided.  If
+        none are provided the index alone is used to determine the return
+        value.
+
+        If no values are found, the code raises a LookupError.
+
+        If ``index`` is None (the default) and more than one form matches the
+        search, the code raises an AmbiguityError.  If an index is provided,
+        it is used to choose the index from the ambiguous choices.  If the
+        index does not exist, the code raises a LookupError.
+        """
+
+
+class ExpiredError(Exception):
+    """The browser page to which this was attached is no longer active"""
+
+
+class IControl(interface.Interface):
+    """A control (input field) of a page."""
+
+    name = schema.TextLine(
+        title=u"Name",
+        description=u"The name of the control.",
+        required=True)
+
+    value = schema.Field(
+        title=u"Value",
+        description=u"The value of the control",
+        default=None,
+        required=True)
+
+    type = schema.Choice(
+        title=u"Type",
+        description=u"The type of the control",
+        values=['text', 'password', 'hidden', 'submit', 'checkbox', 'select',
+                'radio', 'image', 'file'],
+        required=True)
+
+    disabled = schema.Bool(
+        title=u"Disabled",
+        description=u"Describes whether a control is disabled.",
+        default=False,
+        required=False)
+
+    multiple = schema.Bool(
+        title=u"Multiple",
+        description=u"Describes whether this control can hold multiple values.",
+        default=False,
+        required=False)
+
+    def clear():
+        """Clear the value of the control."""
+
+
+class IListControl(IControl):
+    """A radio button, checkbox, or select control"""
+
+    options = schema.List(
+        title=u"Options",
+        description=u"""\
+        A list of possible values for the control.""",
+        required=True)
+
+    displayOptions = schema.List(
+        # TODO: currently only implemented for select by ClientForm
+        title=u"Options",
+        description=u"""\
+        A list of possible display values for the control.""",
+        required=True)
+
+    displayValue = schema.Field(
+        # TODO: currently only implemented for select by ClientForm
+        title=u"Value",
+        description=u"The value of the control, as rendered by the display",
+        default=None,
+        required=True)
+
+    def getControl(label=None, value=None, index=None):
+        """return subcontrol for given label or value, disambiguated by index
+        if given.  Label value is searched as case-sensitive whole words within
+        the labels for each item--that is, a search for 'Add' will match
+        'Add a contact' but not 'Address'.  A word is defined as one or more
+        alphanumeric characters or the underline."""
+
+    controls = interface.Attribute(
+        """a list of subcontrols for the control.  mutating list has no effect
+        on control (although subcontrols may be changed as usual).""")
+
+
+class ISubmitControl(IControl):
+
+    def click():
+        "click the submit button"
+
+
+class IImageSubmitControl(ISubmitControl):
+
+    def click(coord=(1,1,)):
+        "click the submit button with optional coordinates"
+
+
+class IItemControl(interface.Interface):
+    """a radio button or checkbox within a larger multiple-choice control"""
+
+    control = schema.Object(
+        title=u"Control",
+        description=(u"The parent control element."),
+        schema=IControl,
+        required=True)
+
+    disabled = schema.Bool(
+        title=u"Disabled",
+        description=u"Describes whether a subcontrol is disabled.",
+        default=False,
+        required=False)
+
+    selected = schema.Bool(
+        title=u"Selected",
+        description=u"Whether the subcontrol is selected",
+        default=None,
+        required=True)
+
+    optionValue = schema.TextLine(
+        title=u"Value",
+        description=u"The value of the subcontrol",
+        default=None,
+        required=False)
+
+
+class ILink(interface.Interface):
+
+    def click():
+        """click the link, going to the URL referenced"""
+
+    url = schema.TextLine(
+        title=u"URL",
+        description=u"The normalized URL of the link",
+        required=False)
+
+    text = schema.TextLine(
+        title=u'Text',
+        description=u'The contained text of the link',
+        required=False)
+
+
+class IForm(interface.Interface):
+    """An HTML form of the page."""
+
+    action = schema.TextLine(
+        title=u"Action",
+        description=u"The action (or URI) that is opened upon submittance.",
+        required=True)
+
+    name = schema.TextLine(
+        title=u"Name",
+        description=u"The value of the `name` attribute in the form tag, "
+                    u"if specified.",
+        required=True)
+
+    id = schema.TextLine(
+        title=u"Id",
+        description=u"The value of the `id` attribute in the form tag, "
+                    u"if specified.",
+        required=True)
+
+    def getControl(label=None, name=None, index=None):
+        """Get a control in the page.
+
+        Only one of ``label`` and ``name`` may be provided.  ``label``
+        searches form labels (including submit button values, per the HTML 4.0
+        spec), and ``name`` searches form field names.
+
+        Label value is searched as case-sensitive whole words within
+        the labels for each control--that is, a search for 'Add' will match
+        'Add a contact' but not 'Address'.  A word is defined as one or more
+        alphanumeric characters or the underline.
+
+        If no values are found, the code raises a LookupError.
+
+        If ``index`` is None (the default) and more than one field matches the
+        search, the code raises an AmbiguityError.  If an index is provided,
+        it is used to choose the index from the ambiguous choices.  If the
+        index does not exist, the code raises a LookupError.
+        """
+
+    def submit(label=None, name=None, index=None, coord=(1,1)):
+        """Submit this form.
+
+        The `label`, `name`, and `index` arguments select the submit button to
+        use to submit the form.  You may label or name, with index to
+        disambiguate.
+
+        Label value is searched as case-sensitive whole words within
+        the labels for each control--that is, a search for 'Add' will match
+        'Add a contact' but not 'Address'.  A word is defined as one or more
+        alphanumeric characters or the underline.
+
+        The control code works identically to 'get' except that searches are
+        filtered to find only submit and image controls.
+        """
+
+


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/interfaces.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/real.js
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/real.js	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/real.js	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,110 @@
+var tb_tokens = {};
+var tb_next_token = 0;
+
+tb_page_loaded = false;
+document.getElementById("appcontent"
+    ).addEventListener("load", function() { tb_page_loaded = true; }, true);
+
+function tb_get_link_by_predicate(predicate, index) {
+    var anchors = content.document.getElementsByTagName('a');
+    var i=0;
+    var found = null;
+    if (index == undefined) index = null;
+    for (var x=0; x < anchors.length; x++) {
+        a = anchors[x];
+        if (!predicate(a)) {
+            continue;
+        }
+        // this anchor matches
+
+        // if we weren't given an index, but we found more than
+        // one match, we have an ambiguity
+        if (index == null && i > 0) {
+            return 'ambiguity error';
+        }
+
+        found = x;
+
+        // if we were given an index and we just found it, stop
+        if (index != null && i == index) {
+            break
+        }
+        i++;
+    }
+    if (found != null) {
+        tb_tokens[tb_next_token] = anchors[found];
+        return tb_next_token++;
+    }
+    return false; // link not found
+}
+
+function tb_normalize_whitespace(text) {
+    text = text.replace(/[\n\r]+/g, ' ');
+    text = text.replace(/\s+/g, ' ');
+    text = text.replace(/ +$/g, '');
+    text = text.replace(/^ +/g, '');
+    return text;
+}
+
+function tb_get_link_by_text(text, index) {
+    text = tb_normalize_whitespace(text);
+    return tb_get_link_by_predicate(
+        function (a) {
+            //alert(tb_normalize_whitespace(a.textContent) + '|' + text + '|' + tb_normalize_whitespace(a.textContent).indexOf(text));
+            return tb_normalize_whitespace(a.textContent).indexOf(text) != -1;
+        }, index)
+}
+
+function tb_get_link_by_url(url, index) {
+    return tb_get_link_by_predicate(
+        function (a) {
+            return a.href.indexOf(url) != -1;
+        }, index)
+}
+
+function tb_get_link_by_id(id, index) {
+    return tb_get_link_by_predicate(
+        function (a) {
+            alert(a.id + '|' + id + '|' + (a.id == id));
+            return a.id == id;
+        }, index)
+}
+
+function tb_take_screen_shot(out_path) {
+    // The `subject` is what we want to take a screen shot of.
+    var subject = content.document;
+    var canvas = content.document.createElement('canvas');
+    canvas.width = subject.width;
+    canvas.height = subject.height;
+
+    var ctx = canvas.getContext('2d');
+    ctx.drawWindow(content, 0, 0, subject.width, subject.height, 'rgb(0,0,0)');
+    tb_save_canvas(canvas, out_path);
+}
+
+function tb_save_canvas(canvas, out_path) {
+    var io = Components.classes['@mozilla.org/network/io-service;1'
+        ].getService(Components.interfaces.nsIIOService);
+    var source = io.newURI(canvas.toDataURL('image/png', ''), 'UTF8', null);
+    var persist = Components.classes[
+        '@mozilla.org/embedding/browser/nsWebBrowserPersist;1'
+        ].createInstance(Components.interfaces.nsIWebBrowserPersist);
+    var file = Components.classes['@mozilla.org/file/local;1'
+        ].createInstance(Components.interfaces.nsILocalFile);
+    file.initWithPath(out_path);
+    persist.saveURI(source, null, null, null, null, file);
+}
+
+function tb_follow_link(token) {
+    var a = tb_tokens[token];
+    var evt = a.ownerDocument.createEvent('MouseEvents');
+    evt.initMouseEvent('click', true, true, a.ownerDocument.defaultView,
+        1, 0, 0, 0, 0, false, false, false, false, 0, null);
+    a.dispatchEvent(evt);
+    // empty the tokens data structure, they're all expired now
+    tb_tokens = {};
+}
+
+function tb_get_link_text(token) {
+    return tb_normalize_whitespace(tb_tokens[token].textContent);
+}

Added: zc.testbrowser/trunk/src/zc/testbrowser/real.py
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/real.py	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/real.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,226 @@
+import ClientForm
+import os.path
+import re
+import simplejson
+import socket
+import telnetlib
+import time
+import urlparse
+import zc.testbrowser.browser
+import zc.testbrowser.interfaces
+import zope.interface
+
+PROMPT = re.compile('repl\d?> ')
+
+class BrowserStateError(RuntimeError):
+    pass
+
+class Browser(zc.testbrowser.browser.SetattrErrorsMixin):
+    zope.interface.implements(zc.testbrowser.interfaces.IBrowser)
+
+    base = None
+    raiseHttpErrors = True
+    _counter = 0
+    timeout = 5 # XXX debug only, change back to 60
+
+    def __init__(self, url=None, host='localhost', port=4242):
+        self.timer = zc.testbrowser.browser.PystoneTimer()
+        self.init_repl(host, port)
+        self._enable_setattr_errors = True
+
+        if url is not None:
+            self.open(url)
+
+    def init_repl(self, host, port):
+        dir = os.path.dirname(__file__)
+        js_path = os.path.join(dir, 'real.js')
+        try:
+            self.telnet = telnetlib.Telnet(host, port)
+        except socket.error, e:
+            raise RuntimeError('Error connecting to Firefox at %s:%s.'
+                ' Is MozRepl running?' % (host, port))
+
+        self.telnet.write(open(js_path, 'rt').read())
+        self.expect([PROMPT])
+
+    def execute(self, js):
+        if not js.strip():
+            return
+        self.telnet.write("'MARKER'")
+        self.telnet.read_until('MARKER')
+        self.expect([PROMPT])
+        self.telnet.write(js)
+        i, match, text = self.expect([PROMPT])
+        if '!!!' in text: import pdb;pdb.set_trace() # XXX debug only, remove
+        result = text.rsplit('\n', 1)
+        if len(result) == 1:
+            return None
+        else:
+            return result[0]
+
+    def executeLines(self, js):
+        lines = js.split('\n')
+        for line in lines:
+            self.execute(line)
+
+    def expect(self, res):
+        i, match, text = self.telnet.expect([PROMPT], self.timeout)
+        if match is None:
+            raise RuntimeError('unexpected result from MozRepl')
+        return i, match, text
+
+    def _changed(self):
+        self._counter += 1
+
+    @property
+    def url(self):
+        return self.execute('content.location')
+
+    def waitForPageLoad(self):
+        start = time.time()
+        while self.execute('tb_page_loaded') == 'false':
+            time.sleep(0.001)
+            if time.time() - start > self.timeout:
+                raise RuntimeError('timed out waiting for page load')
+
+        self.execute('tb_page_loaded = false;')
+
+    def open(self, url, data=None):
+        if self.base is not None:
+            url = urlparse.urljoin(self.base, url)
+        assert data is None
+        self.start_timer()
+        try:
+            self.execute('content.location = ' + simplejson.dumps(url))
+            self.waitForPageLoad()
+        finally:
+            self.stop_timer()
+            self._changed()
+
+        # TODO raise non-200 errors
+
+    @property
+    def isHtml(self):
+        return self.execute('content.document.contentType') == 'text/html'
+
+    @property
+    def title(self):
+        if not self.isHtml:
+            raise BrowserStateError('not viewing HTML')
+
+        result = self.execute('content.document.title')
+        if result is '':
+            result = None
+        return result
+
+    @property
+    def contents(self):
+        return self.execute('content.document.documentElement.innerHTML')
+
+    @property
+    def headers(self):
+        raise NotImplementedError
+
+    @apply
+    def handleErrors():
+        def get(self):
+            raise NotImplementedError
+
+        def set(self, value):
+            raise NotImplementedError
+
+        return property(get, set)
+
+    def start_timer(self):
+        self.timer.start()
+
+    def stop_timer(self):
+        self.timer.stop()
+
+    @property
+    def lastRequestPystones(self):
+        return self.timer.elapsedPystones
+
+    @property
+    def lastRequestSeconds(self):
+        return self.timer.elapsedSeconds
+
+    def reload(self):
+        self.start_timer()
+        self.execute('content.document.location = content.document.location')
+        self.waitForPageLoad()
+        self.stop_timer()
+
+    def goBack(self, count=1):
+        self.start_timer()
+        self.execute('content.back()')
+        # Our method of knowing when the page finishes loading doesn't work
+        # for "back", so for now just sleep a little, and hope it is enough.
+        time.sleep(1)
+        self.stop_timer()
+        self._changed()
+
+    def addHeader(self, key, value):
+        raise NotImplementedError
+
+    def getLink(self, text=None, url=None, id=None, index=0):
+        zc.testbrowser.browser.onlyOne((text, url, id), 'text, url, or id')
+        js_index = simplejson.dumps(index)
+        if text is not None:
+            msg = 'text %r' % text
+            token = self.execute('tb_get_link_by_text(%s, %s)'
+                 % (simplejson.dumps(text), js_index))
+        elif url is not None:
+            msg = 'url %r' % url
+            token = self.execute('tb_get_link_by_url(%s, %s)'
+                 % (simplejson.dumps(url), js_index))
+        elif id is not None:
+            msg = 'id %r' % id
+            token = self.execute('tb_get_link_by_id(%s, %s)'
+                 % (simplejson.dumps(id), js_index))
+
+        if token == 'false':
+            raise ValueError('Link not found: ' + msg)
+        if token == 'ambiguity error':
+            raise ClientForm.AmbiguityError(msg)
+
+        return Link(token, self)
+
+    def _follow_link(self, token):
+        self.execute('tb_follow_link(%s)' % token)
+
+    def getControl(self, label=None, name=None, index=None):
+        raise NotImplementedError
+
+    def getForm(self, id=None, name=None, action=None, index=None):
+        raise NotImplementedError
+
+
+class Link(zc.testbrowser.browser.SetattrErrorsMixin):
+    zope.interface.implements(zc.testbrowser.interfaces.ILink)
+
+    def __init__(self, token, browser):
+        self.token = token
+        self.browser = browser
+        self._browser_counter = self.browser._counter
+        self._enable_setattr_errors = True
+
+    def click(self):
+        if self._browser_counter != self.browser._counter:
+            raise zc.testbrowser.interfaces.ExpiredError
+        self.browser.start_timer()
+        self.browser._follow_link(self.token)
+        self.browser.stop_timer()
+        self.browser._changed()
+
+    @property
+    def url(self):
+        return self.browser.execute('tb_tokens[%s].href' % self.token)
+
+    @property
+    def text(self):
+        return self.browser.execute('tb_get_link_text(%s)' % self.token)
+
+    def __repr__(self):
+        return "<%s text=%r url=%r>" % (
+            self.__class__.__name__, self.text, self.url)


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/real.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/real.txt
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/real.txt	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/real.txt	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,44 @@
+    >>> from zc.testbrowser.real import Browser
+
+    >>> browser = Browser()
+
+    >>> base_url = 'http://localhost:%s/' % TEST_PORT
+    >>> browser.open(base_url)
+    >>> browser.url == base_url
+    True
+
+    >>> browser.open(base_url + 'index.html')
+
+    >>> browser.isHtml
+    True
+
+    >>> browser.title
+    'Simple Page'
+
+    >>> browser.contents
+    '<head>...</body>'
+
+    XXX Note that the entire page is not returned; need to find a way to
+    retrieve the entire page.
+
+    >>> url = browser.url
+    >>> browser.reload()
+    >>> browser.url == url
+    True
+
+    >>> browser.goBack()
+    >>> browser.url == base_url
+    True
+
+    >>> browser.open(base_url + 'navigate.html')
+
+    >>> browser.getLink('Link Text')
+    <Link text='Link Text' url='http://localhost:.../target.html'>
+
+    >>> browser.getLink(url='http://')
+    Traceback (most recent call last):
+        ...
+    AmbiguityError: url 'http://'
+
+    >>> browser.getLink(url='http://', index=3)
+    <Link text='Spaces in the URL' url='http://localhost:.../target.html'>


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/real.txt
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/screen-shots.txt
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/screen-shots.txt	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/screen-shots.txt	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,4 @@
+    >>> from zc.testbrowser.real import Browser
+    >>> browser = Browser()
+    >>> browser.open('http://slashdot.org')
+    >>> browser.execute('tb_take_screen_shot("/tmp/1.png")')


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/screen-shots.txt
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.testbrowser/trunk/src/zc/testbrowser/tests.py
===================================================================
--- zc.testbrowser/trunk/src/zc/testbrowser/tests.py	                        (rev 0)
+++ zc.testbrowser/trunk/src/zc/testbrowser/tests.py	2007-09-19 09:34:25 UTC (rev 79746)
@@ -0,0 +1,498 @@
+##############################################################################
+#
+# Copyright (c) 2004 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+
+from cStringIO import StringIO
+from zc.testbrowser import browser
+from zope.testing import renormalizing, doctest
+import BaseHTTPServer
+import cgi
+import httplib
+import mechanize
+import os.path
+import pprint
+import random
+import re
+import string
+import threading
+import unittest
+import urllib
+import urllib2
+import zc.testbrowser.browser
+import zc.testbrowser.real
+
+
+web_server_base_path = os.path.join(os.path.split(__file__)[0], 'ftests')
+
+
+class TestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
+
+    def version_string(self):
+        return 'BaseHTTP'
+
+    def date_time_string(self):
+        return 'Mon, 17 Sep 2007 10:05:42 GMT'
+
+    def do_GET(self):
+        if self.path.endswith('robots.txt'):
+            self.send_response(404)
+            self.send_header('Connection', 'close')
+            return
+
+        try:
+            f = open(web_server_base_path + self.path)
+        except IOError:
+            self.send_response(500)
+            self.send_header('Connection', 'close')
+            return
+
+        if self.path.endswith('.gif'):
+            content_type = 'image/gif'
+        elif self.path.endswith('.html'):
+            content_type = 'text/html'
+        else:
+            self.send_response(500, 'unknown file type')
+
+        self.send_response(200)
+        self.send_header('Connection', 'close')
+        self.send_header('Content-type', content_type)
+        self.end_headers()
+        self.wfile.write(f.read())
+        f.close()
+
+    def do_POST(self):
+        body = self.rfile.read(int(self.headers['content-length']))
+        values = cgi.parse_qs(body)
+        self.wfile
+        self.send_response(200)
+        self.send_header('Content-type', 'text/plain')
+        self.end_headers()
+        pprint.pprint(values, self.wfile)
+
+    def log_request(self, *args, **kws):
+        pass
+
+
+def set_next_response(body, headers=None, status='200', reason='OK'):
+    global next_response_body
+    global next_response_headers
+    global next_response_status
+    global next_response_reason
+    if headers is None:
+        headers = (
+            'Content-Type: text/html\r\n'
+            'Content-Length: %s\r\n'
+            % len(body)
+            )
+    next_response_body = body
+    next_response_headers = headers
+    next_response_status = status
+    next_response_reason = reason
+
+
+class FauxConnection(object):
+    """A ``urllib2`` compatible connection object."""
+
+    def __init__(self, host):
+        pass
+
+    def set_debuglevel(self, level):
+        pass
+
+    def _quote(self, url):
+        # the publisher expects to be able to split on whitespace, so we have
+        # to make sure there is none in the URL
+        return url.replace(' ', '%20')
+
+
+    def request(self, method, url, body=None, headers=None):
+        if body is None:
+            body = ''
+
+        if url == '':
+            url = '/'
+
+        url = self._quote(url)
+
+        # Construct the headers.
+        header_chunks = []
+        if headers is not None:
+            for header in headers.items():
+                header_chunks.append('%s: %s' % header)
+            headers = '\n'.join(header_chunks) + '\n'
+        else:
+            headers = ''
+
+        # Construct the full HTTP request string, since that is what the
+        # ``HTTPCaller`` wants.
+        request_string = (method + ' ' + url + ' HTTP/1.1\n'
+                          + headers + '\n' + body)
+
+        print request_string.replace('\r', '')
+
+    def getresponse(self):
+        return FauxResponse(next_response_body,
+                            next_response_headers,
+                            next_response_status,
+                            next_response_reason,
+                            )
+
+
+class FauxResponse(object):
+
+    def __init__(self, content, headers, status, reason):
+        self.content = content
+        self.status = status
+        self.reason = reason
+        self.msg = httplib.HTTPMessage(StringIO(headers), 0)
+        self.content_as_file = StringIO(self.content)
+
+    def read(self, amt=None):
+        return self.content_as_file.read(amt)
+
+    def close(self):
+        """To overcome changes in urllib2 and socket in python2.5"""
+        pass
+
+
+class FauxHTTPHandler(urllib2.HTTPHandler):
+
+    http_request = urllib2.AbstractHTTPHandler.do_request_
+
+    def http_open(self, req):
+        """Open an HTTP connection having a ``urllib2`` request."""
+        # Here we connect to the publisher.
+        return self.do_open(FauxConnection, req)
+
+
+class FauxMechanizeBrowser(mechanize.Browser):
+
+    handler_classes = {
+        # scheme handlers
+        "http": FauxHTTPHandler,
+
+        "_http_error": mechanize.HTTPErrorProcessor,
+        "_http_request_upgrade": mechanize.HTTPRequestUpgradeProcessor,
+        "_http_default_error": urllib2.HTTPDefaultErrorHandler,
+
+        # feature handlers
+        "_authen": urllib2.HTTPBasicAuthHandler,
+        "_redirect": mechanize.HTTPRedirectHandler,
+        "_cookies": mechanize.HTTPCookieProcessor,
+        "_refresh": mechanize.HTTPRefreshProcessor,
+        "_referer": mechanize.Browser.handler_classes['_referer'],
+        "_equiv": mechanize.HTTPEquivProcessor,
+        }
+
+    default_schemes = ["http"]
+    default_others = ["_http_error", "_http_request_upgrade",
+                      "_http_default_error"]
+    default_features = ["_authen", "_redirect", "_cookies"]
+
+
+class Browser(browser.Browser):
+
+    def __init__(self, url=None):
+        mech_browser = FauxMechanizeBrowser()
+        super(Browser, self).__init__(url=url, mech_browser=mech_browser)
+
+    def open(self, body, headers=None, status=200, reason='OK'):
+        set_next_response(body, headers, status, reason)
+        browser.Browser.open(self, 'http://localhost/')
+
+def test_submit_duplicate_name():
+    """
+
+This test was inspired by bug #723 as testbrowser would pick up the wrong
+button when having the same name twice in a form.
+
+    >>> browser = Browser()
+
+When given a form with two submit buttons that have the same name:
+
+    >>> browser.open('''\
+    ... <html><body>
+    ...   <form action="." method="post" enctype="multipart/form-data">
+    ...      <input type="submit" name="submit_me" value="GOOD" />
+    ...      <input type="submit" name="submit_me" value="BAD" />
+    ...   </form></body></html>
+    ... ''') # doctest: +ELLIPSIS
+    GET / HTTP/1.1
+    ...
+
+We can specify the second button through it's label/value:
+
+    >>> browser.getControl('BAD')
+    <SubmitControl name='submit_me' type='submit'>
+    >>> browser.getControl('BAD').value
+    'BAD'
+    >>> browser.getControl('BAD').click() # doctest: +REPORT_NDIFF
+    POST / HTTP/1.1
+    Content-length: 176
+    Connection: close
+    Content-type: multipart/form-data; boundary=---------------------------100167997466992641913031254
+    Host: localhost
+    User-agent: Python-urllib/2.4
+    <BLANKLINE>
+    -----------------------------100167997466992641913031254
+    Content-disposition: form-data; name="submit_me"
+    <BLANKLINE>
+    BAD
+    -----------------------------100167997466992641913031254--
+    <BLANKLINE>
+
+This also works if the labels have whitespace around them (this tests a
+regression caused by the original fix for the above):
+
+    >>> browser.open('''\
+    ... <html><body>
+    ...   <form action="." method="post" enctype="multipart/form-data">
+    ...      <input type="submit" name="submit_me" value=" GOOD " />
+    ...      <input type="submit" name="submit_me" value=" BAD " />
+    ...   </form></body></html>
+    ... ''') # doctest: +ELLIPSIS
+    GET / HTTP/1.1
+    ...
+    >>> browser.getControl('BAD')
+    <SubmitControl name='submit_me' type='submit'>
+    >>> browser.getControl('BAD').value
+    ' BAD '
+    >>> browser.getControl('BAD').click() # doctest: +REPORT_NDIFF
+    POST / HTTP/1.1
+    Content-length: 176
+    Connection: close
+    Content-type: multipart/form-data; boundary=---------------------------100167997466992641913031254
+    Host: localhost
+    User-agent: Python-urllib/2.4
+    <BLANKLINE>
+    -----------------------------100167997466992641913031254
+    Content-disposition: form-data; name="submit_me"
+    <BLANKLINE>
+     BAD 
+    -----------------------------100167997466992641913031254--
+    <BLANKLINE>
+
+"""
+
+def test_file_upload():
+    """
+
+    >>> browser = Browser()
+
+When given a form with a file-upload
+
+    >>> browser.open('''\
+    ... <html><body>
+    ...   <form action="." method="post" enctype="multipart/form-data">
+    ...      <input name="foo" type="file" />
+    ...      <input type="submit" value="OK" />
+    ...   </form></body></html>
+    ... ''') # doctest: +ELLIPSIS
+    GET / HTTP/1.1
+    ...
+
+Fill in the form value using add_file:
+
+    >>> browser.getControl(name='foo').add_file(
+    ...     StringIO('sample_data'), 'text/foo', 'x.foo')
+    >>> browser.getControl('OK').click()
+    POST / HTTP/1.1
+    Content-length: 173
+    Connection: close
+    Content-type: multipart/form-data; boundary=127.0.0.11000318041146699896411
+    Host: localhost
+    User-agent: Python-urllib/2.99
+    <BLANKLINE>
+    --127.0.0.11000318041146699896411
+    Content-disposition: form-data; name="foo"; filename="x.foo"
+    Content-type: text/foo
+    <BLANKLINE>
+    sample_data
+    --127.0.0.11000318041146699896411--
+    <BLANKLINE>
+
+You can pass a string to add_file:
+
+
+    >>> browser.getControl(name='foo').add_file(
+    ...     'blah blah blah', 'text/blah', 'x.blah')
+    >>> browser.getControl('OK').click()
+    POST / HTTP/1.1
+    Content-length: 178
+    Connection: close
+    Content-type: multipart/form-data; boundary=127.0.0.11000318541146700017052
+    Host: localhost
+    User-agent: Python-urllib/2.98
+    <BLANKLINE>
+    --127.0.0.11000318541146700017052
+    Content-disposition: form-data; name="foo"; filename="x.blah"
+    Content-type: text/blah
+    <BLANKLINE>
+    blah blah blah
+    --127.0.0.11000318541146700017052--
+    <BLANKLINE>
+
+
+    """
+
+
+def test_strip_linebreaks_from_textarea(self):
+    """
+
+    >>> browser = Browser()
+
+According to http://www.w3.org/TR/html4/appendix/notes.html#h-B.3.1 line break
+immediately after start tags or immediately before end tags must be ignored,
+but real browsers only ignore a line break after a start tag.  So if we give
+the following form:
+
+    >>> browser.open('''
+    ... <html><body>
+    ...   <form action="." method="post" enctype="multipart/form-data">
+    ...      <textarea name="textarea">
+    ... Foo
+    ... </textarea>
+    ...   </form></body></html>
+    ... ''') # doctest: +ELLIPSIS
+    GET / HTTP/1.1
+    ...
+
+The value of the textarea won't contain the first line break:
+
+    >>> browser.getControl(name='textarea').value
+    'Foo\\n'
+
+Of course, if we add line breaks, so that there are now two line breaks
+after the start tag, the textarea value will start and end with a line break.
+
+    >>> browser.open('''
+    ... <html><body>
+    ...   <form action="." method="post" enctype="multipart/form-data">
+    ...      <textarea name="textarea">
+    ...
+    ... Foo
+    ... </textarea>
+    ...   </form></body></html>
+    ... ''') # doctest: +ELLIPSIS
+    GET / HTTP/1.1
+    ...
+
+    >>> browser.getControl(name='textarea').value
+    '\\nFoo\\n'
+
+Also, if there is some other whitespace after the start tag, it will be preserved.
+
+    >>> browser.open('''
+    ... <html><body>
+    ...   <form action="." method="post" enctype="multipart/form-data">
+    ...      <textarea name="textarea">  Foo  </textarea>
+    ...   </form></body></html>
+    ... ''') # doctest: +ELLIPSIS
+    GET / HTTP/1.1
+    ...
+
+    >>> browser.getControl(name='textarea').value
+    '  Foo  '
+    """
+
+class win32CRLFtransformer(object):
+    def sub(self, replacement, text):
+        return text.replace(r'\r','')
+
+checker = renormalizing.RENormalizing([
+    (re.compile(r'^--\S+\.\S+\.\S+', re.M), '-'*30),
+    (re.compile(r'boundary=\S+\.\S+\.\S+'), 'boundary='+'-'*30),
+    (re.compile(r'^---{10}.*', re.M), '-'*30),
+    (re.compile(r'boundary=-{10}.*'), 'boundary='+'-'*30),
+    (re.compile(r'User-agent:\s+\S+'), 'User-agent: Python-urllib/2.4'),
+    (re.compile(r'Content-[Ll]ength:.*'), 'Content-Length: 123'),
+    (re.compile(r'Status: 200.*'), 'Status: 200 OK'),
+    (re.compile(r'httperror_seek_wrapper:', re.M), 'HTTPError:'),
+    (win32CRLFtransformer(), None),
+    (re.compile(r'User-Agent: Python-urllib/2.5'), 'User-agent: Python-urllib/2.4'),
+    (re.compile(r'Host: localhost'), 'Connection: close'),
+    (re.compile(r'Content-Type: '), 'Content-type: '),
+    ])
+
+def serve_requests(server):
+    global server_stopped
+    global server_stop
+    server_stop = False
+    while not server_stop:
+        server.handle_request()
+    server.socket.close()
+
+def setUpServer(test):
+    port = random.randint(20000,30000)
+    test.globs['TEST_PORT'] = port
+    server = BaseHTTPServer.HTTPServer(('localhost', port), TestHandler)
+    thread = threading.Thread(target=serve_requests, args=[server])
+    thread.setDaemon(True)
+    thread.start()
+    test.globs['web_server_thread'] = thread
+
+def tearDownServer(test):
+    global server_stop
+    server_stop = True
+    # make a request, so the last call to `handle_one_request` will return
+    urllib.urlretrieve('http://localhost:%d/' % test.globs['TEST_PORT'])
+    test.globs['web_server_thread'].join()
+
+def setUpReal(test):
+    test.globs['Browser'] = zc.testbrowser.real.Browser
+    setUpServer(test)
+
+def tearDownReal(test):
+    tearDownServer(test)
+
+def setUpReadme(test):
+    test.globs['Browser'] = zc.testbrowser.browser.Browser
+    setUpServer(test)
+
+def tearDownReadme(test):
+    tearDownServer(test)
+
+def setUpHeaders(test):
+    setUpServer(test)
+    test.globs['browser'] = zc.testbrowser.browser.Browser()
+
+def tearDownHeaders(test):
+    tearDownServer(test)
+
+def test_suite():
+    flags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS
+
+    readme = doctest.DocFileSuite('README.txt', optionflags=flags,
+        checker=checker, setUp=setUpReadme, tearDown=tearDownReadme)
+
+    headers = doctest.DocFileSuite('headers.txt', optionflags=flags,
+        setUp=setUpHeaders, tearDown=tearDownHeaders)
+
+    real = doctest.DocFileSuite('real.txt', optionflags=flags,
+        checker=checker, setUp=setUpReal, tearDown=tearDownReal)
+    real.level = 3
+
+    real_readme = doctest.DocFileSuite('README.txt', optionflags=flags,
+        checker=checker, setUp=setUpReal, tearDown=tearDownReal)
+    real_readme.level = 3
+
+    screen_shots = doctest.DocFileSuite('screen-shots.txt', optionflags=flags)
+    screen_shots.level = 3
+
+    this_file = doctest.DocTestSuite(checker=checker)
+
+    return unittest.TestSuite((this_file, readme, real_readme, real,
+        screen_shots))
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')


Property changes on: zc.testbrowser/trunk/src/zc/testbrowser/tests.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native



More information about the Checkins mailing list