← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~maxiberta/launchpad/bing-search into lp:launchpad

 

Maximiliano Bertacchini has proposed merging lp:~maxiberta/launchpad/bing-search into lp:launchpad.

Commit message:
Add basic Bing Custom Search site search support.

Requested reviews:
  Colin Watson (cjwatson)

For more details, see:
https://code.launchpad.net/~maxiberta/launchpad/bing-search/+merge/341549

Add basic Bing Custom Search support.

Should be pretty unobtrusive, while adding a basic site search implementation around Bing Custom Search (shamelessly copied from the Google site search implementation). Might need some more testing; and could use some code deduplication.
-- 
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.
=== modified file 'Makefile'
--- Makefile	2018-02-01 20:56:23 +0000
+++ Makefile	2018-03-28 21:29:05 +0000
@@ -61,6 +61,7 @@
 # NB: It's important PIP_BIN only mentions things genuinely produced by pip.
 PIP_BIN = \
     $(PY) \
+    bin/bingtestservice \
     bin/build-twisted-plugin-cache \
     bin/combine-css \
     bin/googletestservice \
@@ -284,7 +285,7 @@
 	bin/test -f $(TESTFLAGS) $(TESTOPTS)
 
 run: build inplace stop
-	bin/run -r librarian,google-webservice,memcached,rabbitmq,txlongpoll \
+	bin/run -r librarian,bing-webservice,google-webservice,memcached,rabbitmq,txlongpoll \
 	-i $(LPCONFIG)
 
 run-testapp: LPCONFIG=testrunner-appserver
@@ -297,12 +298,12 @@
 
 start-gdb: build inplace stop support_files run.gdb
 	nohup gdb -x run.gdb --args bin/run -i $(LPCONFIG) \
-		-r librarian,google-webservice
+		-r librarian,bing-webservice,google-webservice
 		> ${LPCONFIG}-nohup.out 2>&1 &
 
 run_all: build inplace stop
 	bin/run \
-	 -r librarian,sftp,forker,mailman,codebrowse,google-webservice,\
+	 -r librarian,sftp,forker,mailman,codebrowse,bing-webservice,google-webservice,\
 	memcached,rabbitmq,txlongpoll -i $(LPCONFIG)
 
 run_codebrowse: compile

=== modified file 'configs/development/launchpad-lazr.conf'
--- configs/development/launchpad-lazr.conf	2018-02-02 15:29:38 +0000
+++ configs/development/launchpad-lazr.conf	2018-03-28 21:29:05 +0000
@@ -79,13 +79,22 @@
 error_dir: /var/tmp/lperr
 
 [google]
-# Development and the testrunner should use the stub service be default.
+# Development and the testrunner should use the stub service by default.
 site: http://launchpad.dev:8092/cse
 client_id: ABCDEF2323
 
 [google_test_service]
 launch: True
 
+[bing]
+# Development and the testrunner should use the stub service by default.
+site: http://launchpad.dev:8093/bingcustomsearch/v7.0/search
+subscription_key: abcdef01234567890abcdef012345678
+custom_config_id: 1234567890
+
+[bing_test_service]
+launch: True
+
 [gpghandler]
 host: keyserver.launchpad.dev
 public_host: keyserver.launchpad.dev

=== modified file 'configs/testrunner-appserver/launchpad-lazr.conf'
--- configs/testrunner-appserver/launchpad-lazr.conf	2016-10-11 15:28:25 +0000
+++ configs/testrunner-appserver/launchpad-lazr.conf	2018-03-28 21:29:05 +0000
@@ -14,6 +14,9 @@
 [google_test_service]
 launch: False
 
+[bing_test_service]
+launch: False
+
 [launchpad]
 openid_provider_root: http://testopenid.dev:8085/
 

=== modified file 'configs/testrunner/launchpad-lazr.conf'
--- configs/testrunner/launchpad-lazr.conf	2018-01-26 22:18:38 +0000
+++ configs/testrunner/launchpad-lazr.conf	2018-03-28 21:29:05 +0000
@@ -91,6 +91,9 @@
 [google]
 site: http://launchpad.dev:8092/cse
 
+[bing]
+site: http://launchpad.dev:8093/bingcustomsearch/v7.0/search
+
 [gpghandler]
 upload_keys: True
 host: localhost

=== added directory 'lib/lp/app/browser/doc'
=== added directory 'lib/lp/app/browser/doc.moved'
=== renamed file 'lib/lp/app/browser/tests/base-layout.txt' => 'lib/lp/app/browser/doc/base-layout.txt'
--- lib/lp/app/browser/tests/base-layout.txt	2016-03-14 00:45:42 +0000
+++ lib/lp/app/browser/doc/base-layout.txt	2018-03-28 21:29:05 +0000
@@ -25,7 +25,7 @@
     >>> class MainSideView(LaunchpadView):
     ...     """A simple view to test base-layout."""
     ...     __launchpad_facetname__ = 'overview'
-    ...     template = ViewPageTemplateFile('testfiles/main-side.pt')
+    ...     template = ViewPageTemplateFile('../tests/testfiles/main-side.pt')
     ...     page_title = 'Test base-layout: main_side'
 
 The main_side layout uses all the defined features of the base-layout
@@ -57,7 +57,7 @@
     >>> class MainOnlyView(LaunchpadView):
     ...     """A simple view to test base-layout."""
     ...     __launchpad_facetname__ = 'overview'
-    ...     template = ViewPageTemplateFile('testfiles/main-only.pt')
+    ...     template = ViewPageTemplateFile('../tests/testfiles/main-only.pt')
     ...     page_title = 'Test base-layout: main_only'
 
     >>> view = MainOnlyView(user, request)
@@ -80,7 +80,7 @@
     >>> class SearchlessView(LaunchpadView):
     ...     """A simple view to test base-layout."""
     ...     __launchpad_facetname__ = 'overview'
-    ...     template = ViewPageTemplateFile('testfiles/searchless.pt')
+    ...     template = ViewPageTemplateFile('../tests/testfiles/searchless.pt')
     ...     page_title = 'Test base-layout: searchless'
 
     >>> view = SearchlessView(user, request)

