← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:zope.testbrowser-5 into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:zope.testbrowser-5 into launchpad:master with ~cjwatson/launchpad:zope.testbrowser-5-prepare as a prerequisite.

Commit message:
Upgrade to zope.testbrowser 5.5.1

zope.testbrowser 5.0.0 switched its internal implementation to WebTest
instead of mechanize.  This necessitates several changes in Launchpad.
In some cases the new default behaviours are already appropriate (for
example, `<meta http-equiv="refresh" />` tags are no longer followed),
and in some we just need to poke into the implementation in slightly
different ways.

We have to patch around a few bugs, although fortunately this can all be
contained in lp.testing.pages:

 * WebTest doesn't understand `<input type="search" />`
   (https://github.com/Pylons/webtest/pull/219, awaiting an upstream
   release).

 * `Browser.reload` reuses the existing request rather than making a new
   one (related to
   https://github.com/zopefoundation/zope.testbrowser/issues/74).

 * zope.testbrowser doesn't support finding links by image alt text.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/375427
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:zope.testbrowser-5 into launchpad:master.
diff --git a/constraints.txt b/constraints.txt
index 9bcb49a..94b80d3 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -151,7 +151,7 @@ zope.app.publication==3.12.0
 #zope.app.wsgi==3.10.0
 zope.app.wsgi==3.15.0
 #zope.testbrowser==3.10.4
-zope.testbrowser[wsgi]==4.0.4
+zope.testbrowser[wsgi]==5.5.1
 
 # Deprecated
 roman==1.4.0
diff --git a/lib/launchpad_loggerhead/tests.py b/lib/launchpad_loggerhead/tests.py
index 31cb5a7..7eae09a 100644
--- a/lib/launchpad_loggerhead/tests.py
+++ b/lib/launchpad_loggerhead/tests.py
@@ -74,9 +74,6 @@ class TestLogout(TestCase):
         app = SessionHandler(app, SESSION_VAR, SECRET)
         self.cookie_name = app.cookie_handler.cookie_name
         self.browser = Browser(wsgi_app=app)
-        # We want to pretend we are not a robot, or else mechanize will honor
-        # robots.txt.
-        self.browser.mech_browser.set_handle_robots(False)
         self.browser.open(
             config.codehosting.secure_codebrowse_root + '+login')
 
diff --git a/lib/lp/answers/stories/question-search-multiple-languages.txt b/lib/lp/answers/stories/question-search-multiple-languages.txt
index b6353d2..e500547 100644
--- a/lib/lp/answers/stories/question-search-multiple-languages.txt
+++ b/lib/lp/answers/stories/question-search-multiple-languages.txt
@@ -118,10 +118,6 @@ The mozilla-firefox sourcepackage only has English questions. When the
 anonymous user makes a request from a GeoIP that has no languages
 mapped, we assume they speak the default language of English.
 
-    >>> for pos, (key, _) in enumerate(anon_browser.mech_browser.addheaders):
-    ...     if key == 'X_FORWARDED_FOR':
-    ...         del anon_browser.mech_browser.addheaders[pos]
-    ...         break
     >>> anon_browser.addHeader('X_FORWARDED_FOR', '172.16.1.1')
     >>> anon_browser.open(
     ...     'http://launchpad.test/ubuntu/+source/mozilla-firefox/+questions')
@@ -175,10 +171,6 @@ languages and the target's question languages is 'en'.
 When the project languages are just English, and the user speaks
 that language, we do not show the language controls.
 
-    >>> for pos, (key, _) in enumerate(user_browser.mech_browser.addheaders):
-    ...     if key == 'X_FORWARDED_FOR':
-    ...         del user_browser.mech_browser.addheaders[pos]
-    ...         break
     >>> user_browser.addHeader('X_FORWARDED_FOR', '172.16.1.1')
     >>> user_browser.open(
     ...     'http://launchpad.test/ubuntu/+source/mozilla-firefox/+questions')
diff --git a/lib/lp/app/__init__.py b/lib/lp/app/__init__.py
index 0a4887f..fc252a8 100644
--- a/lib/lp/app/__init__.py
+++ b/lib/lp/app/__init__.py
@@ -19,13 +19,10 @@ from zope.formlib import itemswidgets
 
 itemswidgets.EXPLICIT_EMPTY_SELECTION = False
 
-# Monkeypatch our embedded BeautifulSoup and the one in mechanize to
-# teach them that wbr (new in HTML5, but widely supported forever) is
-# self-closing.
+# Monkeypatch our embedded BeautifulSoup to teach it that wbr (new in HTML5,
+# but widely supported forever) is self-closing.
 import BeautifulSoup
-import mechanize._beautifulsoup
 BeautifulSoup.BeautifulSoup.SELF_CLOSING_TAGS['wbr'] = None
-mechanize._beautifulsoup.BeautifulSoup.SELF_CLOSING_TAGS['wbr'] = None
 
 # Load versioninfo.py so that we get errors on start-up rather than waiting
 # for first page load.
diff --git a/lib/lp/app/stories/basics/xx-offsite-form-post.txt b/lib/lp/app/stories/basics/xx-offsite-form-post.txt
index 6924034..74b9812 100644
--- a/lib/lp/app/stories/basics/xx-offsite-form-post.txt
+++ b/lib/lp/app/stories/basics/xx-offsite-form-post.txt
@@ -4,17 +4,36 @@ Referrer Checking on Form Posts
 To help mitigate cross site request forgery attacks, we check that the
 referrer for a form post exists and is a URI from one of the Launchpad sites.
 
-First a helper to set up a browser object that doesn't handle the
-"Referer" header automatically.  We need to poke into the internal
-mechanize browser object here because Zope's test browser does not
-expose the required functionality:
+First a helper to set up a browser object that doesn't handle the "Referer"
+header automatically.  We need to poke into the internals of
+zope.testbrowser.browser.Browser here because it doesn't expose the required
+functionality:
+
+  >>> from contextlib import contextmanager
+  >>> from lp.testing.pages import Browser
+
+  >>> class BrowserWithReferrer(Browser):
+  ...     def __init__(self, referrer):
+  ...         self._referrer = referrer
+  ...         super(BrowserWithReferrer, self).__init__()
+  ...
+  ...     @contextmanager
+  ...     def _preparedRequest(self, url):
+  ...         with super(BrowserWithReferrer, self)._preparedRequest(
+  ...                 url) as reqargs:
+  ...             reqargs["headers"] = [
+  ...                 (key, value) for key, value in reqargs["headers"]
+  ...                 if key.lower() != "referer"]
+  ...             if self._referrer is not None:
+  ...                 reqargs["headers"].append(("Referer", self._referrer))
+  ...             yield reqargs
 
   >>> def setupBrowserWithReferrer(referrer):
-  ...      browser = setupBrowser("Basic no-priv@xxxxxxxxxxxxx:test")
-  ...      browser.mech_browser.set_handle_referer(False)
-  ...      if referrer is not None:
-  ...          browser.addHeader("Referer", referrer)
-  ...      return browser
+  ...     browser = BrowserWithReferrer(referrer)
+  ...     browser.handleErrors = False
+  ...     browser.addHeader(
+  ...         "Authorization", "Basic no-priv@xxxxxxxxxxxxx:test")
+  ...     return browser
 
 
 If we try to create a new team with with the referrer set to
diff --git a/lib/lp/blueprints/browser/tests/test_sprint.py b/lib/lp/blueprints/browser/tests/test_sprint.py
index 907e6a5..066d235 100644
--- a/lib/lp/blueprints/browser/tests/test_sprint.py
+++ b/lib/lp/blueprints/browser/tests/test_sprint.py
@@ -8,11 +8,11 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 
 from fixtures import FakeLogger
-from mechanize import LinkNotFoundError
 from testtools.matchers import Equals
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.enums import InformationType
 from lp.services.webapp.publisher import canonical_url
diff --git a/lib/lp/bugs/stories/bugtask-management/xx-change-assignee.txt b/lib/lp/bugs/stories/bugtask-management/xx-change-assignee.txt
index 268d83d..325d595 100644
--- a/lib/lp/bugs/stories/bugtask-management/xx-change-assignee.txt
+++ b/lib/lp/bugs/stories/bugtask-management/xx-change-assignee.txt
@@ -119,8 +119,7 @@ any team and hence does no see the option to asign somebody else.
     >>> assignee_control.value = ["jokosher.assignee.assign_to"]
     Traceback (most recent call last):
     ...
-    ItemNotFoundError: insufficient items with name
-    u'jokosher.assignee.assign_to'
+    ValueError: Option u'jokosher.assignee.assign_to' not found ...
     >>> user_browser.getControl(name="jokosher.assignee", index=0)
     Traceback (most recent call last):
     ...
diff --git a/lib/lp/bugs/stories/structural-subscriptions/xx-advanced-features.txt b/lib/lp/bugs/stories/structural-subscriptions/xx-advanced-features.txt
index 3ebc332..76dbf19 100644
--- a/lib/lp/bugs/stories/structural-subscriptions/xx-advanced-features.txt
+++ b/lib/lp/bugs/stories/structural-subscriptions/xx-advanced-features.txt
@@ -12,4 +12,4 @@ option to subscribe to add a new subscription
     >>> logout()
     >>> user_browser.open(url)
     >>> user_browser.getLink("Add a subscription")
-    <Link text='Add a subscription' url='.../+subscriptions#'>
+    <Link text='Add a subscription' url='.../+subscriptions'>
diff --git a/lib/lp/buildmaster/stories/xx-builder-page.txt b/lib/lp/buildmaster/stories/xx-builder-page.txt
index fed6bf4..2eafcbd 100644
--- a/lib/lp/buildmaster/stories/xx-builder-page.txt
+++ b/lib/lp/buildmaster/stories/xx-builder-page.txt
@@ -13,7 +13,6 @@ builder state. In the sampledata, the builder 'bob' is building
     >>> bob_builder.version = "100"
     >>> logout()
 
-    >>> anon_browser.mech_browser.set_handle_equiv(False)
     >>> anon_browser.open("http://launchpad.test/builders";)
     >>> anon_browser.getLink("bob").click()
 
@@ -37,7 +36,6 @@ When accessed by logged in users, the builder page renders the
 timezone. This way they can easily find out if they are reading
 outdated information.
 
-    >>> user_browser.mech_browser.set_handle_equiv(False)
     >>> user_browser.open(anon_browser.url)
     >>> print extract_text(find_portlet(
     ...     user_browser.contents, 'View full history Current status'))
@@ -107,7 +105,6 @@ the builder actions.
 
     >>> cprov_browser = setupBrowser(
     ...     auth='Basic celso.providelo@xxxxxxxxxxxxx:test')
-    >>> cprov_browser.mech_browser.set_handle_equiv(False)
 
     >>> cprov_browser.open('http://launchpad.test/builders')
     >>> cprov_browser.getLink('bob').click()
diff --git a/lib/lp/buildmaster/stories/xx-buildfarm-index.txt b/lib/lp/buildmaster/stories/xx-buildfarm-index.txt
index e1d53d8..4ceebda 100644
--- a/lib/lp/buildmaster/stories/xx-buildfarm-index.txt
+++ b/lib/lp/buildmaster/stories/xx-buildfarm-index.txt
@@ -4,7 +4,6 @@ The BuildFarm page is accessible from the root page, although we don't
 link to it yet because we are not yet sure of the benefits of doing
 this, since the audience of this page is still restricted.
 
-    >>> anon_browser.mech_browser.set_handle_equiv(False)
     >>> anon_browser.open('http://launchpad.test/+builds')
 
 The BuildFarm contains a list of all builders registered in Launchpad
@@ -121,7 +120,6 @@ and are not permitted if they go directly to the URL.
 
 Administrators can create new builders.
 
-    >>> admin_browser.mech_browser.set_handle_equiv(False)
     >>> admin_browser.open("http://launchpad.test/+builds/+index";)
 
     >>> admin_browser.getLink("Register a new build machine").click()
diff --git a/lib/lp/code/browser/tests/test_gitsubscription.py b/lib/lp/code/browser/tests/test_gitsubscription.py
index cb49310..62346ff 100644
--- a/lib/lp/code/browser/tests/test_gitsubscription.py
+++ b/lib/lp/code/browser/tests/test_gitsubscription.py
@@ -10,9 +10,9 @@ __metaclass__ = type
 from urllib import urlencode
 
 from fixtures import FakeLogger
-from mechanize import LinkNotFoundError
 from testtools.matchers import MatchesStructure
 from zope.security.interfaces import Unauthorized
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.enums import InformationType
 from lp.code.enums import (
diff --git a/lib/lp/code/browser/tests/test_product.py b/lib/lp/code/browser/tests/test_product.py
index a34bb3b..360745e 100644
--- a/lib/lp/code/browser/tests/test_product.py
+++ b/lib/lp/code/browser/tests/test_product.py
@@ -12,9 +12,9 @@ from datetime import (
     timedelta,
     )
 
-from mechanize import LinkNotFoundError
 import pytz
 from zope.component import getUtility
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.enums import (
     InformationType,
diff --git a/lib/lp/code/browser/tests/test_sourcepackagerecipe.py b/lib/lp/code/browser/tests/test_sourcepackagerecipe.py
index 16952c2..c96ac22 100644
--- a/lib/lp/code/browser/tests/test_sourcepackagerecipe.py
+++ b/lib/lp/code/browser/tests/test_sourcepackagerecipe.py
@@ -15,13 +15,13 @@ import re
 from textwrap import dedent
 
 from fixtures import FakeLogger
-from mechanize import LinkNotFoundError
 from pytz import UTC
 from testtools.matchers import Equals
 import transaction
 from zope.component import getUtility
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
diff --git a/lib/lp/code/browser/tests/test_sourcepackagerecipebuild.py b/lib/lp/code/browser/tests/test_sourcepackagerecipebuild.py
index 669a035..3f3ec4f 100644
--- a/lib/lp/code/browser/tests/test_sourcepackagerecipebuild.py
+++ b/lib/lp/code/browser/tests/test_sourcepackagerecipebuild.py
@@ -8,13 +8,13 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 
 from fixtures import FakeLogger
-from mechanize import LinkNotFoundError
 from storm.locals import Store
 from testtools.matchers import StartsWith
 import transaction
 from zope.component import getUtility
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
@@ -270,22 +270,19 @@ class TestSourcePackageRecipeBuild(BrowserTestCase):
 
     def test_builder_index_public(self):
         build = self.makeBuildingRecipe()
-        url = canonical_url(build.builder)
-        logout()
-        browser = self.getNonRedirectingBrowser(url=url, user=ANONYMOUS)
+        browser = self.getViewBrowser(build.builder, no_login=True)
         self.assertIn('i am failing', browser.contents)
 
     def test_builder_index_private(self):
         archive = self.factory.makeArchive(private=True)
         with admin_logged_in():
             build = self.makeBuildingRecipe(archive=archive)
-        url = canonical_url(removeSecurityProxy(build).builder)
-        logout()
+        builder = removeSecurityProxy(build).builder
 
         # An unrelated user can't see the logtail of a private build.
-        browser = self.getNonRedirectingBrowser(url=url)
+        browser = self.getViewBrowser(builder)
         self.assertNotIn('i am failing', browser.contents)
 
         # But someone who can see the archive can.
-        browser = self.getNonRedirectingBrowser(url=url, user=archive.owner)
+        browser = self.getViewBrowser(builder, user=archive.owner)
         self.assertIn('i am failing', browser.contents)
diff --git a/lib/lp/code/stories/branches/xx-branch-edit.txt b/lib/lp/code/stories/branches/xx-branch-edit.txt
index 1142650..f501da0 100644
--- a/lib/lp/code/stories/branches/xx-branch-edit.txt
+++ b/lib/lp/code/stories/branches/xx-branch-edit.txt
@@ -185,7 +185,7 @@ already own in the same product.
     >>> browser.getControl('Name').value = '2.6'
     >>> browser.getControl('Change Branch').click()
     >>> browser.url
-    'http://code.launchpad.test/%7Ename12/gnome-terminal/main/+edit'
+    'http://code.launchpad.test/~name12/gnome-terminal/main/+edit'
 
     >>> print_feedback_messages(browser.contents)
     There is 1 error.
diff --git a/lib/lp/code/stories/branches/xx-branch-listings.txt b/lib/lp/code/stories/branches/xx-branch-listings.txt
index 16e18a8..431aa01 100644
--- a/lib/lp/code/stories/branches/xx-branch-listings.txt
+++ b/lib/lp/code/stories/branches/xx-branch-listings.txt
@@ -300,7 +300,7 @@ options that can be selected.
     >>> sort_by_control.value = ['by project name']
     Traceback (most recent call last):
       ...
-    ItemNotFoundError: insufficient items with name u'by project name'
+    ValueError: Option u'by project name' not found ...
     >>> for option in sort_by_control.options:
     ...     print(option)
     by most interesting
diff --git a/lib/lp/code/stories/sourcepackagerecipes/xx-recipe-listings.txt b/lib/lp/code/stories/sourcepackagerecipes/xx-recipe-listings.txt
index f110e32..688e713 100644
--- a/lib/lp/code/stories/sourcepackagerecipes/xx-recipe-listings.txt
+++ b/lib/lp/code/stories/sourcepackagerecipes/xx-recipe-listings.txt
@@ -50,7 +50,7 @@ read "3 recipes." Let's click through.
     >>> nopriv_browser.open(branch_url)
     >>> nopriv_browser.getLink('3 recipes').click()
     >>> print(nopriv_browser.url)
-    http://code.launchpad.test/%7Eperson-name.../product-name.../branch.../+recipes
+    http://code.launchpad.test/~person-name.../product-name.../branch.../+recipes
 
 The "Base Source" column should not be shown.
 
@@ -98,7 +98,7 @@ should now read "4 recipes."  Let's click through.
     >>> nopriv_browser.open(repository_url)
     >>> nopriv_browser.getLink('4 recipes').click()
     >>> print(nopriv_browser.url)
-    http://code.launchpad.test/%7Eperson-name.../product-name.../+git/gitrepository.../+recipes
+    http://code.launchpad.test/~person-name.../product-name.../+git/gitrepository.../+recipes
 
 The "Base Source" column should not be shown.
 
@@ -125,7 +125,7 @@ listed.
     ...     nopriv_browser.open(ref1_url)
     >>> nopriv_browser.getLink('2 recipes').click()
     >>> print(nopriv_browser.url)
-    http://code.launchpad.test/%7Eperson-name.../product-name.../+git/gitrepository.../+ref/a/+recipes
+    http://code.launchpad.test/~person-name.../product-name.../+git/gitrepository.../+ref/a/+recipes
 
     >>> print_recipe_listing_head(nopriv_browser)
     Name
diff --git a/lib/lp/registry/stories/distributionmirror/xx-distribution-countrymirrors.txt b/lib/lp/registry/stories/distributionmirror/xx-distribution-countrymirrors.txt
index 40ac555..399f15d 100644
--- a/lib/lp/registry/stories/distributionmirror/xx-distribution-countrymirrors.txt
+++ b/lib/lp/registry/stories/distributionmirror/xx-distribution-countrymirrors.txt
@@ -29,7 +29,6 @@ archive mirrors, plus the canonical one.
 Using a request with no IP address information will give us only the
 canonical mirror.
 
-    >>> anon_browser.addHeader('X_FORWARDED_FOR', None)
     >>> anon_browser.open(
     ...     'http://launchpad.test/ubuntu/+countrymirrors-archive')
     >>> print anon_browser.headers['X-Generated-For-Country']
diff --git a/lib/lp/registry/stories/gpg-coc/xx-gpg-coc.txt b/lib/lp/registry/stories/gpg-coc/xx-gpg-coc.txt
index f83f7e5..bcca447 100644
--- a/lib/lp/registry/stories/gpg-coc/xx-gpg-coc.txt
+++ b/lib/lp/registry/stories/gpg-coc/xx-gpg-coc.txt
@@ -631,10 +631,6 @@ Test if the advertisement email was sent:
 
   Let's login with an Launchpad Admin
 
-  >>> for pos, (key, _) in enumerate(browser.mech_browser.addheaders):
-  ...     if key == 'Authorization':
-  ...         del browser.mech_browser.addheaders[pos]
-  ...         break
   >>> browser.addHeader(
   ...   'Authorization', 'Basic guilherme.salgado@xxxxxxxxxxxxx:test')
 
diff --git a/lib/lp/registry/stories/milestone/object-milestones.txt b/lib/lp/registry/stories/milestone/object-milestones.txt
index d1ef248..8a3e624 100644
--- a/lib/lp/registry/stories/milestone/object-milestones.txt
+++ b/lib/lp/registry/stories/milestone/object-milestones.txt
@@ -232,8 +232,8 @@ Next, we'll target each bug to the 1.0 milestone:
     >>> control = browser.getControl('Milestone')
     >>> milestone_name = '1.0'
     >>> [milestone_id] = [
-    ...     option.name for option in control.mech_control.items
-    ...     if option.get_labels()[0].text.endswith(milestone_name)]
+    ...     option.optionValue for option in control.controls
+    ...     if option.labels[0].endswith(milestone_name)]
     >>> control.value = [milestone_id]
     >>> browser.getControl('Save Changes').click()
 
diff --git a/lib/lp/registry/stories/team/xx-team-membership.txt b/lib/lp/registry/stories/team/xx-team-membership.txt
index a7eca20..f21ee07 100644
--- a/lib/lp/registry/stories/team/xx-team-membership.txt
+++ b/lib/lp/registry/stories/team/xx-team-membership.txt
@@ -36,12 +36,12 @@ which would enable the input when the radio button was clicked to indicate
 that a specific expiration date was desired. There is also no TestBrowser
 way to "enable" the input. So, we have to reach into the guts of the
 TestBrowser to manually re-enable the input. That's what the
-control.mech_control.disabled=False stuff is.
+`del expiry._control.attrs['disabled']` stuff is.
 
     >>> browser.getControl(name='admin').value = ['no']
     >>> browser.getControl(name='expires').value = ['date']
     >>> expiry = browser.getControl(name='membership.expirationdate')
-    >>> expiry.mech_control.disabled = False # control may have been disabled
+    >>> del expiry._control.attrs['disabled']
     >>> expiry.value = 'ssdf'
     >>> browser.getControl('Change').click()
 
@@ -80,7 +80,7 @@ next year -- successfully.
     >>> browser.getControl(name='admin').value = ['no']
     >>> browser.getControl(name='expires').value = ['date']
     >>> expiry = browser.getControl(name='membership.expirationdate')
-    >>> expiry.mech_control.disabled = False # control may have been disabled
+    >>> del expiry._control.attrs['disabled']
     >>> expiry.value = expire_date.strftime('%Y-%m-%d')
     >>> browser.getControl(name='comment').value = 'Arfie'
     >>> browser.getControl('Change').click()
diff --git a/lib/lp/registry/stories/teammembership/xx-member-renewed-membership.txt b/lib/lp/registry/stories/teammembership/xx-member-renewed-membership.txt
index c7113c5..bec3b86 100644
--- a/lib/lp/registry/stories/teammembership/xx-member-renewed-membership.txt
+++ b/lib/lp/registry/stories/teammembership/xx-member-renewed-membership.txt
@@ -81,8 +81,8 @@ the user to renew that membership because it's not about to expire.
 If we now change Karl's membership to expire in a couple days, he'll be
 able to renew it himself.
 
-See pagetests/foaf/40-team-membership.txt for an explanation of the
-mech_control TestBrowser voodoo.
+See lib/lp/registry/stories/team/xx-team-membership.txt for an explanation
+of the expiry._control.attrs TestBrowser voodoo.
 
     >>> from datetime import datetime, timedelta
     >>> import pytz
@@ -95,7 +95,7 @@ mech_control TestBrowser voodoo.
     >>> team_admin_browser.getControl(name='expires').value = ['date']
     >>> expiry = team_admin_browser.getControl(
     ...     name='membership.expirationdate')
-    >>> expiry.mech_control.disabled = False # control may be disabled in JS
+    >>> del expiry._control.attrs['disabled']
     >>> expiry.value = day_after_tomorrow.date().strftime('%Y-%m-%d')
     >>> team_admin_browser.getControl(name='change').click()
 
diff --git a/lib/lp/registry/stories/teammembership/xx-teammembership.txt b/lib/lp/registry/stories/teammembership/xx-teammembership.txt
index f3f278b..06208ac 100644
--- a/lib/lp/registry/stories/teammembership/xx-teammembership.txt
+++ b/lib/lp/registry/stories/teammembership/xx-teammembership.txt
@@ -415,7 +415,7 @@ error message:
     ...     '2049-04-16'
     >>> browser2.getControl("Reactivate").click()
     >>> browser2.url
-    'http://launchpad.test/%7Emyemail/+member/karl/+index'
+    'http://launchpad.test/~myemail/+member/karl/+index'
     >>> for tag in find_tags_by_class(browser2.contents, 'error message'):
     ...     print tag.renderContents()
     The membership request for Karl Tilbury has already been processed.
diff --git a/lib/lp/registry/stories/vouchers/xx-voucher-redemption.txt b/lib/lp/registry/stories/vouchers/xx-voucher-redemption.txt
index d271730..dfb017d 100644
--- a/lib/lp/registry/stories/vouchers/xx-voucher-redemption.txt
+++ b/lib/lp/registry/stories/vouchers/xx-voucher-redemption.txt
@@ -71,7 +71,7 @@ successful voucher redemption.
     ...     'LPCBS12-f78df324-0cc2-11dd-8b6b-000000000004']
     >>> browser.getControl('Redeem').click()
     >>> print browser.url
-    http://launchpad.test/%7Ecprov/+vouchers
+    http://launchpad.test/~cprov/+vouchers
     >>> print_feedback_messages(browser.contents)
     Voucher redeemed successfully
 
diff --git a/lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt b/lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt
index dcadf49..441d5e9 100644
--- a/lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt
+++ b/lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt
@@ -52,14 +52,11 @@ minute time interval (set in lp.services.webapp.login and enforced
 with an assert in lp.services.webapp.session) is intended to be fudge
 time for browsers with bad system clocks.
 
-    >>> from six.moves.urllib.error import HTTPError
-    >>> browser.mech_browser.set_handle_redirect(False)
-    >>> try:
-    ...     browser.getControl('Log Out').click()
-    ... except HTTPError as redirection:
-    ...     print(redirection)
-    ...     print(redirection.hdrs['Location'])
-    HTTP Error 303: See Other
+    >>> browser.followRedirects = False
+    >>> browser.getControl('Log Out').click()
+    >>> print(browser.headers['Status'])
+    303 See Other
+    >>> print(browser.headers['Location'])
     https://bazaar.launchpad.test/+logout?next_to=...
 
 After ensuring the browser has not left the launchpad.test domain, the
diff --git a/lib/lp/services/webapp/tests/test_login.py b/lib/lp/services/webapp/tests/test_login.py
index 9aaae8f..dd485b7 100644
--- a/lib/lp/services/webapp/tests/test_login.py
+++ b/lib/lp/services/webapp/tests/test_login.py
@@ -656,9 +656,6 @@ class TestOpenIDCallbackRedirects(TestCaseWithFactory):
             view.request.response.getHeader('Location'), self.APPLICATION_URL)
 
 
