Merge lp:~wallyworld/launchpad/codehost-error-trackback-leak into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Ian Booth
Approved revision: no longer in the source branch.
Merged at revision: 12079
Proposed branch: lp:~wallyworld/launchpad/codehost-error-trackback-leak
Merge into: lp:launchpad
Diff against target: 367 lines (+183/-11)
4 files modified
lib/canonical/launchpad/xmlrpc/faults.py (+17/-2)
lib/lp/codehosting/puller/tests/test_scheduler.py (+0/-1)
lib/lp/codehosting/vfs/branchfs.py (+51/-7)
lib/lp/codehosting/vfs/tests/test_branchfs.py (+115/-1)
To merge this branch: bzr merge lp:~wallyworld/launchpad/codehost-error-trackback-leak
Reviewer Review Type Date Requested Status
Tim Penhey (community) Approve
Graham Binns (community) code Approve
Review via email: mp+41685@code.launchpad.net

Commit message

[r=gmb,thumper][ui=none][bug=675517] Display user friendly message on stderr instead of traceback when code hosting error occurs when pushing a branch

Description of the change

= Summary =

Bugs 674305 and 675517 relate to a situation whereby the xmlrpc server was misbehaving and bzr users who were trying to push or commit branches saw tracebacks printed on their consoles. This branch is to stop the traceback from being printed in front of the user - instead they get a message with an oops id.

= Implementation =

The problem was that the branchChanged() method on class LaunchpadServer merely added Twisted's log.err as an error callback to the relevant deferred instance:

return deferred.addCallback(got_path_info).addErrback(log.err)

log.err writes traceback info etc to stderr which I believe the smart server just catches and passes back to the user.
***
Actually, the above line was true when this branch was created. Creating this merge proposal resulted in conflicts in the diff and so after merging from trunk it is apparent there have been changes to Twisted infrastructure. log.err no longer outputs to stderr (when running the tests) so an explicit write to stderr has been added as well as doing the logging via log.err. I assume this changed behaviour of log.err will also be reflected in production.
***

So, what I've done is:

- create a new OopsOccurred(LaunchpadFault) class in canonical.launchpad.xmlrpc.faults
- implement a new error handler which:
  * creates an oops
  * creates and returns an OopsOccurred instance
  * use log.err and also write to stderr to record repr(OopsOccurred) so the user sees a nicer error message

The new oops logged by LaunchpadServer is the one whose oopid is reported to the user. The error report for this oops will contain a traceback and error message from the root cause xmlrpc error.

There are also a number of drive by lint fixes, mainly inserting newlines above method defs.

= Tests =

A new test class was added to TestBranchChangedErrorHandling. The setup for this class installs a replacement codehosting_api.branchChanged endpoint which generates an oops and raises an xmlrpc fault. The test captures stderr and ensures the expected user friendly text is output. It also checks the contents of the error report to ensure the expected oopsid is included.

  bin/test -vvt test_branchfs

= Lint =

Linting changed files:
  lib/canonical/launchpad/xmlrpc/faults.py
  lib/lp/codehosting/vfs/branchfs.py
  lib/lp/codehosting/vfs/tests/test_branchfs.py

./lib/lp/codehosting/vfs/tests/test_branchfs.py
    1168: E501 line too long (81 characters)
    1168: Line exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :
Download full text (4.1 KiB)

Hi Ian,

A couple of comments but nothing major. We agreed on IRC that you'd
request a review from Thumper since I don't have enough domain knowledge
to judge whether your fix is the best way to do things.