=== added file 'lib/lp/app/browser/doc/launchpad-search-pages-bing.txt'
--- lib/lp/app/browser/doc/launchpad-search-pages-bing.txt	1970-01-01 00:00:00 +0000
+++ lib/lp/app/browser/doc/launchpad-search-pages-bing.txt	2018-03-28 21:29:05 +0000
@@ -0,0 +1,725 @@
+Launchpad search page
+=====================
+
+Users can search for Launchpad objects and pages from the search form
+located on all pages. The search is performed and displayed by the
+LaunchpadSearchView.
+
+    >>> from zope.component import getMultiAdapter, getUtility
+    >>> from lp.services.webapp.interfaces import ILaunchpadRoot
+    >>> from lp.services.webapp.servers import LaunchpadTestRequest
+
+    >>> root = getUtility(ILaunchpadRoot)
+    >>> request = LaunchpadTestRequest()
+    >>> search_view = getMultiAdapter((root, request), name="+search")
+    >>> search_view.initialize()
+    >>> search_view
+    <....SimpleViewClass from .../templates/launchpad-search.pt ...>
+
+
+Page title and heading
+----------------------
+
+The page title and heading suggest to the user to search launchpad
+when there is no search text.
+
+    >>> print search_view.text
+    None
+    >>> search_view.page_title
+    'Search Launchpad'
+    >>> search_view.page_heading
+    'Search Launchpad'
+
+When text is not None, the title indicates what was searched.
+
+    >>> def getSearchView(form):
+    ...     search_param_list = []
+    ...     for name in sorted(form):
+    ...         value = form[name]
+    ...         search_param_list.append('%s=%s' % (name, value))
+    ...     query_string = '&'.join(search_param_list)
+    ...     request = LaunchpadTestRequest(
+    ...         SERVER_URL='https://launchpad.dev/+search',
+    ...         QUERY_STRING=query_string, form=form, PATH_INFO='/+search')
+    ...     search_view = getMultiAdapter((root, request), name="+search")
+    ...     search_view.initialize()
+    ...     return search_view
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'albatross'})
+
+    >>> search_view.text
+    u'albatross'
+    >>> search_view.page_title
+    u'Pages matching "albatross" in Launchpad'
+    >>> search_view.page_heading
+    u'Pages matching "albatross" in Launchpad'
+
+
+No matches
+----------
+
+There were no matches for 'albatross'.
+
+    >>> search_view.has_matches
+    False
+
+When search text is not submitted there are no matches. Search text is
+required to perform a search. Note that field.actions.search is not a
+required param to call the Search Action. The view always calls the
+search action.
+
+    >>> search_view = getSearchView(form={})
+
+    >>> print search_view.text
+    None
+    >>> search_view.has_matches
+    False
+
+
+Bug and Question Searches
+-------------------------
+
+When a numeric token can be extracted from the submitted search text,
+the view tries to match a bug and question. Bugs and questions are
+matched by their id.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': '5'})
+    >>> search_view._getNumericToken(search_view.text)
+    u'5'
+    >>> search_view.has_matches
+    True
+    >>> search_view.bug.title
+    u'Firefox install instructions should be complete'
+    >>> search_view.question.title
+    u'Installation failed'
+
+Bugs and questions are matched independent of each other. The number
+extracted may only match one kind of object. For example, there are
+more bugs than questions.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': '15'})
+    >>> search_view._getNumericToken(search_view.text)
+    u'15'
+    >>> search_view.has_matches
+    True
+    >>> search_view.bug.title
+    u'Nonsensical bugs are useless'
+    >>> print search_view.question
+    None
+
+Private bugs are not matched if the user does not have permission to
+see them. For example, Sample Person can see a private bug that they
+created because they are the owner.
+
+    >>> from lp.services.webapp.interfaces import ILaunchBag
+    >>> from lp.app.enums import InformationType
+
+    >>> login('test@xxxxxxxxxxxxx')
+    >>> sample_person = getUtility(ILaunchBag).user
+    >>> private_bug = factory.makeBug(
+    ...     owner=sample_person, information_type=InformationType.USERDATA)
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': private_bug.id})
+    >>> search_view.bug.private
+    True
+
+But anonymous and unprivileged users cannot see the private bug.
+
+    >>> login(ANONYMOUS)
+    >>> search_view = getSearchView(
+    ...     form={'field.text': private_bug.id})
+    >>> print search_view.bug
+    None
+
+The text and punctuation in the search text is ignored, and only the
+first group of numbers is matched. For example a user searches for three
+questions by number ('Question #15, #7, and 5.'). Only the first number
+is used, and it matches a bug, not a question. The second and third
+numbers do match questions, but they are not used.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'Question #15, #7, and 5.'})
+    >>> search_view._getNumericToken(search_view.text)
+    u'15'
+    >>> search_view.has_matches
+    True
+    >>> search_view.bug.title
+    u'Nonsensical bugs are useless'
+    >>> print search_view.question
+    None
+
+It is not an error to search for a non-existent bug or question.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': '55555'})
+    >>> search_view._getNumericToken(search_view.text)
+    u'55555'
+    >>> search_view.has_matches
+    False
+    >>> print search_view.bug
+    None
+    >>> print search_view.question
+    None
+
+There is no error if a number cannot be extracted from the search text.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'fifteen'})
+    >>> print search_view._getNumericToken(
+    ...     search_view.text)
+    None
+    >>> search_view.has_matches
+    False
+    >>> print search_view.bug
+    None
+    >>> print search_view.question
+    None
+
+Bugs and questions are only returned for the first page of search,
+when the start param is 0.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': '5',
+    ...           'start': '20'})
+    >>> search_view.has_matches
+    False
+    >>> print search_view.bug
+    None
+    >>> print search_view.question
+    None
+
+
+
+Projects and Persons and Teams searches
+---------------------------------------
+
+When a Launchpad name can be made from the search text, the view tries
+to match the name to a pillar or person. a pillar is a distribution,
+product, or project group. A person is a person or a team.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'launchpad'})
+    >>> search_view._getNameToken(search_view.text)
+    u'launchpad'
+    >>> search_view.has_matches
+    True
+    >>> search_view.pillar.displayname
+    u'Launchpad'
+    >>> search_view.person_or_team.displayname
+    u'Launchpad Developers'
+
+A launchpad name is constructed from the search text. The letters are
+converted to lowercase. groups of spaces and punctuation are replaced
+with a hyphen.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'Gnome Terminal'})
+    >>> search_view._getNameToken(search_view.text)
+    u'gnome-terminal'
+    >>> search_view.has_matches
+    True
+    >>> search_view.pillar.displayname
+    u'GNOME Terminal'
+    >>> print search_view.person_or_team
+    None
+
+Since our pillars can have aliases, it's also possible to look up a pillar
+by any of its aliases.
+
+    >>> from lp.registry.interfaces.product import IProductSet
+    >>> firefox = getUtility(IProductSet)['firefox']
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> firefox.setAliases(['iceweasel'])
+    >>> login(ANONYMOUS)
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'iceweasel'})
+    >>> search_view._getNameToken(search_view.text)
+    u'iceweasel'
+    >>> search_view.has_matches
+    True
+    >>> search_view.pillar.displayname
+    u'Mozilla Firefox'
+
+This is a harder example that illustrates that text that is clearly not
+the name of a pillar will none-the-less be tried. See the `Page searches`
+section for how this kind of search can return matches.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': "YAHOO! webservice's Python API."})
+    >>> search_view._getNameToken(search_view.text)
+    u'yahoo-webservices-python-api.'
+    >>> search_view.has_matches
+    False
+    >>> print search_view.pillar
+    None
+    >>> print search_view.person_or_team
+    None
+
+Leading and trailing punctuation and whitespace are stripped.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': "~name12"})
+    >>> search_view._getNameToken(search_view.text)
+    u'name12'
+    >>> search_view.has_matches
+    True
+    >>> print search_view.pillar
+    None
+    >>> search_view.person_or_team.displayname
+    u'Sample Person'
+
+Pillars, persons and teams are only returned for the first page of
+search, when the start param is 0.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'launchpad',
+    ...           'start': '20'})
+    >>> search_view.has_matches
+    True
+    >>> print search_view.bug
+    None
+    >>> print search_view.question
+    None
+    >>> print search_view.pillar
+    None
+
+Deactivated pillars and non-valid persons and teams cannot be exact
+matches. For example, the python-gnome2-dev product will not match a
+pillar, nor will nsv match Nicolas Velin's unclaimed account.
+
+    >>> from lp.registry.interfaces.person import IPersonSet
+
+    >>> python_gnome2 = getUtility(IProductSet).getByName('python-gnome2-dev')
+    >>> python_gnome2.active
+    False
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'python-gnome2-dev',
+    ...           'start': '0'})
+    >>> search_view._getNameToken(search_view.text)
+    u'python-gnome2-dev'
+    >>> print search_view.pillar
+    None
+
+    >>> nsv = getUtility(IPersonSet).getByName('nsv')
+    >>> nsv.displayname
+    u'Nicolas Velin'
+    >>> nsv.is_valid_person_or_team
+    False
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'nsv',
+    ...           'start': '0'})
+    >>> search_view._getNameToken(search_view.text)
+    u'nsv'
+    >>> print search_view.person_or_team
+    None
+
+Private pillars are not matched if the user does not have permission to see
+them. For example, Sample Person can see a private project that they created
+because they are the owner.
+
+    >>> from lp.registry.interfaces.product import License
+
+    >>> login('test@xxxxxxxxxxxxx')
+    >>> private_product = factory.makeProduct(
+    ...     owner=sample_person, information_type=InformationType.PROPRIETARY,
+    ...     licenses=[License.OTHER_PROPRIETARY])
+    >>> private_product_name = private_product.name
+
+    >>> search_view = getSearchView(form={'field.text': private_product_name})
+    >>> search_view.pillar.private
+    True
+
+But anonymous and unprivileged users cannot see the private project.
+
+    >>> login(ANONYMOUS)
+    >>> search_view = getSearchView(form={'field.text': private_product_name})
+    >>> print search_view.pillar
+    None
+
+
+Shipit CD searches
+------------------
+
+The has_shipit property will be True when the search looks like the user
+is searching for Shipit CDs. There is no correct object in Launchpad to
+display. The page template decides how to handle when has_shipit is
+True.
+
+The match is based on an intersection to the words in the search text
+and the shipit_keywords. The comparison is case-insensitive, has_shipit
+is True when 2 or more words match.
+
+    >>> sorted(search_view.shipit_keywords)
+    ['cd', 'cds', 'disc', 'dvd', 'dvds', 'edubuntu', 'free', 'get', 'kubuntu',
+     'mail', 'send', 'ship', 'shipit', 'ubuntu']
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'ubuntu CDs',
+    ...           'start': '0'})
+    >>> search_view.has_shipit
+    True
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'shipit',
+    ...           'start': '0'})
+    >>> search_view.has_shipit
+    False
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'get Kubuntu cds',
+    ...           'start': '0'})
+    >>> search_view.has_shipit
+    True
+
+There are shipit_anti_keywords too, words that indicate the search is
+not for free CDs from Shipit. Search that have any of these word will
+set has_shipit to False.
+
+    >>> sorted(search_view.shipit_anti_keywords)
+    ['burn', 'burning', 'enable', 'error', 'errors', 'image', 'iso',
+     'read', 'rip', 'write']
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'ubuntu CD write',
+    ...           'start': '0'})
+    >>> search_view.has_shipit
+    False
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': 'shipit error',
+    ...           'start': '0'})
+    >>> search_view.has_shipit
+    False
+
+
+The shipit FAQ URL is provides by the view for the template to use.
+
+    >>> search_view.shipit_faq_url
+    'http://www.ubuntu.com/getubuntu/shipit-faq'
+
+
+Page searches
+-------------
+
+The view uses the BingSearchService to locate pages that match the
+search terms.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': " bug"})
+    >>> search_view.text
+    u'bug'
+    >>> search_view.has_matches
+    True
+    >>> search_view.pages
+    <...SiteSearchBatchNavigator ...>
+
+The BingSearchService may not be available due to connectivity problems.
+The view's has_page_service attribute reports when the search was performed
+with Bing page matches.
+
+    >>> search_view.has_page_service
+    True
+
+The batch navigation heading is created by the view. The heading
+property returns a 2-tuple of singular and plural heading. There
+is a heading when there are only Bing page matches...
+
+    >>> search_view.has_exact_matches
+    False
+    >>> search_view.batch_heading
+    (u'page matching "bug"', u'pages matching "bug"')
+
+...and a heading for when there are exact matches and Bing page
+matches.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': " launchpad"})
+    >>> search_view.has_exact_matches
+    True
+    >>> search_view.batch_heading
+    (u'other page matching "launchpad"', u'other pages matching "launchpad"')
+
+The SiteSearchBatchNavigator behaves like most BatchNavigators, except that
+its batch size is always 20. The size restriction conforms to Google's
+maximum number of results that can be returned per request.
+
+    >>> search_view.start
+    0
+    >>> search_view.pages.currentBatch().size
+    20
+    >>> pages = list(search_view.pages.currentBatch())
+    >>> len(pages)
+    20
+    >>> for page in pages[0:5]:
+    ...     page.title
+    u'Launchpad Bugs'
+    u'Bugs in Ubuntu Linux'
+    u'Bugs related to Sample Person'
+    u'Bug #1 in Mozilla Firefox: Firefox does not support SVG'
+    u'Question #232632 : Questions : OpenStack Heat'
+
+The batch navigator provides access to the other batches. There are two
+batches of pages that match the search text 'bugs'. The navigator
+provides a link to the next batch, which also happens to be the last
+batch.
+
+    >>> search_view.pages.nextBatchURL()
+    '...start=20'
+    >>> search_view.pages.lastBatchURL()
+    '...start=20'
+
+The second batch has only five matches in it, even though the batch size
+is 20. That is because there were only 25 matching pages.
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': "bug",
+    ...           'start': '20'})
+    >>> search_view.start
+    20
+    >>> search_view.text
+    u'bug'
+    >>> search_view.has_matches
+    True
+
+    >>> search_view.pages.currentBatch().size
+    20
+    >>> pages = list(search_view.pages.currentBatch())
+    >>> len(pages)
+    5
+    >>> for page in pages:
+    ...     page.title
+    u'Bugs - Launchpad Help'
+    u'Of Bugs and Statuses - Launchpad Blog'
+    u'Mahara 1.8.0'
+    u'Mighty Box in Launchpad'
+    u'Bug tracking - Launchpad Bugs'
+
+    >>> search_view.pages.nextBatchURL()
+    ''
+    >>> search_view.pages.lastBatchURL()
+    ''
+
+The PageMatch object has a title, url, and summary. The title and url
+are used for making links to the pages. The summary contains markup
+showing the matching terms in context of the page text.
+
+    >>> print range(20)
+    [0, 1, ..., 18, 19]
+    >>> page = pages[0]
+    >>> page
+    <...PageMatch ...>
+    >>> page.title
+    u'Bugs - Launchpad Help'
+    >>> page.url
+    'https://help.launchpad.net/Bugs'
+    >>> page.summary  # doctest: +ELLIPSIS
+    u"Launchpad Help > Bugs . Use Launchpad's bug tracker for your project..."
+
+See `google-searchservice.txt` for more information about the
+BingSearchService and PageMatch objects.
+
+
+No page matches
+---------------
+
+When an empty PageMatches object is returned by the BingSearchService to
+the view, there are no matches to show.
+
+    >>> search_view = getSearchView(form={'field.text': 'no-meaningful'})
+    >>> search_view.has_matches
+    False
+
+
+Unintelligible searches
+-----------------------
+
+When a user searches for a malformed string, we don't OOPS, but show an
+error. Also disable warnings, since we are tossing around malformed Unicode.
+
+    >>> import warnings
+    >>> with warnings.catch_warnings():
+    ...     warnings.simplefilter('ignore')
+    ...     search_view = getSearchView(
+    ...         form={'field.text': '\xfe\xfckr\xfc'})
+    >>> html = search_view()
+    >>> 'Can not convert your search term' in html
+    True
+
+
+Bad Bing response handling
+----------------------------
+
+Connectivity problems can cause missing or incomplete responses from
+Bing. The LaunchpadSearchView will display the other searches and
+show a message explaining that the user can search again to find
+matching pages.
+
+    >>> search_view = getSearchView(form={'field.text': 'gnomebaker'})
+    >>> search_view.has_matches
+    True
+    >>> search_view.pillar.displayname
+    u'gnomebaker'
+    >>> search_view.has_page_service
+    False
+
+The view provides the requested URL so that the template can make a
+link to try the search again
+
+    >>> print search_view.url
+    https://launchpad.dev/+search?field.text=gnomebaker
+
+
+SearchFormView and SearchFormPrimaryView
+----------------------------------------
+
+Two companion views are used to help render the global search form.
+They define the required attributes to render the form in the
+correct state.
+
+The LaunchpadSearchFormView provides the minimum information to display
+the form, but cannot handled the submitted data. It appends a suffix
+('-secondary') to the id= and name= of the form and inputs, to prevent
+them from conflicting with the other form. The search text is not the
+default value of the text field; 'bug' was submitted above, but is not
+present in the rendered form.
+
+    >>> search_form_view = getMultiAdapter(
+    ...     (search_view, request), name='+search-form')
+    >>> search_form_view.initialize()
+    >>> search_form_view.id_suffix
+    '-secondary'
+    >>> print search_form_view.render()
+    <form action="http://launchpad.dev/+search"; method="get"
+      accept-charset="UTF-8" id="sitesearch-secondary"
+      name="sitesearch-secondary">
+      <div>
+        <input class="textType" type="text" size="36"
+          id="field.text-secondary" name="field.text" />
+        <input class="button" type="submit" value="Search"
+          id="field.text-secondary" name="field.actions.search-secondary" />
+      </div>
+    </form>
+
+LaunchpadPrimarySearchFormView can handle submitted form by deferring to
+its context (the LaunchpadSearchView) for the needed information. The
+view does not append a suffix to the form and input ids. The search
+field's value is 'bug', as was submitted above.
+
+    >>> search_form_view = getMultiAdapter(
+    ...     (search_view, request), name='+primary-search-form')
+    >>> search_form_view.initialize()
+    >>> search_form_view.id_suffix
+    ''
+    >>> print search_form_view.render()
+    <form action="http://launchpad.dev/+search"; method="get"
+      accept-charset="UTF-8" id="sitesearch"
+      name="sitesearch">
+      <div>
+        <input class="textType" type="text" size="36"
+          id="field.text" value="gnomebaker" name="field.text" />
+        <input class="button" type="submit" value="Search"
+          id="field.text" name="field.actions.search" />
+      </div>
+    </form>
+
+WindowedList and SiteSearchBatchNavigator
+-------------------------------------
+
+The LaunchpadSearchView uses two helper classes to work with
+PageMatches.
+
+The PageMatches object returned by the BingSearchService contains 20
+or fewer PageMatches of what could be thousands of matches. Bing
+requires client's to make repeats request to step though the batches of
+matches. The Windowed list is a list that contains only a subset of its
+reported size. It is used to make batches in the SiteSearchBatchNavigator.
+
+For example, the last batch of the 'bug' search contained 5 of the 25
+matching pages. The WindowList claims to be 25 items in length, but
+the first 20 items are None. Only the last 5 items are PageMatches.
+
+    >>> from lp.app.browser.root import WindowedList
+    >>> from lp.services.sitesearch import BingSearchService
+
+    >>> bing_search = BingSearchService()
+    >>> page_matches = bing_search.search(terms='bug', start=20)
+    >>> results = WindowedList(
+    ...     page_matches, page_matches.start, page_matches.total)
+    >>> len(results)
+    25
+    >>> print results[0]
+    None
+    >>> results[24].title
+    u'Bug tracking - Launchpad Bugs'
+    >>> results[18, 22]
+    [None, None, <...PageMatch ...>, <...PageMatch ...>]
+
+The SiteSearchBatchNavigator restricts the batch size to 20. the 'batch'
+parameter that comes from the URL is ignored. For example, setting
+the 'batch' parameter to 100 has no affect upon the Bing search
+or on the navigator object.
+
+    >>> from lp.app.browser.root import SiteSearchBatchNavigator
+
+    >>> SiteSearchBatchNavigator.batch_variable_name
+    'batch'
+
+    >>> search_view = getSearchView(
+    ...     form={'field.text': "bug",
+    ...           'start': '0',
+    ...           'batch': '100',})
+
+    >>> navigator = search_view.pages
+    >>> navigator.currentBatch().size
+    20
+    >>> len(navigator.currentBatch())
+    20
+    >>> navigator.nextBatchURL()
+    '...start=20'
+
+Even if the PageMatch object to have an impossibly large size, the
+navigator conforms to Google's maximum size of 20.
+
+    >>> matches = list(range(0, 100))
+    >>> page_matches._matches = matches
+    >>> page_matches.start = 0
+    >>> page_matches.total = 100
+    >>> navigator = SiteSearchBatchNavigator(
+    ...     page_matches, search_view.request, page_matches.start, size=100)
+    >>> navigator.currentBatch().size
+    20
+    >>> len(navigator.currentBatch())
+    20
+    >>> navigator.nextBatchURL()
+    '...start=20'
+
+The PageMatches object can be smaller than 20, for instance, pages
+without titles are skipped when parsing the Bing Search JSON. The size
+of the batch is still 20, but when the items in the batch are iterated,
+the true size can be seen. For example there could be only 3 matches in
+the PageMatches object, so only 3 are yielded. The start of the next
+batch is 20, which is the start of the next batch from Bing.
+
+    >>> matches = list(range(0, 3))
+    >>> page_matches._matches = matches
+    >>> navigator = SiteSearchBatchNavigator(
+    ...     page_matches, search_view.request, page_matches.start, size=100)
+    >>> batch = navigator.currentBatch()
+    >>> batch.size
+    20
+    >>> len(batch)
+    20
+    >>> batch.endNumber()
+    3
+    >>> for item in batch:
+    ...     print item
+    0
+    1
+    2
+    >>> navigator.nextBatchURL()
+    '...start=20'