-urls_redirected_to = []
-
-
 def fill_login_form_and_submit(browser, email_address):
     assert browser.getControl(name='field.email') is not None, (
         "We don't seem to be looking at a login form.")
@@ -671,7 +668,7 @@ class TestOpenIDReplayAttack(TestCaseWithFactory):
 
     def test_replay_attacks_do_not_succeed(self):
         browser = Browser()
-        browser.mech_browser.set_handle_redirect(False)
+        browser.followRedirects = False
         browser.open('%s/+login' % self.layer.appserver_root_url())
         # On a JS-enabled browser this page would've been auto-submitted
         # (thanks to the onload handler), but here we have to do it manually.
@@ -679,24 +676,22 @@ class TestOpenIDReplayAttack(TestCaseWithFactory):
         browser.getControl('Continue').click()
 
         self.assertEqual('Login', browser.title)
-        redirection = self.assertRaises(
-            HTTPError,
-            fill_login_form_and_submit, browser, 'test@xxxxxxxxxxxxx')
-        self.assertEqual(httplib.FOUND, redirection.code)
-        callback_url = redirection.hdrs['Location']
+        fill_login_form_and_submit(browser, 'test@xxxxxxxxxxxxx')
+        self.assertEqual(
+            httplib.FOUND, int(browser.headers['Status'].split(' ', 1)[0]))
+        callback_url = browser.headers['Location']
         self.assertIn('+openid-callback', callback_url)