> === modified file 'lib/canonical/launchpad/xmlrpc/faults.py'
> --- lib/canonical/launchpad/xmlrpc/faults.py 2010-07-02 12:10:21 +0000
> +++ lib/canonical/launchpad/xmlrpc/faults.py 2010-11-27 05:11:16 +0000
> @@ -461,3 +462,15 @@
>
> def __init__(self, openid_identifier):
> LaunchpadFault.__init__(self, openid_identifier=openid_identifier)
> +
> +
> +class OopsOccurred(LaunchpadFault):
> + """An oops has occurred performing the requested operation."""
> +
> + error_code = 380
> + msg_template = ('An unexpected error has occurred while %(server_op)s. '
> + 'Please report a Launchpad bug and quote: %(oopsid)s.')

Multi-line strings should start on the line after the declaration and by
indented by 4 spaces:

    msg_template = (
        'An unexpected error has occurred while %(server_op)s. '
        'Please report a Launchpad bug and quote: %(oopsid)s.')

> === modified file 'lib/lp/codehosting/vfs/branchfs.py'
> --- lib/lp/codehosting/vfs/branchfs.py 2010-09-22 18:32:22 +0000
> +++ lib/lp/codehosting/vfs/branchfs.py 2010-11-27 05:11:16 +0000
> @@ -141,7 +146,7 @@
> # directories that Bazaar creates as part of regular operation. We support
> # only two numbered backups to avoid indefinite space usage.
> ALLOWED_DIRECTORIES = ('.bzr', '.bzr.backup', 'backup.bzr', 'backup.bzr.~1~',
> - 'backup.bzr.~2~')
> + 'backup.bzr.~2~')

A nice tidy-up here would be to have each element in the tuple on its
own line, as we do with lists and dicts. The closing paren should go on
a separate line and the last element should be followed by a comma.

> FORBIDDEN_DIRECTORY_ERROR = (
> "Cannot create '%s'. Only Bazaar branches are allowed.")
>
> @@ -500,10 +505,12 @@
> # Launchpad branch.
> deferred = AsyncVirtualTransport._getUnderylingTransportAndPath(
> self, relpath)
> +
> def maybe_make_branch_in_db(failure):
> # Looks like we are trying to make a branch.
> failure.trap(NoSuchFile)
> return self.server.createBranch(self._abspath(relpath))
> +
> def real_mkdir((transport, path)):
> return getattr(transport, 'mkdir')(path, mode)
>
> @@ -687,10 +694,42 @@
> data['id'], stacked_on_url, last_revision,
> control_string, branch_string, repository_string)
>
> - # It gets really confusing if we raise an exception from this method
> - # (the branch remains locked, but this isn't obvious to the client) so
> - # just log the error, which will result in an OOPS being logged.
> - return deferred.addCallback(got_path_info).addErrback(log.err)
> + def handle_error(failure=None, **kw):
> + # It gets really confusing if we raise an exception from this
> + # method (the branch remains locked, but this isn't obvious to
> + # the client). We could just log the failure using Twisted's
> + # log.err but thi...

Read more...

review: Approve (code)
Revision history for this message
Tim Penhey (thumper) wrote :

+1 on Graham's comments.

Have you tried this interactively to see what you get?

I'm wondering if we really need the print to sys.stderr. I'm not sure
though.

I found the variable name 'tb' a little jarring. It caused me to pause to
work out what it might be. Longer more explicit names are good. Perhaps
'traceback'?

Someone told me we had a monkeypatch helper method that added the cleanup
code. If that isn't the case, you could still to the cleanup with addCleanup
rather than adding a tearDown method.

  # Before changing the stderr, do this.
  self.addCleanup(setattr, sys, 'stderr', sys.stderr)

The test class has self.oopses that records oopses generated by the global
error utility, and should be used insetad of getLastOopsReport().
getLastOoopsReport can fail in unexpected ways if the oops was generated on
one side of midnight, and the method is called just after midnight. This was
a cause of quite some pain and debugging, and is the reason that the testcase
has self.oopses and records them based on generated events.

review: Needs Fixing
Revision history for this message
Robert Collins (lifeless) wrote :