=== renamed file 'lib/lp/app/browser/tests/launchpad-search-pages.txt' => 'lib/lp/app/browser/doc/launchpad-search-pages-google.txt'
=== renamed file 'lib/lp/app/browser/tests/launchpadform-view.txt' => 'lib/lp/app/browser/doc/launchpadform-view.txt'
=== renamed file 'lib/lp/app/browser/tests/menu.txt' => 'lib/lp/app/browser/doc/menu.txt'
=== renamed file 'lib/lp/app/browser/tests/root-views.txt' => 'lib/lp/app/browser/doc/root-views.txt'
=== renamed file 'lib/lp/app/browser/tests/watermark.txt' => 'lib/lp/app/browser/doc/watermark.txt'
=== modified file 'lib/lp/app/browser/root.py'
--- lib/lp/app/browser/root.py	2018-03-27 14:31:36 +0000
+++ lib/lp/app/browser/root.py	2018-03-28 21:29:05 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 """Browser code for the Launchpad root page."""
 
@@ -510,18 +510,21 @@
     def searchPages(self, query_terms, start=0):
         """Return the up to 20 pages that match the query_terms, or None.
 
-        :param query_terms: The unescaped terms to query Google.
+        :param query_terms: The unescaped terms to query for.
         :param start: The index of the page that starts the set of pages.
-        :return: A GooglBatchNavigator or None.
+        :return: A SiteSearchBatchNavigator or None.
         """
         if query_terms in [None, '']:
             return None
-        google_search = getUtility(ISearchService)
+        search_engine = getFeatureFlag("sitesearch.engine.name")
+        # Default to the Google search engine.
+        search_engine = search_engine or "google"
+        site_search = getUtility(ISearchService, name=search_engine)
         try:
-            page_matches = google_search.search(
+            page_matches = site_search.search(
                 terms=query_terms, start=start)
         except SiteSearchResponseError:
-            # There was a connectivity or Google service issue that means
+            # There was a connectivity or search service issue that means
             # there is no data available at this moment.
             self.has_page_service = False
             return None

=== modified file 'lib/lp/app/browser/tests/test_views.py'
--- lib/lp/app/browser/tests/test_views.py	2011-12-28 17:03:06 +0000
+++ lib/lp/app/browser/tests/test_views.py	2018-03-28 21:29:05 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """
@@ -7,10 +7,11 @@
 
 import logging
 import os
-import unittest
 
+from lp.services.features.testing import FeatureFixture
+from lp.services.testing import build_test_suite
 from lp.testing.layers import (
-    DatabaseFunctionalLayer,
+    BingLaunchpadFunctionalLayer,
     GoogleLaunchpadFunctionalLayer,
     )
 from lp.testing.systemdocs import (
@@ -21,32 +22,46 @@
 
 
 here = os.path.dirname(os.path.realpath(__file__))
+bing_flag = FeatureFixture({'sitesearch.engine.name': 'bing'})
+google_flag = FeatureFixture({'sitesearch.engine.name': 'google'})
+
+
+def setUp_bing(test):
+    setUp(test)
+    bing_flag.setUp()
+
+
+def setUp_google(test):
+    setUp(test)
+    google_flag.setUp()
+
+
+def tearDown_bing(test):
+    bing_flag.cleanUp()
+    tearDown(test)
+
+
+def tearDown_google(test):
+    google_flag.cleanUp()
+    tearDown(test)
+
 
 # The default layer of view tests is the DatabaseFunctionalLayer. Tests
 # that require something special like the librarian or mailman must run
 # on a layer that sets those services up.
-special_test_layer = {
-    'launchpad-search-pages.txt': GoogleLaunchpadFunctionalLayer,
+special = {
+    'launchpad-search-pages-bing.txt': LayeredDocFileSuite(
+        '../doc/launchpad-search-pages-bing.txt',
+        setUp=setUp_bing, tearDown=tearDown_bing,
+        layer=BingLaunchpadFunctionalLayer,
+        stdout_logging_level=logging.WARNING),
+    'launchpad-search-pages-google.txt': LayeredDocFileSuite(
+        '../doc/launchpad-search-pages-google.txt',
+        setUp=setUp_google, tearDown=tearDown_google,
+        layer=GoogleLaunchpadFunctionalLayer,
+        stdout_logging_level=logging.WARNING),
     }
 
 
 def test_suite():
-    suite = unittest.TestSuite()
-    testsdir = os.path.abspath(here)
-
-    # Add tests using default setup/teardown
-    filenames = [filename
-                 for filename in os.listdir(testsdir)
-                 if filename.endswith('.txt')]
-    # Sort the list to give a predictable order.
-    filenames.sort()
-    for filename in filenames:
-        path = filename
-        layer = special_test_layer.get(path, DatabaseFunctionalLayer)
-        one_test = LayeredDocFileSuite(
-            path, setUp=setUp, tearDown=tearDown, layer=layer,
-            stdout_logging_level=logging.WARNING
-            )
-        suite.addTest(one_test)
-
-    return suite
+    return build_test_suite(here, special)

=== modified file 'lib/lp/app/stories/launchpad-root/site-search.txt'
--- lib/lp/app/stories/launchpad-root/site-search.txt	2016-01-26 15:47:37 +0000
+++ lib/lp/app/stories/launchpad-root/site-search.txt	2018-03-28 21:29:05 +0000
@@ -1,9 +1,9 @@
 Site-wide Search
 ================
 
-Launchpad features a site-wide search that combines Google's site-
-specific search with Launchpad's prominent objects (projects, bugs,
-teams, etc.).
+Launchpad features a site-wide search that combines an external search
+engine's site-specific search with Launchpad's prominent objects (projects,
+bugs, teams, etc.).
 
     # Our very helpful function for printing all the page results.
 
@@ -81,9 +81,9 @@
     3: Firefox is slow and consumes too much RAM
     ...
 
-An arbitrary search returns a list of Google search results. The user
-searches for "bug" and sees a listing of matching pages. The navigation
-states that the page is showing 1 through 20 of 25 total results.
+An arbitrary search returns a list of the search engine's search results.
+The user searches for "bug" and sees a listing of matching pages. The
+navigation states that the page is showing 1 through 20 of 25 total results.
 
     # Use our pre-defined search results for the 'bug' search.
 
@@ -248,9 +248,10 @@
 Searches when there is no page service
 --------------------------------------
 
-Google may not be available when the search is performed. This is often
-caused by temporary connectivity problems. A message is displayed that
-explains that the search can be performed again to find matching pages.
+The search provider may not be available when the search is performed.
+This is often caused by temporary connectivity problems. A message is
+displayed that explains that the search can be performed again to find
+matching pages.
 
     >>> search_for('gnomebaker')
     >>> print find_tag_by_id(anon_browser.contents, 'no-page-service')

=== modified file 'lib/lp/scripts/runlaunchpad.py'
--- lib/lp/scripts/runlaunchpad.py	2018-03-16 14:50:01 +0000
+++ lib/lp/scripts/runlaunchpad.py	2018-03-28 21:29:05 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -26,7 +26,10 @@
     pidfile_path,
     )
 from lp.services.rabbit.server import RabbitServer
-from lp.services.sitesearch import googletestservice
+from lp.services.sitesearch import (
+    bingtestservice,
+    googletestservice,
+    )
 from lp.services.txlongpoll.server import TxLongPollServer
 
 
@@ -153,6 +156,16 @@
         self.addCleanup(stop_process, googletestservice.start_as_process())
 
 
+class BingWebService(Service):
+
+    @property
+    def should_launch(self):
+        return config.bing_test_service.launch
+
+    def launch(self):
+        self.addCleanup(stop_process, bingtestservice.start_as_process())
+
+
 class MemcachedService(Service):
     """A local memcached service for developer environments."""
 
@@ -280,6 +293,7 @@
     'sftp': TacFile('sftp', 'daemons/sftp.tac', 'codehosting'),
     'forker': ForkingSessionService(),
     'mailman': MailmanService(),
+    'bing-webservice': BingWebService(),
     'codebrowse': CodebrowseService(),
     'google-webservice': GoogleWebService(),
     'memcached': MemcachedService(),

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2018-03-16 14:02:16 +0000
+++ lib/lp/services/config/schema-lazr.conf	2018-03-28 21:29:05 +0000
@@ -791,7 +791,43 @@
 # url_rewrite_exceptions is a list of launchpad.net domains that must
 # not be rewritten.
 # datatype: string of space separated domains
-# Example: help.launchpad.net login.launchapd.net
+# Example: help.launchpad.net login.launchpad.net
+url_rewrite_exceptions: help.launchpad.net
+
+[bing_test_service]
+# Run a web service stub that simulates the Bing search service.
+
+# Where are our canned JSON responses stored?
+canned_response_directory: lib/lp/services/sitesearch/tests/data/
+
+# Which file maps service URLs to the JSON that the server returns?
+mapfile: lib/lp/services/sitesearch/tests/data/bingsearchservice-mapping.txt
+
+# Where should the service log files live?
+log: logs/bing-stub.log
+
+# Do we actually want to run the service?
+launch: False
+
+[bing]
+# site is the host and path that search requests are made to.
+# eg. https://api.cognitive.microsoft.com/bingcustomsearch/v7.0/search
+# datatype: string, a url to a host
+site: https://api.cognitive.microsoft.com/bingcustomsearch/v7.0/search
+
+# subscription_key is the Cognitive Services subscription key for
+# Bing Custom Search API.
+# datatype: string
+subscription_key:
+
+# custom_config_id is the id that identifies the custom search instance.
+# datatype: string
+custom_config_id:
+
+# url_rewrite_exceptions is a list of launchpad.net domains that must
+# not be rewritten.
+# datatype: string of space separated domains
+# Example: help.launchpad.net login.launchpad.net
 url_rewrite_exceptions: help.launchpad.net
 
 [gpghandler]

=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py	2016-10-14 16:16:18 +0000
+++ lib/lp/services/features/flags.py	2018-03-28 21:29:05 +0000
@@ -234,6 +234,12 @@
      'disabled',
      'Named authorization tokens for archives',
      ''),
+    ('sitesearch.engine.name',
+     'space delimited',
+     'Name of the site search engine backend ("google" or "bing").',
+     'google',
+     'Site search engine',
+     ''),
     ])
 
 # The set of all flag names that are documented.

=== modified file 'lib/lp/services/sitesearch/__init__.py'
--- lib/lp/services/sitesearch/__init__.py	2018-03-27 15:47:35 +0000
+++ lib/lp/services/sitesearch/__init__.py	2018-03-28 21:29:05 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces for searching and working with results."""
@@ -6,11 +6,13 @@
 __metaclass__ = type
 
 __all__ = [
+    'BingSearchService',
     'GoogleSearchService',
     'PageMatch',
     'PageMatches',
     ]
 
+import json
 import urllib
 from urlparse import (
     parse_qsl,
@@ -163,7 +165,7 @@
 class GoogleSearchService:
     """See `ISearchService`.
 
-    A search service that search Google for launchpad.net pages.
+    A search service that searches Google for launchpad.net pages.
     """
 
     _default_values = {
@@ -333,3 +335,160 @@
             raise GoogleWrongGSPVersion(
                 "Could not get any PageMatches from the GSP XML response.")
         return PageMatches(page_matches, start, total)
+
+
+@implementer(ISearchService)
+class BingSearchService:
+    """See `ISearchService`.
+
+    A search service that searches Bing for launchpad.net pages.
+    """
+
+    _default_values = {
+        # XXX: maxiberta 2018-03-26: Set `mkt` based on the current request.
+        'customConfig': None,
+        'mkt': 'en-US',
+        'count': 20,
+        'offset': 0,
+        'q': None,
+        }
+
+    @property
+    def subscription_key(self):
+        """The subscription key issued by Bing Custom Search."""
+        return config.bing.subscription_key
+
+    @property
+    def custom_config_id(self):
+        """The custom search instance as configured in Bing Custom Search."""
+        return config.bing.custom_config_id
+
+    @property
+    def site(self):
+        """The URL to the Bing Custom Search service.
+
+        The URL is probably
+        https://api.cognitive.microsoft.com/bingcustomsearch/v7.0/search.
+        """
+        return config.bing.site
+
+    def search(self, terms, start=0):
+        """See `ISearchService`.
+
+        The `subscription_key` and `custom_config_id` are used in the
+        search request. Search returns 20 or fewer results for each query.
+        For terms that match more than 20 results, the start param can be
+        used over multiple queries to get successive sets of results.
+
+        :return: `ISearchResults` (PageMatches).
+        :raise: `SiteSearchResponseError` if the json response is incomplete or
+            cannot be parsed.
+        """
+        search_url = self.create_search_url(terms, start=start)
+        search_headers = self.create_search_headers()
+        request = get_current_browser_request()
+        timeline = get_request_timeline(request)
+        action = timeline.start("bing-search-api", search_url)
+        try:
+            response = urlfetch(search_url, headers=search_headers)
+        except (TimeoutError, requests.RequestException) as error:
+            raise SiteSearchResponseError(
+                "The response errored: %s" % str(error))
+        finally:
+            action.finish()
+        page_matches = self._parse_bing_response(response.content, start)
+        return page_matches
+
+    def _checkParameter(self, name, value, is_int=False):
+        """Check that a parameter value is not None or an empty string."""
+        if value in (None, ''):
+            raise ValueError("Missing value for parameter '%s'." % name)
+        if is_int:
+            try:
+                int(value)
+            except ValueError:
+                raise ValueError(
+                    "Value for parameter '%s' is not an int." % name)
+
+    def create_search_url(self, terms, start=0):
+        """Return a Bing Custom Search search url."""
+        self._checkParameter('q', terms)
+        self._checkParameter('offset', start, is_int=True)
+        self._checkParameter('customConfig', self.custom_config_id)
+        search_params = dict(self._default_values)
+        search_params['q'] = terms.encode('utf8')
+        search_params['offset'] = start
+        search_params['customConfig'] = self.custom_config_id
+        query_string = urllib.urlencode(sorted(search_params.items()))
+        return self.site + '?' + query_string
+
+    def create_search_headers(self):
+        """Return a dict with Bing Custom Search compatible request headers."""
+        self._checkParameter('subscription_key', self.subscription_key)
+        return {
+            'Ocp-Apim-Subscription-Key': self.subscription_key,
+            }
+
+    def _parse_bing_response(self, bing_json, start=0):
+        """Return a `PageMatches` object.
+
+        :param bing_json: A string containing Bing Custom Search API v7 JSON.
+        :return: `ISearchResults` (PageMatches).
+        :raise: `SiteSearchResponseError` if the json response is incomplete or
+            cannot be parsed.
+        """
+        try:
+            bing_doc = json.loads(bing_json)
+        except (TypeError, ValueError):
+            raise SiteSearchResponseError(
+                "The response was incomplete, no JSON.")
+
+        try:
+            response_type = bing_doc['_type']
+        except (AttributeError, KeyError, ValueError):
+            raise SiteSearchResponseError(
+                "Could not get the '_type' from the Bing JSON response.")
+
+        if response_type == 'ErrorResponse':
+            try:
+                errors = [error['message'] for error in bing_doc['errors']]
+                raise SiteSearchResponseError(
+                    "Error response from Bing: %s" % '; '.join(errors))
+            except (AttributeError, KeyError, TypeError, ValueError):
+                raise SiteSearchResponseError(
+                    "Unable to parse the Bing JSON error response.")
+        elif response_type != 'SearchResponse':
+            raise SiteSearchResponseError(
+                "Unknown Bing JSON response type: '%s'." % response_type)
+
+        page_matches = []
+        total = 0
+        try:
+            results = bing_doc['webPages']['value']
+        except (AttributeError, KeyError, ValueError):
+            # Bing did not match any pages. Return an empty PageMatches.
+            return PageMatches(page_matches, start, total)
+
+        try:
+            total = int(bing_doc['webPages']['totalEstimatedMatches'])
+        except (AttributeError, KeyError, ValueError):
+            # The datatype is not what PageMatches requires.
+            raise SiteSearchResponseError(
+                "Could not get the total from the Bing JSON response.")
+        if total < 0:
+            # See bug 683115.
+            total = 0
+        for result in results:
+            url = result.get('url')
+            title = result.get('name')
+            summary = result.get('snippet')
+            if None in (url, title, summary):
+                # There is not enough data to create a PageMatch object.
+                # This can be caused by an empty title or summary which
+                # has been observed for pages that are from vhosts that
+                # should not be indexed.
+                continue
+            summary = summary.replace('<br>', '')
+            page_matches.append(PageMatch(title, url, summary))
+
+        return PageMatches(page_matches, start, total)

=== added file 'lib/lp/services/sitesearch/bingtestservice.py'
--- lib/lp/services/sitesearch/bingtestservice.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/bingtestservice.py	2018-03-28 21:29:05 +0000
@@ -0,0 +1,79 @@
+#!/usr/bin/python
+#
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""
+This script runs a simple HTTP server. The server returns JSON files
+when given certain user-configurable URLs.
+"""
+
+import logging
+import os
+
+from six.moves.BaseHTTPServer import HTTPServer
+
+from lp.services.config import config
+from lp.services.osutils import ensure_directory_exists
+from lp.services.pidfile import make_pidfile
+from lp.services.sitesearch import testservice
+
+
+# Set up basic logging.
+log = logging.getLogger(__name__)
+
+# The default service name, used by the Launchpad service framework.
+service_name = 'bing-webservice'
+
+
+class BingRequestHandler(testservice.RequestHandler):
+    default_content_type = 'text/xml; charset=UTF-8'
+    log = log
+    mapfile = config.bing_test_service.mapfile
+    content_dir = config.bing_test_service.canned_response_directory
+
+
+def start_as_process():
+    return testservice.start_as_process('bingtestservice')
+
+
+def get_service_endpoint():
+    """Return the host and port that the service is running on."""
+    return testservice.hostpair(config.bing.site)
+
+
+def service_is_available():
+    host, port = get_service_endpoint()
+    return testservice.service_is_available(host, port)
+
+
+def wait_for_service():
+    host, port = get_service_endpoint()
+    return testservice.wait_for_service(host, port)
+
+
+def kill_running_process():
+    global service_name
+    host, port = get_service_endpoint()
+    return testservice.kill_running_process(service_name, host, port)
+
+
+def main():
+    """Run the HTTP server."""
+    # Redirect our service output to a log file.
+    global log
+    ensure_directory_exists(os.path.dirname(config.bing_test_service.log))
+    filelog = logging.FileHandler(config.bing_test_service.log)
+    log.addHandler(filelog)
+    log.setLevel(logging.DEBUG)
+
+    # To support service shutdown we need to create a PID file that is
+    # understood by the Launchpad services framework.
+    global service_name
+    make_pidfile(service_name)
+
+    host, port = get_service_endpoint()
+    server = HTTPServer((host, port), BingRequestHandler)
+
+    log.info("Starting HTTP Bing webservice server on port %s", port)
+    server.serve_forever()

=== modified file 'lib/lp/services/sitesearch/configure.zcml'
--- lib/lp/services/sitesearch/configure.zcml	2018-03-16 14:02:16 +0000
+++ lib/lp/services/sitesearch/configure.zcml	2018-03-28 21:29:05 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -16,8 +16,17 @@
   </class>
 
   <securedutility
+    name="google"
     class="lp.services.sitesearch.GoogleSearchService"
     provides="lp.services.sitesearch.interfaces.ISearchService">
     <allow interface="lp.services.sitesearch.interfaces.ISearchService" />
   </securedutility>
+
+  <securedutility
+    name="bing"
+    class="lp.services.sitesearch.BingSearchService"
+    provides="lp.services.sitesearch.interfaces.ISearchService">
+    <allow interface="lp.services.sitesearch.interfaces.ISearchService" />
+  </securedutility>
+
 </configure>

=== added file 'lib/lp/services/sitesearch/doc/bing-searchservice.txt'
--- lib/lp/services/sitesearch/doc/bing-searchservice.txt	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/doc/bing-searchservice.txt	2018-03-28 21:29:05 +0000
@@ -0,0 +1,438 @@
+===================
+Bing Search Service
+===================
+
+The BingSearchService is a Bing Custom Search client.
+Given one or more terms, it will retrieve a JSON
+summary of the matching launchpad.net pages.
+
+We silence logging of new HTTP connections from requests throughout.
+
+    >>> from fixtures import FakeLogger
+    >>> logger = FakeLogger()
+    >>> logger.setUp()
+
+
+BingSearchService
+=================
+
+The BingSearchService implements the ISearchService interface.
+
+    >>> from zope.component import getUtility
+    >>> from zope.interface.verify import verifyObject
+    >>> from lp.services.sitesearch.interfaces import (
+    ...     ISearchService)
+
+    >>> bing_search = getUtility(ISearchService, name="bing")
+    >>> verifyObject(ISearchService, bing_search)
+    True
+    >>> bing_search
+    <...BingSearchService ...>
+
+
+--------------------------
+BingSearchService search()
+--------------------------
+
+The search method accepts a string argument of terms and an optional int
+argument of start. The terms are the same as the text that would be
+entered in Bing search form; the terms should not be escaped.
+
+    >>> from lp.services.sitesearch.interfaces import (
+    ...     ISearchResults)
+
+    >>> first_page_matches = bing_search.search(terms='bug')
+    >>> first_page_matches
+    <...PageMatches ...>
+
+The start parameter specifies the index (starting at 0) of the first
+result returned in the overall set of matches. Since 20 results are
+returned, to get the second batch of matches, you would use start=20.
+
+    >>> second_page_matches = bing_search.search(terms='bug', start=20)
+    >>> second_page_matches
+    <...PageMatches ...>
+
+
+PageMatches
+===========
+
+The PageMatches object returned by BingSearchService.search()
+implements ISearchResults.
+
+    >>> verifyObject(ISearchResults, first_page_matches)
+    True
+
+The 'total' attribute is the total number of matches that the search
+found. If that number is higher than 20, it means that multiple requests
+would be needed to retrieve the entire result set.
+
+The 'start' attribute is the index of the first returned item within the
+entire collection of matches. The 'length' attribute contains the number
+of returned results.
+
+The first search for 'bugs' returned a subset of items in the
+ISearchResult. There are 25 total items, but the results contains the
+first 20 matches (because they start at index 0).
+
+    >>> first_page_matches.start
+    0
+    >>> first_page_matches.total
+    25
+    >>> len(first_page_matches)
+    20
+
+The second search for 'bugs' returned the remainder of the 25 matches.
+They start from index 20.
+
+    >>> verifyObject(ISearchResults, second_page_matches)
+    True
+
+    >>> second_page_matches.start
+    20
+    >>> second_page_matches.total
+    25
+    >>> len(second_page_matches)
+    5
+
+An item can be retrieved from an ISearchResults object using its
+index. All the items in the collection can be iterated.
+
+    >>> second_page_matches[1].url
+    'http://blog.launchpad.dev/general/of-bugs-and-statuses'
+
+    >>> for page_match in second_page_matches:
+    ...     page_match.url
+    'https://help.launchpad.net/Bugs'
+    'http://blog.launchpad.dev/general/of-bugs-and-statuses'
+    'http://launchpad.dev/mahara/+milestone/1.8.0'
+    'http://launchpad.dev/mb'
+    'http://launchpad.dev/bugs'
+
+An empty PageMatches is returns if there are no results.
+
+    >>> no_page_matches = bing_search.search(terms='fnord')
+    >>> no_page_matches.start
+    0
+    >>> no_page_matches.total
+    0
+    >>> len(no_page_matches)
+    0
+
+
+PageMatch
+=========
+
+The PageMatch object represents a single result from a search result
+set. It is created by passing a title, url, and a summary. It is
+an implementation of ISearchResult.
+
+    >>> from lp.services.sitesearch.interfaces import ISearchResult
+    >>> from lp.services.sitesearch import PageMatch
+
+    >>> page_match = PageMatch(
+    ...     u'Unicode Titles in Launchpad',
+    ...     'https://launchpad.net/unicode-titles',
+    ...     u'Unicode Titles is a modest project dedicated to using Unicode.')
+
+    >>> verifyObject(ISearchResult, page_match)
+    True
+
+The title and summary attributes contain the same text that
+initialized the object.
+
+    >>> page_match.title
+    u'Unicode Titles in Launchpad'
+    >>> page_match.summary
+    u'Unicode Titles is a modest project dedicated to using Unicode.'
+
+The URL's domain is rewitten to the so that links from launchpad.net are
+mapped to the local environment.
+
+    >>> page_match.url
+    'http://launchpad.dev/unicode-titles'
+
+
+Search configuration
+====================
+
+The bing search service is configured by the bing section in
+lazr.config. All requests are made to Bing's site, but the
+configuration may set a testing site.
+
+    >>> from lp.services.config import config
+    >>> from lp.services.sitesearch import BingSearchService
+
+    >>> bing_search = BingSearchService()
+    >>> config.bing.site == bing_search.site
+    True
+    >>> bing_search.site
+    'http://launchpad.dev:.../bingcustomsearch/v7.0/search'
+
+The subscription_key is the Cognitive Services subscription key for
+Bing Custom Search API.
+
+    >>> config.bing.subscription_key == bing_search.subscription_key
+    True
+    >>> bing_search.subscription_key
+    'abcdef01234567890abcdef012345678'
+
+The custom_config_id is the id that identifies the custom search instance.
+
+    >>> config.bing.custom_config_id == bing_search.custom_config_id
+    True
+    >>> bing_search.custom_config_id
+    1234567890
+
+Several default query parameters are constant. They are kept in the
+_default_values dict. The q (terms) and start params are provided at
+the time of the search.
+
+    >>> for key, value in sorted(bing_search._default_values.items()):
+    ...     print key, ':', repr(value)
+    count : 20
+    customConfig : None
+    mkt : 'en-US'
+    offset : 0
+    q : None
+
+create_search_url()
+===================
+
+The search url used inside the search() method is created by
+create_search_url(). It accepts two optional arguments: terms and start.
+An error is raised if any of the parameters are None.
+
+    >>> bing_search.create_search_url('')
+    Traceback (most recent call last):
+     ...
+    ValueError: Missing value for parameter 'q'.
+
+    >>> bing_search.create_search_url(None)
+    Traceback (most recent call last):
+     ...
+    ValueError: Missing value for parameter 'q'.
+
+    >>> bing_search.create_search_url('bugs', start='true')
+    Traceback (most recent call last):
+     ...
+    ValueError: Value for parameter 'offset' is not an int.
+
+The term parameter in this example can be defined by passing the term
+argument to the method. The argument is url encoded and used as the
+value for the 'q' (query) parameter.
+
+    >>> bing_search.create_search_url(terms='svg +bugs').replace('&', ' ')
+    'http://launchpad.dev:.../bingcustomsearch/v7.0/search?count=20 customConfig=1234567890 mkt=en-US offset=0 q=svg+%2Bbugs'
+
+Unicode characters are escaped correctly in the bing request URL.
+
+    >>> bing_search.create_search_url(terms=u'Carlos Perell\xf3 Mar\xedn')
+    'http://launchpad.dev:.../...offset=0&q=Carlos+Perell%C3%B3+Mar%C3%ADn'
+
+The start parameter can be changed by passing a start int argument.
+
+    >>> bing_search.create_search_url(terms='svg +bugs', start=20)
+    'http://launchpad.dev:.../...offset=20&q=svg+%2Bbugs'
+
+
+Bing Search response parsing
+============================
+
+The BingSearchService's _parse_bing_response() expects a JSON response to
+create the PageMatch and PageMatches objects. An error is raised when
+the JSON document cannot be parsed into objects.
+
+The PageMatches's total attribute comes from the `webPages.totalEstimatedMatches`
+JSON element. When it cannot be found and the value cast to an int,
+an error is raised. If Bing were to redefine the meaning of the
+element to use a '~' to indicate an approximate total, an error would
+be raised.
+
+    >>> from os import path
+
+    >>> base_path = path.normpath(path.join(
+    ...         path.dirname(__file__), '..', 'tests', 'data'))
+    >>> json_file_name = path.join(
+    ...     base_path, 'bingsearchservice-incompatible-matches.json')
+    >>> with open(json_file_name, 'r') as json_file:
+    ...     data = json_file.read()
+    >>> print data
+    {...
+        "totalEstimatedMatches": "~25"...
+
+    >>> bing_search._parse_bing_response(data)
+    Traceback (most recent call last):
+     ...
+    SiteSearchResponseError: Could not get the total from the
+                           Bing JSON response.
+
+On the other hand, if the total is ever less than zero (see bug 683115),
+this is expected: we simply return a total of 0.
+
+    >>> json_file_name = path.join(
+    ...     base_path, 'bingsearchservice-negative-total.json')
+    >>> with open(json_file_name, 'r') as json_file:
+    ...     data = json_file.read()
+    >>> print data
+    {...
+        "totalEstimatedMatches": -25...
+
+    >>> bing_search._parse_bing_response(data).total
+    0
+
+A PageMatch requires a title, url, and a summary. If those elements cannot
+be found, a PageMatch cannot be made. A missing title ('name') indicates
+a bad page on Launchpad, so it is ignored. In this example, the first match
+is missing a title, so only the second page is present in the PageMatches.
+
+    >>> json_file_name = path.join(
+    ...     base_path, 'bingsearchservice-missing-title.json')
+    >>> with open(json_file_name, 'r') as json_file:
+    ...     data = json_file.read()
+    >>> page_matches = bing_search._parse_bing_response(data)
+    >>> len(page_matches)
+    1
+    >>> page_matches[0].title
+    u'GCleaner in Launchpad'
+    >>> page_matches[0].url
+    'http://launchpad.dev/gcleaner'
+
+When a match is missing a summary ('snippet'), the match is skipped because
+there is no information about why it matched. This appears to relate to
+pages that are in the index, but should be removed. In this example
+taken from real data, the links are to the same page on different
+vhosts. The edge vhost has no summary, so it is skipped.
+
+    >>> json_file_name = path.join(
+    ...     base_path, 'bingsearchservice-missing-summary.json')
+    >>> with open(json_file_name, 'r') as json_file:
+    ...     data = json_file.read()
+    >>> page_matches = bing_search._parse_bing_response(data)
+    >>> len(page_matches)
+    1
+    >>> page_matches[0].title
+    u'BugExpiry - Launchpad Help'
+    >>> page_matches[0].url
+    'https://help.launchpad.net/BugExpiry'
+
+When the URL ('url') cannot be found the match is skipped. There are no
+examples of this. We do not want this hypothetical situation to give
+users a bad experience.
+
+    >>> json_file_name = path.join(
+    ...     base_path, 'bingsearchservice-missing-url.json')
+    >>> with open(json_file_name, 'r') as json_file:
+    ...     data = json_file.read()
+    >>> page_matches = bing_search._parse_bing_response(data)
+    >>> len(page_matches)
+    1
+    >>> page_matches[0].title
+    u'LongoMatch in Launchpad'
+    >>> page_matches[0].url
+    'http://launchpad.dev/longomatch'
+
+If no matches are found in the response, and there are 20 or fewer results,
+an Empty PageMatches is returned. This happens when the results are missing
+titles and summaries. This is not considered to be a problem because the
+small number implies that Bing did a poor job of indexing pages or indexed
+the wrong Launchpad server. In this example, there is only one match, but
+the results is missing a title so there is not enough information to make
+a PageMatch.
+
+    >>> json_file_name = path.join(
+    ...     base_path, 'bingsearchservice-no-meaningful-results.json')
+    >>> with open(json_file_name, 'r') as json_file:
+    ...     data = json_file.read()
+    >>> page_matches = bing_search._parse_bing_response(data)
+    >>> len(page_matches)
+    0
+
+
+-------------
+URL rewriting
+-------------
+
+The URL scheme used in the rewritten URL is configured in
+config.bing.url_rewrite_scheme. The hostname is set in the shared
+key config.vhost.mainsite.hostname.
+
+    >>> config.vhosts.use_https
+    False
+    >>> page_match.url_rewrite_scheme
+    'http'
+
+    >>> config.vhost.mainsite.hostname == page_match.url_rewrite_hostname
+    True
+    >>> page_match.url_rewrite_hostname
+    'launchpad.dev'
+
+URLs are rewritten to map public URL to the private hostname.
+The vhost name is preserved when the URL is rewritten.
+
+    >>> page_match = PageMatch(
+    ...     u'Bug #456 in Unicode title: "testrunner hates Unicode"',
+    ...     'https://bugs.launchpad.net/unicode-titles/+bug/456',
+    ...     u'The Zope testrunner likes ASCII more than Unicode.')
+    >>> page_match.url
+    'http://bugs.launchpad.dev/unicode-titles/+bug/456'
+
+A URL's trailing slash is removed; Launchpad does not use trailing
+slashes.
+
+    >>> page_match = PageMatch(
+    ...     u'Ubuntu in Launchpad',
+    ...     'https://launchpad.net/ubuntu/',
+    ...     u'Ubuntu also includes more software than any other operating')
+    >>> page_match.url
+    'http://launchpad.dev/ubuntu'
+
+There is a list of URLs that are not rewritten configured in
+config.bing.url_rewrite_exceptions. For example, help.launchpad.net
+is only run in one environment, so links to that site will be preserved.
+
+    >>> config.bing.url_rewrite_exceptions
+    'help.launchpad.net'
+    >>> page_match.url_rewrite_exceptions
+    ['help.launchpad.net']
+
+    >>> page_match = PageMatch(
+    ...     u'OpenID',
+    ...     'https://help.launchpad.net/OpenID',
+    ...     u'Launchpad uses OpenID.')
+    >>> page_match.url
+    'https://help.launchpad.net/OpenID'
+
+
+-----------------------------
+Graceful handling of timeouts
+-----------------------------
+
+The external service (Bing Search Engine) may not be available, or
+is not responding quickly because there are network issues. In these
+cases a TimeoutError is issued.
+
+    >>> from socket import socket
+    >>> from textwrap import dedent
+    >>> from lp.services.timeout import (
+    ...     get_default_timeout_function, set_default_timeout_function)
+
+    >>> server = socket()
+    >>> server.bind(('127.0.0.01', 0))
+    >>> server.listen(1)
+    >>> config.push('timeout_data', dedent("""
+    ...     [bing]
+    ...     site: http://%s:%d/cse
+    ...     """ % server.getsockname()))
+    >>> old_timeout_function = get_default_timeout_function()
+    >>> set_default_timeout_function(lambda: 0.1)
+    >>> bing_search.search(terms='bug')
+    Traceback (most recent call last):
+     ...
+    SiteSearchResponseError: ... timeout exceeded.
+
+    # Restore the configuration and the timeout state.
+    >>> timeout_data = config.pop('timeout_data')
+    >>> set_default_timeout_function(old_timeout_function)
+
+    >>> logger.cleanUp()

=== modified file 'lib/lp/services/sitesearch/doc/google-searchservice.txt'
--- lib/lp/services/sitesearch/doc/google-searchservice.txt	2018-03-27 15:47:35 +0000
+++ lib/lp/services/sitesearch/doc/google-searchservice.txt	2018-03-28 21:29:05 +0000
@@ -23,7 +23,7 @@
     >>> from lp.services.sitesearch.interfaces import (
     ...     ISearchService)
 
-    >>> google_search = getUtility(ISearchService)
+    >>> google_search = getUtility(ISearchService, name="google")
     >>> verifyObject(ISearchService, google_search)
     True
     >>> google_search

=== added file 'lib/lp/services/sitesearch/googletestservice.py'
--- lib/lp/services/sitesearch/googletestservice.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/googletestservice.py	2018-03-28 21:29:05 +0000
@@ -0,0 +1,79 @@
+#!/usr/bin/python
+#
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""
+This script runs a simple HTTP server. The server returns XML files
+when given certain user-configurable URLs.
+"""
+
+import logging
+import os
+
+from six.moves.BaseHTTPServer import HTTPServer
+
+from lp.services.config import config
+from lp.services.osutils import ensure_directory_exists
+from lp.services.pidfile import make_pidfile
+from lp.services.sitesearch import testservice
+
+
+# Set up basic logging.
+log = logging.getLogger(__name__)
+
+# The default service name, used by the Launchpad service framework.
+service_name = 'google-webservice'
+
+
+class GoogleRequestHandler(testservice.RequestHandler):
+    default_content_type = 'text/xml; charset=UTF-8'
+    log = log
+    mapfile = config.google_test_service.mapfile
+    content_dir = config.google_test_service.canned_response_directory
+
+
+def start_as_process():
+    return testservice.start_as_process('googletestservice')
+
+
+def get_service_endpoint():
+    """Return the host and port that the service is running on."""
+    return testservice.hostpair(config.google.site)
+
+
+def service_is_available():
+    host, port = get_service_endpoint()
+    return testservice.service_is_available(host, port)
+
+
+def wait_for_service():
+    host, port = get_service_endpoint()
+    return testservice.wait_for_service(host, port)
+
+
+def kill_running_process():
+    global service_name
+    host, port = get_service_endpoint()
+    return testservice.kill_running_process(service_name, host, port)
+
+
+def main():
+    """Run the HTTP server."""
+    # Redirect our service output to a log file.
+    global log
+    ensure_directory_exists(os.path.dirname(config.google_test_service.log))
+    filelog = logging.FileHandler(config.google_test_service.log)
+    log.addHandler(filelog)
+    log.setLevel(logging.DEBUG)
+
+    # To support service shutdown we need to create a PID file that is
+    # understood by the Launchpad services framework.
+    global service_name
+    make_pidfile(service_name)
+
+    host, port = get_service_endpoint()
+    server = HTTPServer((host, port), GoogleRequestHandler)
+
+    log.info("Starting HTTP Google webservice server on port %s", port)
+    server.serve_forever()

=== modified file 'lib/lp/services/sitesearch/interfaces.py'
--- lib/lp/services/sitesearch/interfaces.py	2018-03-26 21:06:51 +0000
+++ lib/lp/services/sitesearch/interfaces.py	2018-03-28 21:29:05 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces for searching and working with results."""

=== added file 'lib/lp/services/sitesearch/tests/bingserviceharness.py'
--- lib/lp/services/sitesearch/tests/bingserviceharness.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/bingserviceharness.py	2018-03-28 21:29:05 +0000
@@ -0,0 +1,107 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""
+Fixtures for running the Bing test webservice.
+"""
+
+__metaclass__ = type
+
+__all__ = ['BingServiceTestSetup']
+
+
+import errno
+import os
+import signal
+
+from lp.services.sitesearch import bingtestservice
+
+
+class BingServiceTestSetup:
+    """Set up the Bing web service stub for use in functional tests.
+    """
+
+    # XXX gary 2008-12-06 bug=305858: Spurious test failures discovered on
+    # buildbot, builds 40 and 43. The locations of the failures are marked
+    # below with " # SPURIOUS FAILURE". To reinstate, add the text below back
+    # to the docstring above.  Note that the test that uses this setup,
+    # bing-service-stub.txt, is also disabled.  See test_doc.py.
+    """
+    >>> from lp.services.sitesearch.bingtestservice import (
+    ...     service_is_available)
+    >>> from lp.services.config import config
+
+    >>> assert not service_is_available()  # Sanity check. # SPURIOUS FAILURE
+
+    >>> BingServiceTestSetup().setUp()
+
+    After setUp is called, a Bing test service instance is running.
+
+    >>> assert service_is_available()
+    >>> assert BingServiceTestSetup.service is not None
+
+    After tearDown is called, the service is shut down.
+
+    >>> BingServiceTestSetup().tearDown()
+
+    >>> assert not service_is_available()
+    >>> assert BingServiceTestSetup.service is None
+
+    The fixture can be started and stopped multiple time in succession:
+
+    >>> BingServiceTestSetup().setUp()
+    >>> assert service_is_available()
+
+    Having a service instance already running doesn't prevent a new
+    service from starting.  The old instance is killed off and replaced
+    by the new one.
+
+    >>> old_pid = BingServiceTestSetup.service.pid
+    >>> BingServiceTestSetup().setUp() # SPURIOUS FAILURE
+    >>> BingServiceTestSetup.service.pid != old_pid
+    True
+
+    Tidy up.
+
+    >>> BingServiceTestSetup().tearDown()
+    >>> assert not service_is_available()
+
+    """
+
+    service = None  # A reference to our running service.
+
+    def setUp(self):
+        self.startService()
+
+    def tearDown(self):
+        self.stopService()
+
+    @classmethod
+    def startService(cls):
+        """Start the webservice."""
+        bingtestservice.kill_running_process()
+        cls.service = bingtestservice.start_as_process()
+        assert cls.service, "The Search service process did not start."
+        try:
+            bingtestservice.wait_for_service()
+        except RuntimeError:
+            # The service didn't start itself soon enough.  We must
+            # make sure to kill any errant processes that may be
+            # hanging around.
+            cls.stopService()
+            raise
+
+    @classmethod
+    def stopService(cls):
+        """Shut down the webservice instance."""
+        if cls.service:
+            try:
+                os.kill(cls.service.pid, signal.SIGTERM)
+            except OSError as error:
+                if error.errno != errno.ESRCH:
+                    raise
+                # The process with the given pid doesn't exist, so there's
+                # nothing to kill or wait for.
+            else:
+                cls.service.wait()
+        cls.service = None

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-bugs-1.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-bugs-1.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-bugs-1.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,384 @@
+{
+  "_type": "SearchResponse",
+  "instrumentation": {
+    "pingUrlBase": "https://www.bingapis.com/api/ping?IG=4C3A184771914F6BB81F8541E7EE7695&CID=13045103CBA569B80D475AB7CA03687C&ID=";,
+    "pageLoadPingUrl": "https://www.bingapis.com/api/ping/pageload?IG=4C3A184771914F6BB81F8541E7EE7695&CID=13045103CBA569B80D475AB7CA03687C&Type=Event.CPT&DATA=0";
+  },
+  "queryContext": {
+    "originalQuery": "bug"
+  },
+  "webPages": {
+    "webSearchUrl": "https://www.bing.com/search?q=bug";,
+    "webSearchUrlPingSuffix": "DevEx,5462.1",
+    "totalEstimatedMatches": 25,
+    "value": [
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.0";,
+        "name": "Launchpad Bugs",
+        "url": "https://launchpad.net/~ubuntu-bugs";,
+        "urlPingSuffix": "DevEx,5080.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/~ubuntu-bugs";,
+        "snippet": "The purpose of this team is to have a mailing list that receives all Ubuntu bug mail. If you wish to receive copies of all bug traffic, instead subscribe to the ...",
+        "dateLastCrawled": "2018-03-10T00:32:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.1";,
+        "name": "Bugs in Ubuntu Linux",
+        "url": "https://launchpad.net/gcleaner";,
+        "urlPingSuffix": "DevEx,5095.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/gcleaner";,
+        "snippet": "GCleaner is a beautiful and fast system cleaner for Elementary OS and Ubuntu or based. Writen in Vala, GTK+, Granite and GLib/GIO for the purpose that the users feel ...",
+        "dateLastCrawled": "2018-02-21T13:17:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.2";,
+        "name": "Bugs related to Sample Person",
+        "url": "https://help.launchpad.net/BugExpiry";,
+        "urlPingSuffix": "DevEx,5110.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://help.launchpad.net/BugExpiry";,
+        "snippet": "Want to know more about Bug Statuses? Check out our BugStatuses page. What is bug expiry? Today, projects and distributions have the option of allowing old ...",
+        "dateLastCrawled": "2018-01-13T13:58:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.3";,
+        "name": "Bug #1 in Mozilla Firefox: Firefox does not support SVG",
+        "url": "https://launchpad.net/longomatch";,
+        "urlPingSuffix": "DevEx,5125.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/longomatch";,
+        "snippet": "LongoMatch is a sports video analyse tool for coaches to assist them on making live video reports from a match. It creates a database with the most important plays of ...",
+        "dateLastCrawled": "2018-02-16T21:54:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.4";,
+        "name": "Question #232632 : Questions : OpenStack Heat",
+        "url": "https://answers.launchpad.net/heat/+question/232632";,
+        "urlPingSuffix": "DevEx,5140.1",
+        "datePublished": "2013-07-18T00:00:00.0000000",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://answers.launchpad.net/heat/+question/232632";,
+        "snippet": "Using grizzly version(2013.1.2) from heat/openstack. Failed to create stack, got error: 'NoneType' object has no attribute 'rstrip'. See below my runtime ...",
+        "dateLastCrawled": "2018-02-20T23:45:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.5";,
+        "name": "Sandpad in Launchpad",
+        "url": "https://launchpad.net/sandpad";,
+        "urlPingSuffix": "DevEx,5154.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/sandpad";,
+        "snippet": "Sandpad is an standalone wlua application for making quick scratch programs and sandboxes in Lua. It uses IUP 3 for the user interface.",
+        "dateLastCrawled": "2018-03-03T17:54:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.6";,
+        "name": "Eventum in Launchpad",
+        "url": "https://launchpad.net/eventum";,
+        "urlPingSuffix": "DevEx,5169.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/eventum";,
+        "snippet": "Eventum is a user-friendly and flexible issue tracking system that can be used by a support department to track incoming technical support requests, or by a software ...",
+        "dateLastCrawled": "2018-03-07T19:52:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.7";,
+        "name": "Inkscape 0.48.2 \"0.48.2\" - Launchpad",
+        "url": "https://launchpad.net/inkscape/+milestone/0.48.2";,
+        "urlPingSuffix": "DevEx,5182.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/inkscape/+milestone/0.48.2";,
+        "snippet": "After you've downloaded a file, you can verify its authenticity using its MD5 sum or signature. (How do I verify a download?",
+        "dateLastCrawled": "2018-02-24T19:01:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.8";,
+        "name": "Code Hosting - Launchpad tour",
+        "url": "https://launchpad.net/+tour/branch-hosting-tracking";,
+        "urlPingSuffix": "DevEx,5195.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/+tour/branch-hosting-tracking";,
+        "snippet": "Code hosting and review. Launchpad and Bazaar make it easy for anyone to get your project's code, make their own changes with full version control, and then propose ...",
+        "dateLastCrawled": "2018-03-10T23:01:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.9";,
+        "name": "gdiskdump project files : gdiskdump",
+        "url": "https://launchpad.net/gdiskdump/+download";,
+        "urlPingSuffix": "DevEx,5208.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/gdiskdump/+download";,
+        "snippet": "added advanced settings and estimated Time for process to finish. bug fixes for some languages.",
+        "dateLastCrawled": "2018-02-19T11:52:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.10";,
+        "name": "Bug #1747091 “epicsTimeGetEvent() / generalTime bug ...",
+        "url": "https://code.launchpad.net/bugs/1747091";,
+        "urlPingSuffix": "DevEx,5220.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://code.launchpad.net/bugs/1747091";,
+        "snippet": "When changes in epicsGeneralTime.c were made (fetch time provider's eventTime into a local copy) an inconsistency resulted.",
+        "dateLastCrawled": "2018-02-04T02:49:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.11";,
+        "name": "Bug #96441 “timer updates use too much CPU” : Bugs : Jokosher",
+        "url": "https://launchpad.net/jokosher/+bug/96441";,
+        "urlPingSuffix": "DevEx,5232.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/jokosher/+bug/96441";,
+        "snippet": "The information about this bug in Launchpad is automatically pulled daily from the remote bug. This information was last pulled ...",
+        "dateLastCrawled": "2018-02-17T16:53:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.12";,
+        "name": "Series 17.10 : Mahara",
+        "url": "https://launchpad.net/mahara/17.10";,
+        "urlPingSuffix": "DevEx,5247.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/mahara/17.10";,
+        "snippet": "All bugs Latest bugs reported. Bug #1752688: MariaDB fails to upgrade - unable to CAST as JSON Reported on 2018-03-01 Bug #1752442: Problems with group forums / topics",
+        "dateLastCrawled": "2018-03-12T13:48:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.13";,
+        "name": "sap in Launchpad",
+        "url": "https://launchpad.net/sap+";,
+        "urlPingSuffix": "DevEx,5260.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/sap+";,
+        "snippet": "Sap is a simple audio player written in Vala and utilizing gstreamer for audio playback and ncurses for user interaction.",
+        "dateLastCrawled": "2018-03-12T05:18:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.14";,
+        "name": "Code/BugAndBlueprintLinks - Launchpad Help",
+        "url": "https://help.launchpad.net/Code/BugAndBlueprintLinks";,
+        "urlPingSuffix": "DevEx,5275.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://help.launchpad.net/Code/BugAndBlueprintLinks";,
+        "snippet": "Linking code to bug reports and blueprints. Launchpad is much like a fancy china dinner service: you can get a great deal of use and contentment from just one or two ...",
+        "dateLastCrawled": "2018-01-26T14:46:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.15";,
+        "name": "One Hundred Papercuts in Launchpad",
+        "url": "https://launchpad.net/hundredpapercuts";,
+        "urlPingSuffix": "DevEx,5289.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/hundredpapercuts";,
+        "snippet": "Downloads. One Hundred Papercuts does not have any download files registered with Launchpad. •",
+        "dateLastCrawled": "2018-03-01T20:05:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.16";,
+        "name": "Little Software Stats in Launchpad",
+        "url": "https://launchpad.net/lilsoftstats/";,
+        "urlPingSuffix": "DevEx,5304.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/lilsoftstats";,
+        "snippet": "Little Software Stats is the first free and open source program that will allow software developers to keep track of how their software is being used.",
+        "dateLastCrawled": "2018-03-07T21:46:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.17";,
+        "name": "Take the tour - Launchpad tour",
+        "url": "https://launchpad.net/+tour";,
+        "urlPingSuffix": "DevEx,5317.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/+tour";,
+        "snippet": "Launchpad helps people to work together on free software by making it easy to share code, bug reports, translations and ideas.",
+        "dateLastCrawled": "2018-02-25T04:37:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.18";,
+        "name": "EOEC in Launchpad",
+        "url": "https://launchpad.net/eoec";,
+        "urlPingSuffix": "DevEx,5332.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/eoec";,
+        "snippet": "Barcode example published as standalone extension on 2009-01-15 After adding a number of extra features to the Barcode example of EuroOffice ... EOEC ...",
+        "dateLastCrawled": "2018-03-09T21:39:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.19";,
+        "name": "Bug #1210747 “taskdef name not specified” : Bugs : Radkarte",
+        "url": "https://launchpad.net/radkarte/+bug/1210747";,
+        "urlPingSuffix": "DevEx,5345.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/radkarte/+bug/1210747";,
+        "snippet": "Sorry for the late response, somehow the notification mail did not work. I use the \"ant-contrib\" package for the ID calculation to prepare the build-file for easy ...",
+        "dateLastCrawled": "2018-01-11T22:23:00.0000000Z",
+        "fixedPosition": false
+      }
+    ]
+  },
+  "rankingResponse": {
+    "mainline": {
+      "items": [
+        {
+          "answerType": "WebPages",
+          "resultIndex": 0,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.0";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 1,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.1";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 2,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.2";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 3,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.3";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 4,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.4";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 5,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.5";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 6,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.6";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 7,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.7";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 8,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.8";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 9,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.9";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 10,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.10";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 11,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.11";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 12,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.12";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 13,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.13";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 14,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.14";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 15,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.15";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 16,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.16";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 17,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.17";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 18,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.18";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 19,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.19";
+          }
+        }
+      ]
+    }
+  }
+}

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-bugs-2.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-bugs-2.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-bugs-2.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,151 @@
+{
+  "_type": "SearchResponse",
+  "instrumentation": {
+    "pingUrlBase": "https://www.bingapis.com/api/ping?IG=41744312F5E645D58DE6733982EDC72A&CID=222E331334AA69A5318C38A7350C683B&ID=";,
+    "pageLoadPingUrl": "https://www.bingapis.com/api/ping/pageload?IG=41744312F5E645D58DE6733982EDC72A&CID=222E331334AA69A5318C38A7350C683B&Type=Event.CPT&DATA=0";
+  },
+  "queryContext": {
+    "originalQuery": "bug"
+  },
+  "webPages": {
+    "webSearchUrl": "https://www.bing.com/search?q=bug";,
+    "webSearchUrlPingSuffix": "DevEx,5530.1",
+    "totalEstimatedMatches": 25,
+    "value": [
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.0";,
+        "name": "Bugs - Launchpad Help",
+        "url": "https://help.launchpad.net/Bugs";,
+        "urlPingSuffix": "DevEx,5103.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://help.launchpad.net/Bugs";,
+        "snippet": "Launchpad Help > Bugs . Use Launchpad's bug tracker for your project. Bug heat: a computed estimate of a bug's significance. Learn about automatic bug expiry",
+        "deepLinks": [
+          {
+            "name": "Bugs/EmailInterface",
+            "url": "https://help.launchpad.net/Bugs/EmailInterface";,
+            "urlPingSuffix": "DevEx,5093.1",
+            "snippet": "Overview. Launchpad's bug tracker sends you email about the bugs you're interested in. If you see something that requires your attention - for example, you want to ..."
+          },
+          {
+            "name": "Bugs/PluginAPISpec",
+            "url": "https://help.launchpad.net/Bugs/PluginAPISpec";,
+            "urlPingSuffix": "DevEx,5094.1",
+            "snippet": "Overview. We want Launchpad to share bug reports, comments, statuses and other information with as many bug trackers as possible. We've already produced plugins that ..."
+          },
+          {
+            "name": "Bugs/Subscriptions",
+            "url": "https://help.launchpad.net/Bugs/Subscriptions";,
+            "urlPingSuffix": "DevEx,5095.1",
+            "snippet": "Overview. Launchpad uses notification emails and Atom feeds to help you stay on top of the bugs that interest you. Bug mail. There are three ways to get bug ..."
+          },
+          {
+            "name": "Bugs/YourProject",
+            "url": "https://help.launchpad.net/Bugs/YourProject";,
+            "urlPingSuffix": "DevEx,5096.1",
+            "snippet": "Overview. Launchpad's bug tracker is unique: it can track how one bug affects different communities. When you share free software, you share its bugs."
+          },
+          {
+            "name": "Bugs/Expiry",
+            "url": "https://help.launchpad.net/Bugs/Expiry";,
+            "urlPingSuffix": "DevEx,5097.1",
+            "snippet": "This gives you three benefits: you can view a report of all bugs that are due to expiry and so deal with any that need your attention ; once the bug's expired ..."
+          }
+        ],
+        "dateLastCrawled": "2018-02-20T23:45:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.1";,
+        "name": "Of Bugs and Statuses - Launchpad Blog",
+        "url": "https://blog.launchpad.net/general/of-bugs-and-statuses";,
+        "urlPingSuffix": "DevEx,5149.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://blog.launchpad.net/general/of-bugs-and-statuses";,
+        "snippet": "This past week’s Bug Janitor thread has made it clear that we need better descriptions of what our bug statuses actually mean. While it’s true that many project ...",
+        "dateLastCrawled": "2018-03-09T07:40:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.2";,
+        "name": "Mahara 1.8.0",
+        "url": "https://launchpad.net/mahara/+milestone/1.8.0";,
+        "urlPingSuffix": "DevEx,5178.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/mahara/+milestone/1.8.0";,
+        "snippet": "Mahara 1.8.0 Release Notes. This is a stable release of Mahara 1.8. Stable releases are fit for general use. If you find a bug, please report it to the tracker:",
+        "dateLastCrawled": "2018-03-04T10:53:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.3";,
+        "name": "Mighty Box in Launchpad",
+        "url": "https://launchpad.net/mb";,
+        "urlPingSuffix": "DevEx,5193.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/mb";,
+        "snippet": "All bugs Latest bugs reported. Bug #766411: The settings dialog box is not available on Windows 7 Reported on 2011-04-19 Bug #766410: App crashes when navigating file ...",
+        "dateLastCrawled": "2018-03-10T07:57:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.4";,
+        "name": "Bug tracking - Launchpad Bugs",
+        "url": "https://launchpad.net/bugs";,
+        "urlPingSuffix": "DevEx,5208.1",
+        "about": [
+          {
+            "name": "Launchpad"
+          }
+        ],
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/bugs";,
+        "snippet": "Statistics. 1755185 bugs reported across 12480 projects including 122769 links to 2796 bug trackers; 148160 bugs are shared across multiple projects",
+        "dateLastCrawled": "2018-03-14T01:18:00.0000000Z",
+        "fixedPosition": false
+      }
+    ]
+  },
+  "rankingResponse": {
+    "mainline": {
+      "items": [
+        {
+          "answerType": "WebPages",
+          "resultIndex": 0,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.0";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 1,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.1";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 2,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.2";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 3,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.3";
+          }
+        },
+        {
+          "answerType": "WebPages",
+          "resultIndex": 4,
+          "value": {
+            "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.4";
+          }
+        }
+      ]
+    }
+  }
+}
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-error.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-error.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-error.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,20 @@
+{
+  "_type": "ErrorResponse",
+  "errors": [
+    {
+      "code": "InvalidRequest",
+      "subCode": "ParameterInvalidValue",
+      "message": "Parameter has invalid value.",
+      "parameter": "count",
+      "value": "-1"
+    },
+    {
+      "code": "InvalidRequest",
+      "subCode": "ParameterInvalidValue",
+      "message": "Parameter has invalid value.",
+      "parameter": "offset",
+      "value": "a"
+    }
+  ]
+}
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-incompatible-matches.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-incompatible-matches.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-incompatible-matches.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,8 @@
+{
+  "_type": "SearchResponse",
+  "webPages": {
+    "totalEstimatedMatches": "~25",
+    "value": []
+  }
+}
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-incomplete-response.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-incomplete-response.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-incomplete-response.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,1 @@
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-mapping.txt'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-mapping.txt	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-mapping.txt	2018-03-28 21:29:05 +0000
@@ -0,0 +1,26 @@
+# This file defines a mapping of Bing search service URLs to the JSON
+# files that should be returned by them.
+#
+# The format is 'url JSONfile'. Blank lines and lines starting with '#'
+# are ignored.
+#
+# The special URL, '*', is returned for all un-mapped URLs.
+
+* bingsearchservice-no-results.json
+
+/bingcustomsearch/v7.0/search?count=20&customConfig=1234567890&mkt=en-US&offset=0&q=bug bingsearchservice-bugs-1.json
+
+/bingcustomsearch/v7.0/search?count=20&customConfig=1234567890&mkt=en-US&offset=20&q=bug bingsearchservice-bugs-2.json
+
+/bingcustomsearch/v7.0/search?count=20&customConfig=1234567890&mkt=en-US&offset=0&q=launchpad bingsearchservice-bugs-1.json
+
+/bingcustomsearch/v7.0/search?count=20&customConfig=1234567890&mkt=en-US&offset=20&q=launchpad bingsearchservice-bugs-2.json
+
+/bingcustomsearch/v7.0/search?count=20&customConfig=1234567890&mkt=en-US&offset=0&q=gnomebaker bingsearchservice-incomplete-response.json
+
+/bingcustomsearch/v7.0/search?count=20&customConfig=1234567890&mkt=en-US&offset=0&q=no-meaningful bingsearchservice-no-meaningful-results.json
+
+/bingcustomsearch/v7.0/search?count=20&customConfig=1234567890&mkt=en-US&offset=0&q=errors-please bingsearchservice-error.json
+
+# This stub service is also used to impersonate the Blog feed
+/blog-feed blog.launchpad.net-feed.json

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-summary.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-summary.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-summary.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,30 @@
+{
+  "_type": "SearchResponse",
+  "webPages": {
+    "totalEstimatedMatches": -25,
+    "value": [
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.0";,
+        "name": "Ubuntu Bugs in Launchpad",
+        "url": "https://launchpad.net/~ubuntu-bugs";,
+        "urlPingSuffix": "DevEx,5080.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/~ubuntu-bugs";,
+        "dateLastCrawled": "2018-03-10T00:32:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.2";,
+        "name": "BugExpiry - Launchpad Help",
+        "url": "https://help.launchpad.net/BugExpiry";,
+        "urlPingSuffix": "DevEx,5110.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://help.launchpad.net/BugExpiry";,
+        "snippet": "Want to know more about Bug Statuses? Check out our BugStatuses page. What is bug expiry? Today, projects and distributions have the option of allowing old ...",
+        "dateLastCrawled": "2018-01-13T13:58:00.0000000Z",
+        "fixedPosition": false
+      }
+    ]
+  }
+}
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-title.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-title.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-title.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,30 @@
+{
+  "_type": "SearchResponse",
+  "webPages": {
+    "totalEstimatedMatches": -25,
+    "value": [
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.0";,
+        "url": "https://launchpad.net/~ubuntu-bugs";,
+        "urlPingSuffix": "DevEx,5080.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/~ubuntu-bugs";,
+        "snippet": "The purpose of this team is to have a mailing list that receives all Ubuntu bug mail. If you wish to receive copies of all bug traffic, instead subscribe to the ...",
+        "dateLastCrawled": "2018-03-10T00:32:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.1";,
+        "name": "GCleaner in Launchpad",
+        "url": "https://launchpad.net/gcleaner";,
+        "urlPingSuffix": "DevEx,5095.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/gcleaner";,
+        "snippet": "GCleaner is a beautiful and fast system cleaner for Elementary OS and Ubuntu or based. Writen in Vala, GTK+, Granite and GLib/GIO for the purpose that the users feel ...",
+        "dateLastCrawled": "2018-02-21T13:17:00.0000000Z",
+        "fixedPosition": false
+      }
+    ]
+  }
+}
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-url.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-url.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-missing-url.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,30 @@
+{
+  "_type": "SearchResponse",
+  "webPages": {
+    "totalEstimatedMatches": -25,
+    "value": [
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.0";,
+        "name": "Ubuntu Bugs in Launchpad",
+        "urlPingSuffix": "DevEx,5080.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/~ubuntu-bugs";,
+        "snippet": "The purpose of this team is to have a mailing list that receives all Ubuntu bug mail. If you wish to receive copies of all bug traffic, instead subscribe to the ...",
+        "dateLastCrawled": "2018-03-10T00:32:00.0000000Z",
+        "fixedPosition": false
+      },
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.3";,
+        "name": "LongoMatch in Launchpad",
+        "url": "https://launchpad.net/longomatch";,
+        "urlPingSuffix": "DevEx,5125.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://launchpad.net/longomatch";,
+        "snippet": "LongoMatch is a sports video analyse tool for coaches to assist them on making live video reports from a match. It creates a database with the most important plays of ...",
+        "dateLastCrawled": "2018-02-16T21:54:00.0000000Z",
+        "fixedPosition": false
+      }
+    ]
+  }
+}
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-negative-total.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-negative-total.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-negative-total.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,8 @@
+{
+  "_type": "SearchResponse",
+  "webPages": {
+    "totalEstimatedMatches": -25,
+    "value": []
+  }
+}
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-no-meaningful-results.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-no-meaningful-results.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-no-meaningful-results.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,19 @@
+{
+  "_type": "SearchResponse",
+  "webPages": {
+    "totalEstimatedMatches": 25,
+    "value": [
+      {
+        "id": "https://api.cognitive.microsoft.com/api/v7/#WebPages.2";,
+        "url": "https://help.launchpad.net/BugExpiry";,
+        "urlPingSuffix": "DevEx,5110.1",
+        "isFamilyFriendly": true,
+        "displayUrl": "https://help.launchpad.net/BugExpiry";,
+        "snippet": "Want to know more about Bug Statuses? Check out our BugStatuses page. What is bug expiry? Today, projects and distributions have the option of allowing old ...",
+        "dateLastCrawled": "2018-01-13T13:58:00.0000000Z",
+        "fixedPosition": false
+      }
+    ]
+  }
+}
+