-        callback_redirection = self.assertRaises(
-            HTTPError, browser.open, callback_url)
+        browser.open(callback_url)
         self.assertEqual(
-            httplib.TEMPORARY_REDIRECT, callback_redirection.code)
-        browser.open(callback_redirection.hdrs['Location'])
+            httplib.TEMPORARY_REDIRECT,
+            int(browser.headers['Status'].split(' ', 1)[0]))
+        browser.open(browser.headers['Location'])
         login_status = extract_text(
             find_tag_by_id(browser.contents, 'logincontrol'))
         self.assertIn('Sample Person (name12)', login_status)
 
-        # Now we look up (in urls_redirected_to) the +openid-callback URL that
-        # was used to complete the authentication and open it on a different
-        # browser with a fresh set of cookies.
+        # Now we open the +openid-callback URL that was used to complete the
+        # authentication on a different browser with a fresh set of cookies.
         replay_browser = Browser()
         replay_browser.open(callback_url)
         login_status = extract_text(
diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py
index 339ab44..165fd1f 100644
--- a/lib/lp/snappy/browser/tests/test_snap.py
+++ b/lib/lp/snappy/browser/tests/test_snap.py
@@ -13,14 +13,12 @@ from datetime import (
     )
 import json
 import re
-from urllib2 import HTTPError
 from urlparse import (
     parse_qs,
     urlsplit,
     )
 
 from fixtures import FakeLogger
-from mechanize import LinkNotFoundError
 from pymacaroons import Macaroon
 import pytz
 import responses
@@ -40,6 +38,7 @@ from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -478,8 +477,7 @@ class TestSnapAddView(BaseTestSnapView):
         responses.add(
             "POST", "http://sca.example/dev/api/acl/";,
             json={"macaroon": root_macaroon_raw})
-        redirection = self.assertRaises(
-            HTTPError, browser.getControl("Create snap package").click)
+        browser.getControl("Create snap package").click()
         login_person(self.person)
         snap = getUtility(ISnapSet).getByName(self.person, "snap-name")
         self.assertThat(snap, MatchesStructure.byEquality(
@@ -499,8 +497,8 @@ class TestSnapAddView(BaseTestSnapView):
             "permissions": ["package_upload"],
             }
         self.assertEqual(expected_body, json.loads(call.request.body))
-        self.assertEqual(303, redirection.code)
-        parsed_location = urlsplit(redirection.hdrs["Location"])
+        self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+        parsed_location = urlsplit(browser.headers["Location"])
         self.assertEqual(
             urlsplit(
                 canonical_url(snap, rootsite="code") +
@@ -996,7 +994,7 @@ class TestSnapEditView(BaseTestSnapView):
         # enables it, they can't enable the restricted processor.
         for control in processors.controls:
             if control.optionValue == "armhf":
-                control.mech_item.disabled = False
+                del control._control.attrs["disabled"]
         processors.value = ["386", "amd64", "armhf"]
         self.assertRaises(
             CannotModifySnapProcessor,
@@ -1023,7 +1021,8 @@ class TestSnapEditView(BaseTestSnapView):
         browser = self.getUserBrowser(
             canonical_url(snap) + "/+edit", user=snap.owner)
         processors = browser.getControl(name="field.processors")
-        self.assertContentEqual(["386", "amd64"], processors.value)
+        # armhf is checked but disabled.
+        self.assertContentEqual(["386", "amd64", "armhf"], processors.value)
         self.assertProcessorControls(
             processors, ["386", "amd64", "hppa"], ["armhf"])
         processors.value = ["386"]
@@ -1097,8 +1096,7 @@ class TestSnapEditView(BaseTestSnapView):
         responses.add(
             "POST", "http://sca.example/dev/api/acl/";,
             json={"macaroon": root_macaroon_raw})
-        redirection = self.assertRaises(
-            HTTPError, browser.getControl("Update snap package").click)
+        browser.getControl("Update snap package").click()
         login_person(self.person)
         self.assertThat(snap, MatchesStructure.byEquality(
             store_name="two", store_secrets={"root": root_macaroon_raw},
@@ -1111,8 +1109,8 @@ class TestSnapEditView(BaseTestSnapView):
             "permissions": ["package_upload"],
             }
         self.assertEqual(expected_body, json.loads(call.request.body))
-        self.assertEqual(303, redirection.code)
-        parsed_location = urlsplit(redirection.hdrs["Location"])
+        self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+        parsed_location = urlsplit(browser.headers["Location"])
         self.assertEqual(
             urlsplit(canonical_url(snap) + "/+authorize/+login")[:3],
             parsed_location[:3])
@@ -1163,8 +1161,7 @@ class TestSnapAuthorizeView(BaseTestSnapView):
             json={"macaroon": root_macaroon_raw})
         browser = self.getNonRedirectingBrowser(
             url=snap_url + "/+authorize", user=self.snap.owner)
-        redirection = self.assertRaises(
-            HTTPError, browser.getControl("Begin authorization").click)
+        browser.getControl("Begin authorization").click()
         [call] = responses.calls
         self.assertThat(call.request, MatchesStructure.byEquality(
             url="http://sca.example/dev/api/acl/";, method="POST"))
@@ -1179,12 +1176,12 @@ class TestSnapAuthorizeView(BaseTestSnapView):
             self.assertEqual(expected_body, json.loads(call.request.body))
             self.assertEqual(
                 {"root": root_macaroon_raw}, self.snap.store_secrets)
-        self.assertEqual(303, redirection.code)
+        self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
         self.assertEqual(
             snap_url + "/+authorize/+login?macaroon_caveat_id=dummy&"
             "discharge_macaroon_action=field.actions.complete&"
             "discharge_macaroon_field=field.discharge_macaroon",
-            redirection.hdrs["Location"])
+            browser.headers["Location"])
 
     def test_complete_authorization_missing_discharge_macaroon(self):
         # If the form does not include a discharge macaroon, the "complete"
diff --git a/lib/lp/snappy/browser/tests/test_snapbuild.py b/lib/lp/snappy/browser/tests/test_snapbuild.py
index 9676891..3b34639 100644
--- a/lib/lp/snappy/browser/tests/test_snapbuild.py
+++ b/lib/lp/snappy/browser/tests/test_snapbuild.py
@@ -10,7 +10,6 @@ __metaclass__ = type
 import re
 
 from fixtures import FakeLogger
-from mechanize import LinkNotFoundError
 from pymacaroons import Macaroon
 import soupmatchers
 from storm.locals import Store
@@ -22,6 +21,7 @@ import transaction
 from zope.component import getUtility
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
@@ -34,7 +34,6 @@ from lp.testing import (
     ANONYMOUS,
     BrowserTestCase,
     login,
-    logout,
     person_logged_in,
     TestCaseWithFactory,
     )
@@ -425,24 +424,19 @@ class TestSnapBuildOperations(BrowserTestCase):
 
     def test_builder_index_public(self):
         build = self.makeBuildingSnap()
-        builder_url = canonical_url(build.builder)
-        logout()
-        browser = self.getNonRedirectingBrowser(
-            url=builder_url, user=ANONYMOUS)
+        browser = self.getViewBrowser(build.builder, no_login=True)
         self.assertIn("tail of the log", browser.contents)
 
     def test_builder_index_private(self):
         archive = self.factory.makeArchive(private=True)
         with admin_logged_in():
             build = self.makeBuildingSnap(archive=archive)
-            builder_url = canonical_url(build.builder)
-        logout()
+        builder = removeSecurityProxy(build).builder
 
         # An unrelated user can't see the logtail of a private build.
-        browser = self.getNonRedirectingBrowser(url=builder_url)
+        browser = self.getViewBrowser(builder)
         self.assertNotIn("tail of the log", browser.contents)
 
         # But someone who can see the archive can.
-        browser = self.getNonRedirectingBrowser(
-            url=builder_url, user=archive.owner)
+        browser = self.getViewBrowser(builder, user=archive.owner)
         self.assertIn("tail of the log", browser.contents)
diff --git a/lib/lp/soyuz/browser/tests/test_archive.py b/lib/lp/soyuz/browser/tests/test_archive.py
index 8ec40ee..058f63c 100644
--- a/lib/lp/soyuz/browser/tests/test_archive.py
+++ b/lib/lp/soyuz/browser/tests/test_archive.py
@@ -124,7 +124,7 @@ class TestArchiveEditView(TestCaseWithFactory):
         # enables it, they can't enable the restricted processor.
         for control in processors.controls:
             if control.optionValue == "armhf":
-                control.mech_item.disabled = False
+                del control._control.attrs["disabled"]
         processors.value = ["386", "amd64", "armhf"]
         self.assertRaises(
             CannotModifyArchiveProcessor, browser.getControl("Save").click)
@@ -147,7 +147,8 @@ class TestArchiveEditView(TestCaseWithFactory):
         browser = self.getUserBrowser(
             canonical_url(ppa) + "/+edit", user=ppa.owner)
         processors = browser.getControl(name="field.processors")
-        self.assertContentEqual(["386", "amd64"], processors.value)
+        # armhf is checked but disabled.
+        self.assertContentEqual(["386", "amd64", "armhf"], processors.value)
         self.assertProcessorControls(
             processors, ["386", "amd64", "hppa"], ["armhf"])
         processors.value = ["386"]
diff --git a/lib/lp/soyuz/browser/tests/test_livefs.py b/lib/lp/soyuz/browser/tests/test_livefs.py
index 9b68371..86f3d86 100644
--- a/lib/lp/soyuz/browser/tests/test_livefs.py
+++ b/lib/lp/soyuz/browser/tests/test_livefs.py
@@ -14,11 +14,11 @@ from datetime import (
 import json
 
 from fixtures import FakeLogger
-from mechanize import LinkNotFoundError
 import pytz
 from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
diff --git a/lib/lp/soyuz/browser/tests/test_livefsbuild.py b/lib/lp/soyuz/browser/tests/test_livefsbuild.py
index 348d176..b753054 100644
--- a/lib/lp/soyuz/browser/tests/test_livefsbuild.py
+++ b/lib/lp/soyuz/browser/tests/test_livefsbuild.py
@@ -8,7 +8,6 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 
 from fixtures import FakeLogger
-from mechanize import LinkNotFoundError
 import soupmatchers
 from storm.locals import Store
 from testtools.matchers import StartsWith
@@ -16,6 +15,7 @@ import transaction
 from zope.component import getUtility
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
@@ -27,7 +27,6 @@ from lp.testing import (
     ANONYMOUS,
     BrowserTestCase,
     login,
-    logout,
     person_logged_in,
     TestCaseWithFactory,
     )
@@ -234,24 +233,19 @@ class TestLiveFSBuildOperations(BrowserTestCase):
 
     def test_builder_index_public(self):
         build = self.makeBuildingLiveFS()
-        builder_url = canonical_url(build.builder)
-        logout()
-        browser = self.getNonRedirectingBrowser(
-            url=builder_url, user=ANONYMOUS)
+        browser = self.getViewBrowser(build.builder, no_login=True)
         self.assertIn("tail of the log", browser.contents)
 
     def test_builder_index_private(self):
         archive = self.factory.makeArchive(private=True)
         with admin_logged_in():
             build = self.makeBuildingLiveFS(archive=archive)
-            builder_url = canonical_url(build.builder)
-        logout()
+        builder = removeSecurityProxy(build).builder
 
         # An unrelated user can't see the logtail of a private build.
-        browser = self.getNonRedirectingBrowser(url=builder_url)
+        browser = self.getViewBrowser(builder)
         self.assertNotIn("tail of the log", browser.contents)
 
         # But someone who can see the archive can.
-        browser = self.getNonRedirectingBrowser(
-            url=builder_url, user=archive.owner)
+        browser = self.getViewBrowser(builder, user=archive.owner)
         self.assertIn("tail of the log", browser.contents)
diff --git a/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt b/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt
index c6e795b..7f0f2b7 100644
--- a/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt
+++ b/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt
@@ -166,17 +166,19 @@ An nonexistent source:
 
     >>> admin_browser.getControl(
     ...    name='field.selected_sources').value = ['100']
-    Traceback (most recent call last):
-    ...
-    ItemNotFoundError: insufficient items with name u'100'
+    >>> admin_browser.getControl('Request Deletion').click()
+    >>> print_feedback_messages(admin_browser.contents)
+    There is 1 error.
+    No sources selected.
 
 An invalid value.
 
     >>> admin_browser.getControl(
     ...    name='field.selected_sources').value = ['blah']
-    Traceback (most recent call last):
-    ...
-    ItemNotFoundError: insufficient items with name u'blah'
+    >>> admin_browser.getControl('Request Deletion').click()
+    >>> print_feedback_messages(admin_browser.contents)
+    There is 1 error.
+    No sources selected.
 
 The deleted record is now presented accordingly in the +index page. We
 will use another browser to inspect the results of our deletions.
diff --git a/lib/lp/soyuz/stories/ppa/xx-ppa-files.txt b/lib/lp/soyuz/stories/ppa/xx-ppa-files.txt
index d7a180a..2b9cde6 100644
--- a/lib/lp/soyuz/stories/ppa/xx-ppa-files.txt
+++ b/lib/lp/soyuz/stories/ppa/xx-ppa-files.txt
@@ -124,7 +124,7 @@ Links to files accessible via +files/ proxy in the Build page.
 
 Create a function to check the expected links.
 
-    >>> from mechanize import LinkNotFoundError
+    >>> from zope.testbrowser.browser import LinkNotFoundError
     >>> def check_urls(browser, links, base_url):
     ...     for link, libraryfile, source_name, source_version in links:
     ...         try:
diff --git a/lib/lp/soyuz/stories/soyuz/xx-builds-pages.txt b/lib/lp/soyuz/stories/soyuz/xx-builds-pages.txt
index 6a6ceb1..da2791d 100644
--- a/lib/lp/soyuz/stories/soyuz/xx-builds-pages.txt
+++ b/lib/lp/soyuz/stories/soyuz/xx-builds-pages.txt
@@ -69,7 +69,6 @@ For DistroArchSeries, same as Distribution:
 
 For Builder, same as Distribution:
 
-    >>> anon_browser.mech_browser.set_handle_equiv(False)
     >>> anon_browser.open("http://launchpad.test/builders/bob";)
     >>> anon_browser.getLink("View full history").click()
     >>> print(anon_browser.title)
@@ -270,7 +269,7 @@ to the form:
     >>> anon_browser.getControl(name="build_state").value = ['foo']
     Traceback (most recent call last):
     ...
-    ItemNotFoundError: insufficient items with name u'foo'
+    ValueError: Option u'foo' not found ...
 
 However even if anonymous user builds an URL with a incorrect value,
 code is prepared to raise the correct exception:
diff --git a/lib/lp/soyuz/stories/soyuz/xx-private-builds.txt b/lib/lp/soyuz/stories/soyuz/xx-private-builds.txt
index 9fbe07f..44c2730 100644
--- a/lib/lp/soyuz/stories/soyuz/xx-private-builds.txt
+++ b/lib/lp/soyuz/stories/soyuz/xx-private-builds.txt
@@ -60,7 +60,6 @@ So now we can go to frog's builder page and see what the page shows us.
 The status shown to an anonymous user hides the private source it is
 building.
 
-    >>> anon_browser.mech_browser.set_handle_equiv(False)
     >>> anon_browser.open("http://launchpad.test/+builds/frog";)
     >>> print(extract_text(find_main_content(anon_browser.contents)))
     The frog builder...
@@ -70,7 +69,6 @@ building.
 
 Launchpad Administrators are allowed to see the build:
 
-    >>> admin_browser.mech_browser.set_handle_equiv(False)
     >>> admin_browser.open("http://launchpad.test/+builds/frog";)
     >>> print(extract_text(find_main_content(admin_browser.contents)))
     The frog builder...
@@ -82,7 +80,6 @@ Launchpad Administrators are allowed to see the build:
 Buildd Administrators are not allowed to see the build in the portlet:
 
     >>> name12_browser = setupBrowser(auth="Basic test@xxxxxxxxxxxxx:test")
-    >>> name12_browser.mech_browser.set_handle_equiv(False)
     >>> name12_browser.open("http://launchpad.test/+builds/frog";)
     >>> print(extract_text(find_main_content(name12_browser.contents)))
     The frog builder...
@@ -94,7 +91,6 @@ cprov is also allowed to see his own build:
 
     >>> cprov_browser = setupBrowser(
     ...     auth="Basic celso.providelo@xxxxxxxxxxxxx:test")
-    >>> cprov_browser.mech_browser.set_handle_equiv(False)
     >>> cprov_browser.open("http://launchpad.test/+builds/frog";)
     >>> print(extract_text(find_main_content(cprov_browser.contents)))
     The frog builder...
@@ -304,7 +300,6 @@ Now the anonymous user can see the build:
 
 Any other logged-in user will also see the build:
 
-    >>> browser.mech_browser.set_handle_equiv(False)
     >>> browser.open("http://launchpad.test/+builds";)
     >>> print(extract_text(find_main_content(browser.contents)))
     The Launchpad build farm
diff --git a/lib/lp/testing/__init__.py b/lib/lp/testing/__init__.py
index 6a0d9b2..0c543e8 100644
--- a/lib/lp/testing/__init__.py
+++ b/lib/lp/testing/__init__.py
@@ -867,8 +867,7 @@ class TestCaseWithFactory(TestCase):
             browser = setupBrowser()
         else:
             browser = self.getUserBrowser(user=user)
-        browser.mech_browser.set_handle_redirect(False)
-        browser.mech_browser.set_handle_equiv(False)
+        browser.followRedirects = False
         if url is not None:
             browser.open(url)
         return browser
diff --git a/lib/lp/testing/doc/pagetest-helpers.txt b/lib/lp/testing/doc/pagetest-helpers.txt
index 0676eb0..25684c4 100644
--- a/lib/lp/testing/doc/pagetest-helpers.txt
+++ b/lib/lp/testing/doc/pagetest-helpers.txt
@@ -27,7 +27,7 @@ one should be use for all anonymous browsing tests.
   # Shortcut to fetch the Authorization header from the testbrowser
 
     >>> def getAuthorizationHeader(browser):
-    ...   return dict(browser.mech_browser.addheaders).get('Authorization','')
+    ...   return browser._req_headers.get('Authorization', '')
 
     >>> anon_browser = test.globs['anon_browser']
     >>> print(getAuthorizationHeader(anon_browser))
diff --git a/lib/lp/testing/layers.py b/lib/lp/testing/layers.py
index b90c853..59617f5 100644
--- a/lib/lp/testing/layers.py
+++ b/lib/lp/testing/layers.py
@@ -79,7 +79,10 @@ from fixtures import (
     MonkeyPatch,
     )
 import psycopg2
-from six.moves.urllib.parse import quote
+from six.moves.urllib.parse import (
+    quote,
+    urlparse,
+    )
 from storm.zope.interfaces import IZStorm
 import transaction
 from webob.request import environ_from_url as orig_environ_from_url
@@ -101,6 +104,7 @@ from zope.security.management import (
     getSecurityPolicy,
     )
 from zope.server.logger.pythonlogger import PythonLogger
+from zope.testbrowser.browser import HostNotAllowed
 import zope.testbrowser.wsgi
 from zope.testbrowser.wsgi import AuthorizationMiddleware
 
@@ -1153,17 +1157,16 @@ class FunctionalLayer(BaseLayer):
         transaction.begin()
 
         # Allow the WSGI test browser to talk to our various test hosts.
-        def assert_allowed_host(self):
-            host = self.host
-            if ':' in host:
-                host = host.split(':')[0]
+        def _assertAllowed(self, url):
+            parsed = urlparse(url)
+            host = parsed.netloc.partition(':')[0]
             if host == 'localhost' or host.endswith('.test'):
                 return
-            self._allowed = False
+            raise HostNotAllowed(url)
 
         FunctionalLayer._testbrowser_allowed = MonkeyPatch(
-            'zope.testbrowser.wsgi.WSGIConnection.assert_allowed_host',
-            assert_allowed_host)
+            'zope.testbrowser.browser.TestbrowserApp._assertAllowed',
+            _assertAllowed)
         FunctionalLayer._testbrowser_allowed.setUp()
         FunctionalLayer.browser_layer.testSetUp()
 
diff --git a/lib/lp/testing/pages.py b/lib/lp/testing/pages.py
index cff8368..705b02a 100644
--- a/lib/lp/testing/pages.py
+++ b/lib/lp/testing/pages.py
@@ -28,6 +28,7 @@ from BeautifulSoup import (
     Tag,
     )
 from bs4.element import (
+    CData as CData4,
     Comment as Comment4,
     Declaration as Declaration4,
     Doctype as Doctype4,
@@ -44,8 +45,12 @@ from contrib.oauth import (
     )
 from lazr.restful.testing.webservice import WebServiceCaller
 import six
+from soupsieve import escape as css_escape
 import transaction
-from webtest import TestRequest
+from webtest import (
+    forms,
+    TestRequest,
+    )
 from zope.app.wsgi.testlayer import (
     FakeResponse as _FakeResponse,
     NotInBrowserLayer,
@@ -54,8 +59,15 @@ from zope.component import getUtility
 from zope.security.management import setSecurityPolicy
 from zope.security.proxy import removeSecurityProxy
 from zope.session.interfaces import ISession
+from zope.testbrowser.browser import (
+    BrowserStateError,
+    isMatching,
+    Link as _Link,
+    LinkNotFoundError,
+    normalizeWhitespace,
+    )
 from zope.testbrowser.wsgi import (
-    Browser,
+    Browser as _Browser,
     Layer as TestBrowserWSGILayer,
     )
 
@@ -74,7 +86,10 @@ from lp.services.oauth.interfaces import (
 from lp.services.webapp import canonical_url
 from lp.services.webapp.authorization import LaunchpadPermissiveSecurityPolicy
 from lp.services.webapp.interfaces import OAuthPermission
-from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.services.webapp.servers import (
+    LaunchpadTestRequest,
+    wsgi_native_string,
+    )
 from lp.services.webapp.url import urlsplit
 from lp.testing import (
     ANONYMOUS,
@@ -100,6 +115,11 @@ SAMPLEDATA_ACCESS_SECRETS = {
     }
 
 
+# Teach WebTest about <input type="search" />.
+# https://github.com/Pylons/webtest/pull/219
+forms.Field.classes['search'] = forms.Text
+
+
 class FakeResponse(_FakeResponse):
     """A fake response for use in tests.
 
@@ -197,8 +217,7 @@ class LaunchpadWebServiceCaller(WebServiceCaller):
                 self.access_token)
             oauth_headers = request.to_header(OAUTH_REALM)
             full_headers.update({
-                six.ensure_str(key, encoding='ISO-8859-1'):
-                    six.ensure_str(value, encoding='ISO-8859-1')
+                wsgi_native_string(key): wsgi_native_string(value)
                 for key, value in oauth_headers.items()})
         if not self.handle_errors:
             full_headers['X_Zope_handle_errors'] = 'False'
@@ -693,6 +712,67 @@ def print_errors(contents):
         print(error)
 
 
+class Link(_Link):
+    """`zope.testbrowser.browser.Link`, but with image alt text handling."""
+
+    @property
+    def text(self):
+        txt = normalizeWhitespace(self.browser._getText(self._link))
+        return self.browser.toStr(txt)
+
+
+class Browser(_Browser):
+    """A modified Browser with behaviour more suitable for pagetests."""
+
+    def reload(self):
+        """Make a new request rather than reusing an existing one."""
+        if self.url is None:
+            raise BrowserStateError("no URL has yet been .open()ed")
+        self.open(self.url, referrer=self._req_referrer)
+
+    def addHeader(self, key, value):
+        """Make sure headers are native strings."""
+        super(Browser, self).addHeader(
+            wsgi_native_string(key), wsgi_native_string(value))
+
+    def _getText(self, element):
+        def get_strings(elem):
+            for descendant in elem.descendants:
+                if isinstance(descendant, (NavigableString4, CData4)):
+                    yield descendant
+                elif isinstance(descendant, Tag4) and descendant.name == 'img':
+                    yield u'%s[%s]' % (
+                        descendant.get('alt', u''), descendant.name.upper())
+
+        return u''.join(list(get_strings(element)))
+
+    def getLink(self, text=None, url=None, id=None, index=0):
+        """Search for both text nodes and image alt attributes."""
+        # XXX cjwatson 2019-11-09: This should be merged back into
+        # `zope.testbrowser.browser.Browser.getLink`.
+        qa = 'a' if id is None else 'a#%s' % css_escape(id)
+        qarea = 'area' if id is None else 'area#%s' % css_escape(id)
+        html = self._html
+        links = html.select(qa)
+        links.extend(html.select(qarea))
+
+        matching = []
+        for elem in links:
+            matches = (isMatching(self._getText(elem), text) and
+                       isMatching(elem.get('href', ''), url))
+
+            if matches:
+                matching.append(elem)
+
+        if index >= len(matching):
+            raise LinkNotFoundError()
+        elem = matching[index]
+
+        baseurl = self._getBaseUrl()
+
+        return Link(elem, self, baseurl)
+
+
 def setupBrowser(auth=None):
     """Create a testbrowser object for use in pagetests.
 
diff --git a/lib/lp/translations/stories/buildfarm/xx-build-summary.txt b/lib/lp/translations/stories/buildfarm/xx-build-summary.txt
index e114e3c..230b756 100644
--- a/lib/lp/translations/stories/buildfarm/xx-build-summary.txt
+++ b/lib/lp/translations/stories/buildfarm/xx-build-summary.txt
@@ -70,7 +70,6 @@ Show summary
 The job's summary shows that what type of job this is.  It also links
 to the branch.
 
-    >>> user_browser.mech_browser.set_handle_equiv(False)
     >>> user_browser.open(builder_page)
     >>> print(extract_text(find_build_summary(user_browser)))
     Working on TranslationTemplatesBuild for branch ...
diff --git a/lib/lp/translations/stories/standalone/xx-pofile-translate-alternative-language.txt b/lib/lp/translations/stories/standalone/xx-pofile-translate-alternative-language.txt
index e4991da..fc97599 100644
--- a/lib/lp/translations/stories/standalone/xx-pofile-translate-alternative-language.txt
+++ b/lib/lp/translations/stories/standalone/xx-pofile-translate-alternative-language.txt
@@ -24,16 +24,18 @@ in the form's main language.
     ...     """
     ...     if not browser.url.endswith('/+translate'):
     ...         raise AssertionError("Not a +translate page: " + browser.url)
+    ...     alternative_language = browser.getControl(
+    ...         name='field.alternative_language')
     ...     try:
-    ...         browser.getControl('English (en)', index=0).selected
+    ...         alternative_language.getControl(
+    ...             browser.toStr('English (en)')).selected
     ...         raise AssertionError(
     ...             "Looking up English among alternative languages "
     ...             "should have failed, but didn't.")
     ...     except LookupError:
     ...         pass
     ...
-    ...     return browser.getControl(
-    ...         name='field.alternative_language')
+    ...     return alternative_language
 
 An anonymous user is offered all available languages except English for
 alternative suggestions.  We do not offer suggestions from standard English
@@ -79,7 +81,8 @@ Spanish as an alternative language.
 
     >>> import re
     >>> browser.open(re.sub('/es/', '/ca/', translate_page))
-    >>> browser.getControl('Spanish (es)').selected = True
+    >>> get_alternative_languages_widget(browser).getControl(
+    ...     'Spanish (es)').selected = True
     >>> browser.getControl('Change').click()
 
 The Spanish translations now show up as suggestions.  For example, where
@@ -131,7 +134,8 @@ and other alternative languages does not exist, of course, if no preferred
 languages are defined.  Suggestions just work for anonymous users.
 
     >>> anon_browser.open(re.sub('/es/', '/ca/', translate_page))
-    >>> anon_browser.getControl('Spanish (es)').selected = True
+    >>> get_alternative_languages_widget(anon_browser).getControl(
+    ...     anon_browser.toStr('Spanish (es)')).selected = True
     >>> anon_browser.getControl('Change').click()
 
     >>> print(extract_text(find_main_content(
@@ -161,8 +165,10 @@ show only the strings they are interested in.
 Carlos sets the filter to display only the untranslated strings.
 
     >>> browser.open(translate_page)
-    >>> browser.getControl('Catalan (ca)').selected = True
-    >>> browser.getControl('untranslated').selected = True
+    >>> get_alternative_languages_widget(browser).getControl(
+    ...     'Catalan (ca)').selected = True
+    >>> browser.getControl('Translating').getControl(
+    ...     'untranslated').selected = True
     >>> browser.getControl('Change').click()
     >>> print(extract_url_parameter(
     ...     browser.url, 'field.alternative_language'))
diff --git a/lib/lp/translations/stories/standalone/xx-pofile-translate-lang-direction.txt b/lib/lp/translations/stories/standalone/xx-pofile-translate-lang-direction.txt
index dc1c0e2..cf3e9e4 100644
--- a/lib/lp/translations/stories/standalone/xx-pofile-translate-lang-direction.txt
+++ b/lib/lp/translations/stories/standalone/xx-pofile-translate-lang-direction.txt
@@ -16,11 +16,11 @@ the separator in language codes rather than an underscore.
   ...     'http://translations.launchpad.test/ubuntu/hoary/+source/evolution/'
   ...     '+pots/evolution-2.2/en_AU/+translate')
   >>> control = browser.getControl(name="msgset_130_en_AU_translation_0_new")
-  >>> control.mech_control.attrs.get('dir')
-  'ltr'
+  >>> print(control._control.attrs.get('dir'))
+  ltr
   >>> control = browser.getControl(name="msgset_139_en_AU_translation_0_new")
-  >>> control.mech_control.attrs.get('dir')
-  'ltr'
+  >>> print(control._control.attrs.get('dir'))
+  ltr
 
 When entering Hebrew translations, the form controls are set to right to left:
 
@@ -28,11 +28,11 @@ When entering Hebrew translations, the form controls are set to right to left:
   ...     'http://translations.launchpad.test/ubuntu/hoary/+source/evolution/'
   ...     '+pots/evolution-2.2/he/+translate')
   >>> control = browser.getControl(name="msgset_130_he_translation_0_new")
-  >>> control.mech_control.attrs.get('dir')
-  'rtl'
+  >>> print(control._control.attrs.get('dir'))
+  rtl
   >>> control = browser.getControl(name="msgset_139_he_translation_0_new")
-  >>> control.mech_control.attrs.get('dir')
-  'rtl'
+  >>> print(control._control.attrs.get('dir'))
+  rtl
 
 If we post the form with suggestions, the form controls are still set to rtl:
 
@@ -40,8 +40,8 @@ If we post the form with suggestions, the form controls are still set to rtl:
   ...     'http://translations.launchpad.test/ubuntu/hoary/+source/evolution/'
   ...     '+pots/evolution-2.2/he/+translate?field.alternative_language=es')
   >>> control = browser.getControl(name="msgset_130_he_translation_0_new")
-  >>> control.mech_control.attrs.get('dir')
-  'rtl'
+  >>> print(control._control.attrs.get('dir'))
+  rtl
 
 But suggestion text is tagged with its language code and its own text
 direction:
diff --git a/lib/lp/translations/stories/standalone/xx-pofile-translate-message-filtering.txt b/lib/lp/translations/stories/standalone/xx-pofile-translate-message-filtering.txt
index b7cb1c1..e2de6c3 100644
--- a/lib/lp/translations/stories/standalone/xx-pofile-translate-message-filtering.txt
+++ b/lib/lp/translations/stories/standalone/xx-pofile-translate-message-filtering.txt
@@ -411,7 +411,8 @@ Person submits Chinese translations using Spanish suggestions.
     ...     '+source/evolution/+pots/evolution-2.2/zh_CN/+translate')
     >>> user_browser.getControl(name='show', index=1).value = ['untranslated']
     >>> user_browser.getControl('Change').click()
-    >>> user_browser.getControl('Spanish (es)').selected = True
+    >>> user_browser.getControl(name='field.alternative_language').getControl(
+    ...     user_browser.toStr('Spanish (es)')).selected = True
     >>> user_browser.getControl('Change').click()
 
     >>> user_browser.getControl(
diff --git a/lib/lp/translations/stories/standalone/xx-sourcepackage-export.txt b/lib/lp/translations/stories/standalone/xx-sourcepackage-export.txt
index b425d6e..04994c8 100644
--- a/lib/lp/translations/stories/standalone/xx-sourcepackage-export.txt
+++ b/lib/lp/translations/stories/standalone/xx-sourcepackage-export.txt
@@ -32,7 +32,7 @@ users who are involved in certain ways, in order to keep load to a
 reasonable level.
 
     >>> from zope.security.interfaces import Unauthorized
-    >>> from mechanize import LinkNotFoundError
+    >>> from zope.testbrowser.browser import LinkNotFoundError
 
     >>> def can_download_translations(browser):
     ...     """Can browser download full package translations?
diff --git a/lib/lp/translations/stories/translationgroups/xx-translationgroups.txt b/lib/lp/translations/stories/translationgroups/xx-translationgroups.txt
index e058852..ceaba7d 100644
--- a/lib/lp/translations/stories/translationgroups/xx-translationgroups.txt
+++ b/lib/lp/translations/stories/translationgroups/xx-translationgroups.txt
@@ -746,12 +746,6 @@ languages, they will not be able to add or change translations.
 Let's see if No Privileges Person can see the translated strings in
 Southern Sotho. We expect them to see a readonly form:
 
-    >>> delpoints = []
-    >>> for pos, (key, _) in enumerate(browser.mech_browser.addheaders):
-    ...     if key == 'Authorization':
-    ...         delpoints.append(pos)
-    >>> for pos in reversed(delpoints):
-    ...         del browser.mech_browser.addheaders[pos]
     >>> browser.addHeader(
     ...     'Authorization', 'Basic no-priv@xxxxxxxxxxxxx:test')
     >>> browser.open(
diff --git a/setup.py b/setup.py
index a0518bf..6a72cb8 100644
--- a/setup.py
+++ b/setup.py
@@ -182,7 +182,6 @@ setup(
         'lazr.uri',
         'lpjsmin',
         'Markdown',
-        'mechanize',
         'meliae',
         # Pin version for now to avoid confusion with system site-packages.
         'mock==1.0.1',
diff --git a/utilities/paste b/utilities/paste
index d8be29b..b2df8e0 100755
--- a/utilities/paste
+++ b/utilities/paste
@@ -19,7 +19,7 @@ import urllib
 from urlparse import urljoin
 import webbrowser
 
-from mechanize import HTTPRobotRulesProcessor
+from fixtures import MonkeyPatch
 from zope.testbrowser.browser import Browser
 
 # Should we be able to override any of these?
@@ -127,17 +127,14 @@ def main():
         if lp_cookie is None:
             print LP_AUTH_INSTRUCTIONS
             return
-        cookiejar = CookieJar()
-        cookiejar.set_cookie(lp_cookie)
-        browser.mech_browser.set_cookiejar(cookiejar)
+        browser.testapp.cookiejar.set_cookie(lp_cookie)
 
     # Remove the check for robots.txt, since the one on
     # pastebin.ubuntu.com doesn't allow us to open the page. We're not
     # really a robot.
-    browser.mech_browser.handlers = [
-        handler for handler in browser.mech_browser.handlers
-        if not isinstance(handler, HTTPRobotRulesProcessor)]
-    browser.open(urljoin('https://' + paste_host, PASTE_PATH))
+    with MonkeyPatch(
+            'six.moves.urllib.robotparser.RobotFileParser.allow_all', True):
+        browser.open(urljoin('https://' + paste_host, PASTE_PATH))
 
     if parser.options.private:
         # We need to authenticate before pasting.

Follow ups