On Mon, Dec 6, 2010 at 5:41 PM, Tim Penhey <email address hidden> wrote:
> Someone told me we had a monkeypatch helper method that added the cleanup
> code.  If that isn't the case, you could still to the cleanup with addCleanup
> rather than adding a tearDown method.

self.patch()

Revision history for this message
Ian Booth (wallyworld) wrote :

On 06/12/10 14:41, Tim Penhey wrote:
> Review: Needs Fixing
> +1 on Graham's comments.
>
> Have you tried this interactively to see what you get?
>

Yes. But that was before the changes to trunk (see below). I can re-do
the interactive test to double check its behaviour.

> I'm wondering if we really need the print to sys.stderr. I'm not sure
> though.
>

I didn't need the explicit print to stderr when the branch was started.
Then some changes were made in trunk to the twisted infrastructure and
log.err no longer echoed to stderr after trunk was merged into the branch.

> Someone told me we had a monkeypatch helper method that added the cleanup
> code. If that isn't the case, you could still to the cleanup with addCleanup
> rather than adding a tearDown method.
>

The implementation used mirrors what is done already in the current
tests for this part of the code. If I am to change the implementation as
per the review recommendations, the other existing tests should also be
changed to maintain consistency. I thought it best to use code similar
to what was already there rather than change unrelated code and increase
the diff size etc. I'll look into making the required changes though.

> # Before changing the stderr, do this.
> self.addCleanup(setattr, sys, 'stderr', sys.stderr)
>
> The test class has self.oopses that records oopses generated by the global
> error utility, and should be used insetad of getLastOopsReport().
> getLastOoopsReport can fail in unexpected ways if the oops was generated on
> one side of midnight, and the method is called just after midnight. This was
> a cause of quite some pain and debugging, and is the reason that the testcase
> has self.oopses and records them based on generated events.
>

Ok. I'll look at this approach. Thanks.
I'm still concerned about the need to generate a 2nd oops but can't see
another way around it. I'd be happy to be shown a better approach.

Revision history for this message
Robert Collins (lifeless) wrote :

On Tue, Dec 7, 2010 at 3:26 AM, Ian Booth <email address hidden> wrote:
>> Someone told me we had a monkeypatch helper method that added the cleanup
>> code.  If that isn't the case, you could still to the cleanup with addCleanup
>> rather than adding a tearDown method.
>>
>
> The implementation used mirrors what is done already in the current
> tests for this part of the code. If I am to change the implementation as
> per the review recommendations, the other existing tests should also be
> changed to maintain consistency. I thought it best to use code similar
> to what was already there rather than change unrelated code and increase
> the diff size etc. I'll look into making the required changes though.

The other tests are merely aggregated - there's no interaction between
two different test classes that makes consistency valuable here.

IMNSHOly-yrs,
Rob

Revision history for this message
Ian Booth (wallyworld) wrote :

On 06/12/10 14:41, Tim Penhey wrote:
> Review: Needs Fixing
> +1 on Graham's comments.

Done

>
> I found the variable name 'tb' a little jarring. It caused me to pause to
> work out what it might be. Longer more explicit names are good. Perhaps
> 'traceback'?
>

Done

> Someone told me we had a monkeypatch helper method that added the cleanup
> code. If that isn't the case, you could still to the cleanup with addCleanup
> rather than adding a tearDown method.
>
> # Before changing the stderr, do this.
> self.addCleanup(setattr, sys, 'stderr', sys.stderr)
>

Done

> The test class has self.oopses that records oopses generated by the global
> error utility, and should be used insetad of getLastOopsReport().
> getLastOoopsReport can fail in unexpected ways if the oops was generated on
> one side of midnight, and the method is called just after midnight. This was
> a cause of quite some pain and debugging, and is the reason that the testcase
> has self.oopses and records them based on generated events.
>

Done