=== added file 'lib/lp/services/sitesearch/tests/data/bingsearchservice-no-results.json'
--- lib/lp/services/sitesearch/tests/data/bingsearchservice-no-results.json	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/data/bingsearchservice-no-results.json	2018-03-28 21:29:05 +0000
@@ -0,0 +1,12 @@
+{
+  "_type": "SearchResponse",
+  "instrumentation": {
+    "pingUrlBase": "https://www.bingapis.com/api/ping?IG=74D50DC66A774E74AF486941DDF2C89C&CID=2023A1AFC6086AE52F08AA1BC7476B1F&ID=";,
+    "pageLoadPingUrl": "https://www.bingapis.com/api/ping/pageload?IG=74D50DC66A774E74AF486941DDF2C89C&CID=2023A1AFC6086AE52F08AA1BC7476B1F&Type=Event.CPT&DATA=0";
+  },
+  "queryContext": {
+    "originalQuery": "AELymgURIr4plE6V5qUaesxj1S8kUSFxCVrVLNU_OeCogh9Q7W5be6lEGMcbb0q6WTDgLL7zsnlnYGLvVrsdxgx3AamFm0M6ARaxerSLvSf-1JQHrOLuhsQ"
+  },
+  "rankingResponse": {}
+}
+

=== added file 'lib/lp/services/sitesearch/tests/test_bing.py'
--- lib/lp/services/sitesearch/tests/test_bing.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/test_bing.py	2018-03-28 21:29:05 +0000
@@ -0,0 +1,118 @@
+# Copyright 2011-2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test the bing search service."""
+
+__metaclass__ = type
+
+
+from fixtures import MockPatch
+from requests.exceptions import (
+    ConnectionError,
+    HTTPError,
+    )
+
+from lp.services.sitesearch import BingSearchService
+from lp.services.sitesearch.interfaces import SiteSearchResponseError
+from lp.services.timeout import TimeoutError
+from lp.testing import TestCase
+from lp.testing.layers import (
+    BingLaunchpadFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
+
+
+class TestBingSearchService(TestCase):
+    """Test BingSearchService."""
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestBingSearchService, self).setUp()
+        self.search_service = BingSearchService()
+
+    def test_search_converts_HTTPError(self):
+        # The method converts HTTPError to SiteSearchResponseError.
+        args = ('url', 500, 'oops', {}, None)
+        self.useFixture(MockPatch(
+            'lp.services.sitesearch.urlfetch', side_effect=HTTPError(*args)))
+        self.assertRaises(
+            SiteSearchResponseError, self.search_service.search, 'fnord')
+
+    def test_search_converts_ConnectionError(self):
+        # The method converts ConnectionError to SiteSearchResponseError.
+        self.useFixture(MockPatch(
+            'lp.services.sitesearch.urlfetch',
+            side_effect=ConnectionError('oops')))
+        self.assertRaises(
+            SiteSearchResponseError, self.search_service.search, 'fnord')
+
+    def test_search_converts_TimeoutError(self):
+        # The method converts TimeoutError to SiteSearchResponseError.
+        self.useFixture(MockPatch(
+            'lp.services.sitesearch.urlfetch',
+            side_effect=TimeoutError('oops')))
+        self.assertRaises(
+            SiteSearchResponseError, self.search_service.search, 'fnord')
+
+    def test_parse_bing_response_TypeError(self):
+        # The method converts TypeError to SiteSearchResponseError.
+        self.assertRaises(
+            SiteSearchResponseError,
+            self.search_service._parse_bing_response, None)
+
+    def test_parse_bing_response_ValueError(self):
+        # The method converts ValueError to SiteSearchResponseError.
+        self.assertRaises(
+            SiteSearchResponseError,
+            self.search_service._parse_bing_response, '')
+
+    def test_parse_bing_response_KeyError(self):
+        # The method converts KeyError to SiteSearchResponseError.
+        self.assertRaises(
+            SiteSearchResponseError,
+            self.search_service._parse_bing_response, '{}')
+
+
+class FunctionalTestBingSearchService(TestCase):
+    """Test BingSearchService."""
+
+    layer = BingLaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(FunctionalTestBingSearchService, self).setUp()
+        self.search_service = BingSearchService()
+
+    def test_search_with_results(self):
+        matches = self.search_service.search('bug')
+        self.assertEqual(0, matches.start)
+        self.assertEqual(25, matches.total)
+        self.assertEqual(20, len(matches))
+
+    def test_search_with_results_and_offset(self):
+        matches = self.search_service.search('bug', start=20)
+        self.assertEqual(20, matches.start)
+        self.assertEqual(25, matches.total)
+        self.assertEqual(5, len(matches))
+
+    def test_search_no_results(self):
+        matches = self.search_service.search('fnord')
+        self.assertEqual(0, matches.start)
+        self.assertEqual(0, matches.total)
+        self.assertEqual(0, len(matches))
+
+    def test_search_no_meaningful_results(self):
+        matches = self.search_service.search('no-meaningful')
+        self.assertEqual(0, matches.start)
+        self.assertEqual(25, matches.total)
+        self.assertEqual(0, len(matches))
+
+    def test_search_incomplete_response(self):
+        self.assertRaises(
+            SiteSearchResponseError,
+            self.search_service.search, 'gnomebaker')
+
+    def test_search_error_response(self):
+        self.assertRaises(
+            SiteSearchResponseError,
+            self.search_service.search, 'errors-please')

=== added file 'lib/lp/services/sitesearch/tests/test_bingharness.py'
--- lib/lp/services/sitesearch/tests/test_bingharness.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/test_bingharness.py	2018-03-28 21:29:05 +0000
@@ -0,0 +1,10 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+import doctest
+
+
+def test_suite():
+    return doctest.DocTestSuite(
+            'lp.services.sitesearch.tests.bingserviceharness',
+            optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS)

=== added file 'lib/lp/services/sitesearch/tests/test_bingservice.py'
--- lib/lp/services/sitesearch/tests/test_bingservice.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/sitesearch/tests/test_bingservice.py	2018-03-28 21:29:05 +0000
@@ -0,0 +1,38 @@
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""
+Unit tests for the Bing test service stub.
+"""
+
+__metaclass__ = type
+
+
+import os
+import unittest
+
+from lp.services.osutils import process_exists
+from lp.services.pidfile import pidfile_path
+from lp.services.sitesearch import bingtestservice
+
+
+class TestServiceUtilities(unittest.TestCase):
+    """Test the service's supporting functions."""
+
+    def test_stale_pid_file_cleanup(self):
+        """The service should be able to clean up invalid PID files."""
+        bogus_pid = 9999999
+        self.assertFalse(
+            process_exists(bogus_pid),
+            "There is already a process with PID '%d'." % bogus_pid)
+
+        # Create a stale/bogus PID file.
+        filepath = pidfile_path(bingtestservice.service_name)
+        with file(filepath, 'w') as pidfile:
+            pidfile.write(str(bogus_pid))
+
+        # The PID clean-up code should silently remove the file and return.
+        bingtestservice.kill_running_process()
+        self.assertFalse(
+            os.path.exists(filepath),
+            "The PID file '%s' should have been deleted." % filepath)