Revision history for this message
Tim Penhey (thumper) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/xmlrpc/faults.py'
2--- lib/canonical/launchpad/xmlrpc/faults.py 2010-07-02 12:10:21 +0000
3+++ lib/canonical/launchpad/xmlrpc/faults.py 2010-12-15 05:43:15 +0000
4@@ -22,6 +22,7 @@
5 'InvalidBranchUniqueName',
6 'InvalidProductIdentifier',
7 'InvalidBranchUrl',
8+ 'OopsOccurred',
9 'NoBranchWithID',
10 'NoLinkedBranch',
11 'NoSuchBranch',
12@@ -456,8 +457,22 @@
13 """Raised by `ISoftwareCenterAgentAPI` when an account is suspended."""
14
15 error_code = 370
16- msg_template = ('The openid_identifier \'%(openid_identifier)s\''
17- ' is linked to a suspended account.')
18+ msg_template = (
19+ 'The openid_identifier \'%(openid_identifier)s\''
20+ ' is linked to a suspended account.')
21
22 def __init__(self, openid_identifier):
23 LaunchpadFault.__init__(self, openid_identifier=openid_identifier)
24+
25+
26+class OopsOccurred(LaunchpadFault):
27+ """An oops has occurred performing the requested operation."""
28+
29+ error_code = 380
30+ msg_template = (
31+ 'An unexpected error has occurred while %(server_op)s. '
32+ 'Please report a Launchpad bug and quote: %(oopsid)s.')
33+
34+ def __init__(self, server_op, oopsid):
35+ LaunchpadFault.__init__(self, server_op=server_op, oopsid=oopsid)
36+ self.oopsid = oopsid
37
38=== modified file 'lib/lp/codehosting/puller/tests/test_scheduler.py'
39--- lib/lp/codehosting/puller/tests/test_scheduler.py 2010-10-30 22:00:45 +0000
40+++ lib/lp/codehosting/puller/tests/test_scheduler.py 2010-12-15 05:43:15 +0000
41@@ -257,7 +257,6 @@
42 def setUp(self):
43 self.listener = self.StubPullerListener()
44 super(TestPullerMonitorProtocol, self).setUp()
45- TestCase.setUp(self)
46
47 def assertProtocolSuccess(self):
48 """Assert that the protocol saw no unexpected errors."""
49
50=== modified file 'lib/lp/codehosting/vfs/branchfs.py'
51--- lib/lp/codehosting/vfs/branchfs.py 2010-09-22 18:32:22 +0000
52+++ lib/lp/codehosting/vfs/branchfs.py 2010-12-15 05:43:15 +0000
53@@ -61,6 +61,7 @@
54 'LaunchpadServer',
55 ]
56
57+import sys
58 import xmlrpclib
59
60 from bzrlib import urlutils
61@@ -80,7 +81,10 @@
62 from bzrlib.transport import get_transport
63 from bzrlib.transport.memory import MemoryServer
64 from lazr.uri import URI
65-from twisted.internet import defer
66+from twisted.internet import (
67+ defer,
68+ error,
69+ )
70 from twisted.python import (
71 failure,
72 log,
73@@ -92,6 +96,7 @@
74 )
75
76 from canonical.config import config
77+from canonical.launchpad.webapp import errorlog
78 from canonical.launchpad.xmlrpc import faults
79 from lp.code.enums import BranchType
80 from lp.code.interfaces.branchlookup import IBranchLookup
81@@ -140,8 +145,13 @@
82 # The directories allowed directly beneath a branch directory. These are the
83 # directories that Bazaar creates as part of regular operation. We support
84 # only two numbered backups to avoid indefinite space usage.
85-ALLOWED_DIRECTORIES = ('.bzr', '.bzr.backup', 'backup.bzr', 'backup.bzr.~1~',
86- 'backup.bzr.~2~')
87+ALLOWED_DIRECTORIES = (
88+ '.bzr',
89+ '.bzr.backup',
90+ 'backup.bzr',
91+ 'backup.bzr.~1~',
92+ 'backup.bzr.~2~',
93+ )
94 FORBIDDEN_DIRECTORY_ERROR = (
95 "Cannot create '%s'. Only Bazaar branches are allowed.")
96
97@@ -500,10 +510,12 @@
98 # Launchpad branch.
99 deferred = AsyncVirtualTransport._getUnderylingTransportAndPath(
100 self, relpath)
101+
102 def maybe_make_branch_in_db(failure):
103 # Looks like we are trying to make a branch.
104 failure.trap(NoSuchFile)
105 return self.server.createBranch(self._abspath(relpath))
106+
107 def real_mkdir((transport, path)):
108 return getattr(transport, 'mkdir')(path, mode)
109
110@@ -687,10 +699,42 @@
111 data['id'], stacked_on_url, last_revision,
112 control_string, branch_string, repository_string)
113
114- # It gets really confusing if we raise an exception from this method
115- # (the branch remains locked, but this isn't obvious to the client) so
116- # just log the error, which will result in an OOPS being logged.
117- return deferred.addCallback(got_path_info).addErrback(log.err)
118+ def handle_error(failure=None, **kw):
119+ # It gets really confusing if we raise an exception from this
120+ # method (the branch remains locked, but this isn't obvious to
121+ # the client). We could just log the failure using Twisted's
122+ # log.err but this results in text containing traceback
123+ # information etc being written to stderr. Since stderr is
124+ # displayed to the user, if for example they arrive at this point
125+ # via the smart server, we want to ensure that the message is
126+ # sanitised. So what we will do is raise an oops and ask the user
127+ # to log a bug with the oops information.
128+ # See bugs 674305 and 675517 for details.
129+
130+ request = errorlog.ScriptRequest([
131+ ('source', virtual_url_fragment),
132+ ('error-explanation', failure.getErrorMessage())])
133+ self.unexpectedError(failure, request)
134+ fault = faults.OopsOccurred(
135+ "updating a Launchpad branch", request.oopsid)
136+ # Twisted's log.err used to write to stderr but it doesn't now so
137+ # we will write to stderr as well as log.err.
138+ print >> sys.stderr, repr(fault)
139+ log.err(repr(fault))
140+ return fault
141+ return deferred.addCallback(got_path_info).addErrback(handle_error)
142+
143+ def unexpectedError(self, failure, request=None, now=None):
144+ # If the sub-process exited abnormally, the stderr it produced is
145+ # probably a much more interesting traceback than the one attached to
146+ # the Failure we've been passed.
147+ traceback = None
148+ if failure.check(error.ProcessTerminated):
149+ traceback = getattr(failure, 'error', None)
150+ if traceback is None:
151+ traceback = failure.getTraceback()
152+ errorlog.globalErrorUtility.raising(
153+ (failure.type, failure.value, traceback), request, now)
154
155
156 def get_lp_server(user_id, codehosting_endpoint_url=None, branch_url=None,
157
158=== modified file 'lib/lp/codehosting/vfs/tests/test_branchfs.py'
159--- lib/lp/codehosting/vfs/tests/test_branchfs.py 2010-10-30 22:53:08 +0000
160+++ lib/lp/codehosting/vfs/tests/test_branchfs.py 2010-12-15 05:43:15 +0000
161@@ -7,7 +7,12 @@
162
163 __metaclass__ = type
164
165+import codecs
166 import os
167+import re
168+import sys
169+import xmlrpclib
170+from StringIO import StringIO
171
172 from bzrlib import errors
173 from bzrlib.bzrdir import (
174@@ -43,6 +48,8 @@
175
176 from twisted.internet import defer
177
178+from canonical.launchpad.webapp import errorlog
179+from canonical.launchpad.webapp.errorlog import ErrorReportingUtility
180 from canonical.testing.layers import (
181 ZopelessDatabaseLayer,
182 )
183@@ -68,6 +75,7 @@
184 UnknownTransportType,
185 )
186 from lp.codehosting.vfs.transport import AsyncVirtualTransport
187+from lp.services.job.runner import TimeoutError
188 from lp.testing import (
189 TestCase,
190 TestCaseWithFactory,
191@@ -255,6 +263,7 @@
192 deferred = self.server.translateVirtualPath(
193 '~%s/%s/.bzr/control.conf'
194 % (branch.owner.name, branch.product.name))
195+
196 def check_control_file((transport, path)):
197 self.assertEqual(
198 'default_stack_on = /%s\n' % branch.unique_name,
199@@ -273,6 +282,7 @@
200
201 deferred = self.server.translateVirtualPath(
202 '%s/.bzr/README' % (branch.unique_name,))
203+
204 def check_branch_transport((transport, path)):
205 self.assertEqual(expected_path, path)
206 # Can't test for equality of transports, since URLs and object
207@@ -289,6 +299,7 @@
208 branch = self.factory.makeAnyBranch(branch_type=BranchType.HOSTED)
209 deferred = self.server.translateVirtualPath(
210 '%s/.bzr/README' % (branch.unique_name,))
211+
212 def check_branch_transport((transport, path)):
213 self.assertEqual('.bzr/README', path)
214 self.assertEqual(True, transport.is_readonly())
215@@ -300,6 +311,7 @@
216 branch_url = '/~%s/no-such-product/new-branch' % (self.requester.name)
217 deferred = self.server.createBranch(branch_url)
218 deferred = assert_fails_with(deferred, errors.PermissionDenied)
219+
220 def check_exception(exception):
221 self.assertEqual(branch_url, exception.path)
222 self.assertEqual(
223@@ -328,6 +340,7 @@
224
225 deferred = self.server.translateVirtualPath(
226 '/%s/.bzr/README' % (branch.unique_name,))
227+
228 def check_branch_transport((transport, path)):
229 self.assertEqual(expected_path, path)
230 # Can't test for equality of transports, since URLs and object
231@@ -393,7 +406,6 @@
232 'lp-test://', MemoryTransport())
233
234
235-
236 class TestAsyncVirtualTransport(TestCaseInTempDir):
237 """Tests for `AsyncVirtualTransport`."""
238
239@@ -607,6 +619,7 @@
240 deferred = self._ensureDeferred(
241 transport.readv, '%s/.bzr/hello.txt' % branch.unique_name,
242 [(3, 2)])
243+
244 def get_chunk(generator):
245 return generator.next()[1]
246 deferred.addCallback(get_chunk)
247@@ -622,6 +635,7 @@
248 deferred = self._ensureDeferred(
249 transport.put_bytes,
250 '%s/.bzr/goodbye.txt' % branch.unique_name, "Goodbye")
251+
252 def check_bytes_written(ignored):
253 self.assertEqual(
254 "Goodbye", backing_transport.get_bytes('goodbye.txt'))
255@@ -795,6 +809,7 @@
256 run_tests_with = AsynchronousDeferredRunTest
257
258 def _ensureDeferred(self, function, *args, **kwargs):
259+
260 def call_function_and_check_not_deferred():
261 ret = function(*args, **kwargs)
262 self.assertFalse(
263@@ -1004,6 +1019,105 @@
264 self.assertFormatStringsPassed(branch)
265
266
267+class TestBranchChangedErrorHandling(TestCaseWithTransport, TestCase):
268+ """Test handling of errors when branchChange is called."""
269+
270+ def setUp(self):
271+ TestCaseWithTransport.setUp(self)
272+ self._server = None
273+ frontend = InMemoryFrontend()
274+ self.factory = frontend.getLaunchpadObjectFactory()
275+ self.codehosting_api = frontend.getCodehostingEndpoint()
276+ self.codehosting_api.branchChanged = self._replacement_branchChanged
277+ self.requester = self.factory.makePerson()
278+ self.backing_transport = MemoryTransport()
279+ self.disable_directory_isolation()
280+
281+ # Trap stderr.
282+ self.addCleanup(setattr, sys, 'stderr', sys.stderr)
283+ self._real_stderr = sys.stderr
284+ sys.stderr = codecs.getwriter('utf8')(StringIO())
285+
286+ # To record generated oopsids
287+ self.generated_oopsids = []
288+
289+ def _replacement_branchChanged(self, user_id, branch_id, stacked_on_url,
290+ last_revision, *format_strings):
291+ """Log an oops and raise an xmlrpc fault."""
292+
293+ request = errorlog.ScriptRequest([
294+ ('source', branch_id),
295+ ('error-explanation', "An error occurred")])
296+ try:
297+ raise TimeoutError()
298+ except TimeoutError:
299+ f = sys.exc_info()
300+ report = errorlog.globalErrorUtility.raising(f, request)
301+ # Record the id for checking later.
302+ self.generated_oopsids.append(report.id)
303+ raise xmlrpclib.Fault(-1, report)
304+
305+ def get_server(self):
306+ if self._server is None:
307+ self._server = LaunchpadServer(
308+ XMLRPCWrapper(self.codehosting_api), self.requester.id,
309+ self.backing_transport)
310+ self._server.start_server()
311+ self.addCleanup(self._server.stop_server)
312+ return self._server
313+
314+ def test_branchChanged_stderr_text(self):
315+ # An unexpected error invoking branchChanged() results in a user
316+ # friendly error printed to stderr (and not a traceback).
317+
318+ # Unlocking a branch calls branchChanged x 2 on the branch filesystem
319+ # endpoint. We will then check the error handling.
320+ db_branch = self.factory.makeAnyBranch(
321+ branch_type=BranchType.HOSTED, owner=self.requester)
322+ branch = self.make_branch(db_branch.unique_name)
323+ branch.lock_write()
324+ branch.unlock()
325+ stderr_text = sys.stderr.getvalue()
326+
327+ # The text printed to stderr should be like this:
328+ # (we need the prefix text later for extracting the oopsid)
329+ expected_fault_text_prefix = """
330+ <Fault 380: 'An unexpected error has occurred while updating a
331+ Launchpad branch. Please report a Launchpad bug and quote:"""
332+ expected_fault_text = expected_fault_text_prefix + " OOPS-.*'>"
333+
334+ # For our test case, branchChanged() is called twice, hence 2 errors.
335+ expected_stderr = ' '.join([expected_fault_text for x in range(2)])
336+ self.assertTextMatchesExpressionIgnoreWhitespace(
337+ expected_stderr, stderr_text)
338+
339+ # Extract an oops id from the std error text.
340+ # There will be 2 oops ids. The 2nd will be the oops for the last
341+ # logged error report and the 1st will be in the error text from the
342+ # error report.
343+ oopsids = []
344+ stderr_text = ' '.join(stderr_text.split())
345+ expected_fault_text_prefix = ' '.join(
346+ expected_fault_text_prefix.split())
347+ parts = re.split(expected_fault_text_prefix, stderr_text)
348+ for txt in parts:
349+ if len(txt) == 0:
350+ continue
351+ txt = txt.strip()
352+ # The oopsid ends with a '.'
353+ oopsid = txt[:txt.find('.')]
354+ oopsids.append(oopsid)
355+
356+ # Now check the error report - we just check the last one.
357+ self.assertEqual(len(oopsids), 2)
358+ error_report = self.oopses[-1]
359+ # The error report oopsid should match what's print to stderr.
360+ self.assertEqual(error_report.id, oopsids[1])
361+ # The error report text should contain the root cause oopsid.
362+ self.assertContainsString(
363+ error_report.tb_text, self.generated_oopsids[1])
364+
365+
366 class TestLaunchpadTransportReadOnly(BzrTestCase):
367 """Tests for read-only operations on the LaunchpadTransport."""
368