=== modified file 'lib/lp/services/sitesearch/tests/test_doc.py'
--- lib/lp/services/sitesearch/tests/test_doc.py	2012-01-01 02:58:52 +0000
+++ lib/lp/services/sitesearch/tests/test_doc.py	2018-03-28 21:29:05 +0000
@@ -9,6 +9,7 @@
 
 from lp.services.testing import build_test_suite
 from lp.testing.layers import (
+    BingLaunchpadFunctionalLayer,
     DatabaseFunctionalLayer,
     GoogleLaunchpadFunctionalLayer,
     )
@@ -23,6 +24,10 @@
 
 
 special = {
+    'bing-searchservice.txt': LayeredDocFileSuite(
+        '../doc/bing-searchservice.txt',
+        setUp=setUp, tearDown=tearDown,
+        layer=BingLaunchpadFunctionalLayer,),
     'google-searchservice.txt': LayeredDocFileSuite(
         '../doc/google-searchservice.txt',
         setUp=setUp, tearDown=tearDown,

=== modified file 'lib/lp/services/sitesearch/tests/test_google.py'
--- lib/lp/services/sitesearch/tests/test_google.py	2018-03-26 21:06:51 +0000
+++ lib/lp/services/sitesearch/tests/test_google.py	2018-03-28 21:29:05 +0000
@@ -5,8 +5,8 @@
 
 __metaclass__ = type
 
-from contextlib import contextmanager
 
+from fixtures import MockPatch
 from requests.exceptions import (
     ConnectionError,
     HTTPError,
@@ -19,26 +19,6 @@
 from lp.testing.layers import LaunchpadFunctionalLayer
 
 
-@contextmanager
-def urlfetch_exception(test_error, *args):
-    """Raise an error during the execution of urlfetch.
-
-    This function replaces urlfetch() with a function that
-    raises an error.
-    """
-
-    def raise_exception(url):
-        raise test_error(*args)
-
-    from lp.services import timeout
-    original_urlfetch = timeout.urlfetch
-    timeout.urlfetch = raise_exception
-    try:
-        yield
-    finally:
-        timeout.urlfetch = original_urlfetch
-
-
 class TestGoogleSearchService(TestCase):
     """Test GoogleSearchService."""
 
@@ -51,35 +31,43 @@
     def test_search_converts_HTTPError(self):
         # The method converts HTTPError to SiteSearchResponseError.
         args = ('url', 500, 'oops', {}, None)
-        with urlfetch_exception(HTTPError, *args):
-            self.assertRaises(
-                SiteSearchResponseError, self.search_service.search, 'fnord')
+        self.useFixture(MockPatch(
+            'lp.services.sitesearch.urlfetch', side_effect=HTTPError(*args)))
+        self.assertRaises(
+            SiteSearchResponseError, self.search_service.search, 'fnord')
 
     def test_search_converts_ConnectionError(self):
         # The method converts ConnectionError to SiteSearchResponseError.
-        with urlfetch_exception(ConnectionError, 'oops'):
-            self.assertRaises(
-                SiteSearchResponseError, self.search_service.search, 'fnord')
+        self.useFixture(MockPatch(
+            'lp.services.sitesearch.urlfetch',
+            side_effect=ConnectionError('oops')))
+        self.assertRaises(
+            SiteSearchResponseError, self.search_service.search, 'fnord')
 
     def test_search_converts_TimeoutError(self):
         # The method converts TimeoutError to SiteSearchResponseError.
-        with urlfetch_exception(TimeoutError, 'oops'):
-            self.assertRaises(
-                SiteSearchResponseError, self.search_service.search, 'fnord')
+        self.useFixture(MockPatch(
+            'lp.services.sitesearch.urlfetch',
+            side_effect=TimeoutError('oops')))
+        self.assertRaises(
+            SiteSearchResponseError, self.search_service.search, 'fnord')
 
     def test___parse_google_search_protocol_SyntaxError(self):
         # The method converts SyntaxError to SiteSearchResponseError.
-        with urlfetch_exception(SyntaxError, 'oops'):
-            self.assertRaises(
-                SiteSearchResponseError,
-                self.search_service._parse_google_search_protocol, '')
+        self.useFixture(MockPatch(
+            'lp.services.sitesearch.urlfetch',
+            side_effect=SyntaxError('oops')))
+        self.assertRaises(
+            SiteSearchResponseError,
+            self.search_service._parse_google_search_protocol, '')
 
     def test___parse_google_search_protocol_IndexError(self):
         # The method converts IndexError to SiteSearchResponseError.
-        with urlfetch_exception(IndexError, 'oops'):
-            data = (
-                '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'
-                '<GSP VER="3.2"></GSP>')
-            self.assertRaises(
-                SiteSearchResponseError,
-                self.search_service._parse_google_search_protocol, data)
+        self.useFixture(MockPatch(
+            'lp.services.sitesearch.urlfetch', side_effect=IndexError('oops')))
+        data = (
+            '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'
+            '<GSP VER="3.2"></GSP>')
+        self.assertRaises(
+            SiteSearchResponseError,
+            self.search_service._parse_google_search_protocol, data)

=== renamed file 'lib/lp/services/sitesearch/googletestservice.py' => 'lib/lp/services/sitesearch/testservice.py'
--- lib/lp/services/sitesearch/googletestservice.py	2018-03-26 21:22:03 +0000
+++ lib/lp/services/sitesearch/testservice.py	2018-03-28 21:29:05 +0000
@@ -1,54 +1,37 @@
 #!/usr/bin/python
 #
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """
-This script runs a simple HTTP server. The server returns XML files
+This script runs a simple HTTP server. The server returns files
 when given certain user-configurable URLs.
 """
 
 
 import errno
-import logging
 import os
 import signal
 import socket
 import subprocess
 import time
 
-from six.moves.BaseHTTPServer import (
-    BaseHTTPRequestHandler,
-    HTTPServer,
-    )
+from six.moves.BaseHTTPServer import BaseHTTPRequestHandler
 
-from lp.services.config import config
-from lp.services.osutils import (
-    ensure_directory_exists,
-    remove_if_exists,
-    )
+from lp.services.osutils import remove_if_exists
 from lp.services.pidfile import (
     get_pid,
-    make_pidfile,
     pidfile_path,
     )
 from lp.services.webapp.url import urlsplit
 
-# Set up basic logging.
-log = logging.getLogger(__name__)
-
-# The default service name, used by the Launchpad service framework.
-service_name = 'google-webservice'
-
-
-class GoogleRequestHandler(BaseHTTPRequestHandler):
-    """Return an XML file depending on the requested URL."""
-
-    default_content_type = 'text/xml; charset=UTF-8'
+
+class RequestHandler(BaseHTTPRequestHandler):
+    """Return a file depending on the requested URL."""
 
     def do_GET(self):
         """See BaseHTTPRequestHandler in the Python Standard Library."""
-        urlmap = url_to_xml_map()
+        urlmap = url_to_file_map(self.mapfile)
         if self.path in urlmap:
             self.return_file(urlmap[self.path])
         else:
@@ -65,8 +48,7 @@
         self.send_header('Content-Type', self.default_content_type)
         self.end_headers()
 
-        content_dir = config.google_test_service.canned_response_directory
-        filepath = os.path.join(content_dir, filename)
+        filepath = os.path.join(self.content_dir, filename)
         content_body = file(filepath).read()
         self.wfile.write(content_body)
 
@@ -78,12 +60,11 @@
                    (self.address_string(),
                     self.log_date_time_string(),
                     format % args))
-        log.info(message)
-
-
-def url_to_xml_map():
-    """Return our URL-to-XML mapping as a dictionary."""
-    mapfile = config.google_test_service.mapfile
+        self.log.info(message)
+
+
+def url_to_file_map(mapfile):
+    """Return our URL-to-file mapping as a dictionary."""
     mapping = {}
     for line in file(mapfile):
         if line.startswith('#') or len(line.strip()) == 0:
@@ -95,12 +76,7 @@
     return mapping
 
 
-def get_service_endpoint():
-    """Return the host and port that the service is running on."""
-    return hostpair(config.google.site)
-
-
-def service_is_available(timeout=2.0):
+def service_is_available(host, port, timeout=2.0):
     """Return True if the service is up and running.
 
     :param timeout: BLOCK execution for at most 'timeout' seconds
@@ -108,7 +84,6 @@
     """
     sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
     sock.settimeout(timeout)  # Block for 'timeout' seconds.
-    host, port = get_service_endpoint()
     try:
         try:
             sock.connect((host, port))
@@ -120,14 +95,13 @@
         sock.close()  # Clean up.
 
 
-def wait_for_service(timeout=15.0):
+def wait_for_service(host, port, timeout=15.0):
     """Poll the service and BLOCK until we can connect to it.
 
     :param timeout: The socket should timeout after this many seconds.
         Refer to the socket module documentation in the Standard Library
         for possible timeout values.
     """
-    host, port = get_service_endpoint()
     sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
     sock.settimeout(timeout)  # Block for at most X seconds.
 
@@ -150,7 +124,7 @@
         sock.close()  # Clean up.
 
 
-def wait_for_service_shutdown(seconds_to_wait=10.0):
+def wait_for_service_shutdown(host, port, seconds_to_wait=10.0):
     """Poll the service until it shuts down.
 
     Raises a RuntimeError if the service doesn't shut down within the allotted
@@ -160,8 +134,6 @@
     :param seconds_to_wait: The number of seconds to wait for the socket to
         open up.
     """
-    host, port = get_service_endpoint()
-
     start = time.time()  # Record when we started polling.
     try:
         while True:
@@ -194,7 +166,7 @@
     return (host, port)
 
 
-def start_as_process():
+def start_as_process(service_binary_name):
     """Run this file as a stand-alone Python script.
 
     Returns a subprocess.Popen object. (See the `subprocess` module in
@@ -203,7 +175,7 @@
     script = os.path.join(
         os.path.dirname(__file__),
         os.pardir, os.pardir, os.pardir, os.pardir, 'bin',
-        'googletestservice')
+        service_binary_name)
     # Make sure we aren't using the parent stdin and stdout to avoid spam
     # and have fewer things that can go wrong shutting down the process.
     proc = subprocess.Popen(
@@ -213,9 +185,8 @@
     return proc
 
 
-def kill_running_process():
+def kill_running_process(service_name, host, port):
     """Find and kill any running web service processes."""
-    global service_name
     try:
         pid = get_pid(service_name)
     except IOError:
@@ -233,7 +204,7 @@
                 # becomes available.  Failing to do so causes a race condition
                 # between freeing the socket in the killed process, and
                 # opening it in the current one.
-                wait_for_service_shutdown()
+                wait_for_service_shutdown(host, port)
             except os.error as err:
                 if err.errno == errno.ESRCH:
                     # Whoops, we got a 'No such process' error. The PID file
@@ -243,24 +214,3 @@
                     remove_if_exists(pidfile_path(service_name))
                 else:
                     raise
-
-
-def main():
-    """Run the HTTP server."""
-    # Redirect our service output to a log file.
-    global log
-    ensure_directory_exists(os.path.dirname(config.google_test_service.log))
-    filelog = logging.FileHandler(config.google_test_service.log)
-    log.addHandler(filelog)
-    log.setLevel(logging.DEBUG)
-
-    # To support service shutdown we need to create a PID file that is
-    # understood by the Launchpad services framework.
-    global service_name
-    make_pidfile(service_name)
-
-    host, port = get_service_endpoint()
-    server = HTTPServer((host, port), GoogleRequestHandler)
-
-    log.info("Starting HTTP Google webservice server on port %s", port)
-    server.serve_forever()

=== modified file 'lib/lp/testing/layers.py'
--- lib/lp/testing/layers.py	2018-03-16 14:55:41 +0000
+++ lib/lp/testing/layers.py	2018-03-28 21:29:05 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Layers used by Launchpad tests.
@@ -23,6 +23,8 @@
     'AppServerLayer',
     'AuditorLayer',
     'BaseLayer',
+    'BingLaunchpadFunctionalLayer',
+    'BingServiceLayer',
     'DatabaseFunctionalLayer',
     'DatabaseLayer',
     'FunctionalLayer',
@@ -126,6 +128,9 @@
 from lp.services.osutils import kill_by_pidfile
 from lp.services.rabbit.server import RabbitServer
 from lp.services.scripts import execute_zcml_for_scripts
+from lp.services.sitesearch.tests.bingserviceharness import (
+    BingServiceTestSetup,
+    )
 from lp.services.sitesearch.tests.googleserviceharness import (
     GoogleServiceTestSetup,
     )
@@ -1259,6 +1264,31 @@
         pass
 
 
+class BingServiceLayer(BaseLayer):
+    """Tests for Bing web service integration."""
+
+    @classmethod
+    def setUp(cls):
+        bing = BingServiceTestSetup()
+        bing.setUp()
+
+    @classmethod
+    def tearDown(cls):
+        BingServiceTestSetup().tearDown()
+
+    @classmethod
+    def testSetUp(self):
+        # We need to override BaseLayer.testSetUp(), or else we will
+        # get a LayerIsolationError.
+        pass
+
+    @classmethod
+    def testTearDown(self):
+        # We need to override BaseLayer.testTearDown(), or else we will
+        # get a LayerIsolationError.
+        pass
+
+
 class DatabaseFunctionalLayer(DatabaseLayer, FunctionalLayer):
     """Provides the database and the Zope3 application server environment."""
 
@@ -1383,6 +1413,31 @@
         pass
 
 
+class BingLaunchpadFunctionalLayer(LaunchpadFunctionalLayer,
+                                   BingServiceLayer):
+    """Provides Bing service in addition to LaunchpadFunctionalLayer."""
+
+    @classmethod
+    @profiled
+    def setUp(cls):
+        pass
+
+    @classmethod
+    @profiled
+    def tearDown(cls):
+        pass
+
+    @classmethod
+    @profiled
+    def testSetUp(cls):
+        pass
+
+    @classmethod
+    @profiled
+    def testTearDown(cls):
+        pass
+
+
 class ZopelessDatabaseLayer(ZopelessLayer, DatabaseLayer):
     """Testing layer for unit tests with no need for librarian.
 
@@ -1541,7 +1596,8 @@
         return self.request._orig_env
 
 
-class PageTestLayer(LaunchpadFunctionalLayer, GoogleServiceLayer):
+class PageTestLayer(LaunchpadFunctionalLayer,
+                    BingServiceLayer, GoogleServiceLayer):
     """Environment for page tests.
     """
 

=== modified file 'setup.py'
--- setup.py	2018-03-16 15:14:34 +0000
+++ setup.py	2018-03-28 21:29:05 +0000
@@ -284,6 +284,8 @@
     },
     entry_points=dict(
         console_scripts=[  # `console_scripts` is a magic name to setuptools
+            'bingtestservice = '
+                'lp.services.sitesearch.bingtestservice:main',
             'build-twisted-plugin-cache = '
                 'lp.services.twistedsupport.plugincache:main',
             'combine-css = lp.scripts.utilities.js.combinecss:main',


Follow ups