Merge lp:~jml/launchpad/different-ec2-mail into lp:launchpad

Proposed by Jonathan Lange
Status: Merged
Merged at revision: 11390
Proposed branch: lp:~jml/launchpad/different-ec2-mail
Merge into: lp:launchpad
Diff against target: 1807 lines (+1222/-361)
4 files modified
lib/devscripts/ec2test/remote.py (+567/-323)
lib/devscripts/ec2test/testrunner.py (+11/-6)
lib/devscripts/ec2test/tests/remote_daemonization_test.py (+49/-0)
lib/devscripts/ec2test/tests/test_remote.py (+595/-32)
To merge this branch: bzr merge lp:~jml/launchpad/different-ec2-mail
Reviewer Review Type Date Requested Status
Jelmer Vernooij (community) code Approve
Review via email: mp+32905@code.launchpad.net

Commit message

Refactor ec2test/remote.py and add a whole bunch of tests.

Description of the change

This branch took a really long time and doesn't do what I wanted it to originally.

It busts up ec2test/remote.py, the script that runs on EC2 instances which runs tests, gathers output and sends emails to PQM and developers. It's also responsible for shutting down the instance when running in detached mode.

The refactoring is pretty severe, so you might be better off comparing the old file and the new file side by side, rather than looking at the diff.

I've avoided behavioral changes, even when they would be simple, since mixing them with refactoring generally only causes woe. However, I may have made the occasional slip.

Once I refactored the script and roughly demonstrated that it works by doing a couple of test runs, I added a whole bunch of tests. I wanted to try to reach full test coverage, but I ran out of steam. The untested bits are flagged as such.

To summarize the refactoring:
 * The large chunk of code in an 'if __name__ == "__main__"' block has been
   split into parse_options() and main() functions.

 * TestOnMergeRunner has merged into BaseTestRunner and then split up into
   three classes.
   1. EC2Runner, which knows how to run arbitrary stuff and do
      daemonization & shutdown around it.
   2. LaunchpadTester, which knows how to run Launchpad tests and gather
      results.
   3. Request, which knows all about the test run we've been asked to do,
      and how to send information back to the end user.

 * WebTestLogger has been beefed up so that its clients don't have to
   fiddle directly with the files it manages. It is now also responsible
   for deciding what to do with the test result once it gets it.

 * SummaryResult has been tweaked to be way more testable.

 * A general renaming of internal attributes and methods to have
   underscore prefixes.

I'm not wedded to any of the names of the classes, but I do think the breakdown of responsibilities makes sense.

In addition to the refactoring, I added a --not-really option to "ec2 test", because it was very helpful for me when debugging. Since the ec2 commands are already laboring under a heavy burden of options and flags that are rarely (if ever) used, I'd be happy to drop it.

Let me know if you have any questions, and thanks for reviewing such a large branch.

To post a comment you must log in.
Revision history for this message
Jelmer Vernooij (jelmer) wrote :

Nice to see the class structure rearranged and great to see much more test coverage.

As mentioned on IRC, two minor points:

<jelmer> jml: is there any reason you use Branch.open_containing vs Branch.open ?
<jml> jelmer, I don't, it's preserved from the original. I reckon we should use Branch.open & would happily change the code.
<jelmer> jml: --not-really doesn't really explain to me what it's not doing. What about --no-tests or --test-setup-only ?
<jml> jelmer, I'm going to delete the whole thing. it's buggy anyway

With that in mind, r=me.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/devscripts/ec2test/remote.py'
2--- lib/devscripts/ec2test/remote.py 2010-06-08 03:01:14 +0000
3+++ lib/devscripts/ec2test/remote.py 2010-08-18 18:07:59 +0000
4@@ -1,9 +1,21 @@
5 #!/usr/bin/env python
6-# Run tests in a daemon.
7-#
8 # Copyright 2009 Canonical Ltd. This software is licensed under the
9 # GNU Affero General Public License version 3 (see the file LICENSE).
10
11+"""Run tests in a daemon.
12+
13+ * `EC2Runner` handles the daemonization and instance shutdown.
14+
15+ * `Request` knows everything about the test request we're handling (e.g.
16+ "test merging foo-bar-bug-12345 into db-devel").
17+
18+ * `LaunchpadTester` knows how to actually run the tests and gather the
19+ results. It uses `SummaryResult` and `FlagFallStream` to do so.
20+
21+ * `WebTestLogger` knows how to display the results to the user, and is given
22+ the responsibility of handling the results that `LaunchpadTester` gathers.
23+"""
24+
25 from __future__ import with_statement
26
27 __metatype__ = type
28@@ -11,6 +23,7 @@
29 import datetime
30 from email import MIMEMultipart, MIMEText
31 from email.mime.application import MIMEApplication
32+import errno
33 import gzip
34 import optparse
35 import os
36@@ -38,29 +51,38 @@
37 class SummaryResult(unittest.TestResult):
38 """Test result object used to generate the summary."""
39
40- double_line = '=' * 70 + '\n'
41- single_line = '-' * 70 + '\n'
42+ double_line = '=' * 70
43+ single_line = '-' * 70
44
45 def __init__(self, output_stream):
46 super(SummaryResult, self).__init__()
47 self.stream = output_stream
48
49- def printError(self, flavor, test, error):
50- """Print an error to the output stream."""
51- self.stream.write(self.double_line)
52- self.stream.write('%s: %s\n' % (flavor, test))
53- self.stream.write(self.single_line)
54- self.stream.write('%s\n' % (error,))
55- self.stream.flush()
56+ def _formatError(self, flavor, test, error):
57+ return '\n'.join(
58+ [self.double_line,
59+ '%s: %s' % (flavor, test),
60+ self.single_line,
61+ error,
62+ ''])
63
64 def addError(self, test, error):
65 super(SummaryResult, self).addError(test, error)
66- self.printError('ERROR', test, self._exc_info_to_string(error, test))
67+ self.stream.write(
68+ self._formatError(
69+ 'ERROR', test, self._exc_info_to_string(error, test)))
70
71 def addFailure(self, test, error):
72 super(SummaryResult, self).addFailure(test, error)
73- self.printError(
74- 'FAILURE', test, self._exc_info_to_string(error, test))
75+ self.stream.write(
76+ self._formatError(
77+ 'FAILURE', test, self._exc_info_to_string(error, test)))
78+
79+ def stopTest(self, test):
80+ super(SummaryResult, self).stopTest(test)
81+ # At the very least, we should be sure that a test's output has been
82+ # completely displayed once it has stopped.
83+ self.stream.flush()
84
85
86 class FlagFallStream:
87@@ -93,52 +115,118 @@
88 self._stream.flush()
89
90
91-class BaseTestRunner:
92-
93- def __init__(self, email=None, pqm_message=None, public_branch=None,
94- public_branch_revno=None, test_options=None):
95- self.email = email
96- self.pqm_message = pqm_message
97- self.public_branch = public_branch
98- self.public_branch_revno = public_branch_revno
99- self.test_options = test_options
100-
101- # Configure paths.
102- self.lp_dir = os.path.join(os.path.sep, 'var', 'launchpad')
103- self.tmp_dir = os.path.join(self.lp_dir, 'tmp')
104- self.test_dir = os.path.join(self.lp_dir, 'test')
105- self.sourcecode_dir = os.path.join(self.test_dir, 'sourcecode')
106-
107- # Set up logging.
108- self.logger = WebTestLogger(
109- self.test_dir,
110- self.public_branch,
111- self.public_branch_revno,
112- self.sourcecode_dir
113- )
114-
115- # Daemonization options.
116- self.pid_filename = os.path.join(self.lp_dir, 'ec2test-remote.pid')
117- self.daemonized = False
118-
119- def daemonize(self):
120+class EC2Runner:
121+ """Runs generic code in an EC2 instance.
122+
123+ Handles daemonization, instance shutdown, and email in the case of
124+ catastrophic failure.
125+ """
126+
127+ # XXX: JonathanLange 2010-08-17: EC2Runner needs tests.
128+
129+ # The number of seconds we give this script to clean itself up, and for
130+ # 'ec2 test --postmortem' to grab control if needed. If we don't give
131+ # --postmortem enough time to log in via SSH and take control, then this
132+ # server will begin to shutdown on its own.
133+ #
134+ # (FWIW, "grab control" means sending SIGTERM to this script's process id,
135+ # thus preventing fail-safe shutdown.)
136+ SHUTDOWN_DELAY = 60
137+
138+ def __init__(self, daemonize, pid_filename, shutdown_when_done,
139+ emails=None):
140+ """Make an EC2Runner.
141+
142+ :param daemonize: Whether or not we will daemonize.
143+ :param pid_filename: The filename to store the pid in.
144+ :param shutdown_when_done: Whether or not to shut down when the tests
145+ are done.
146+ :param emails: The email address(es) to send catastrophic failure
147+ messages to. If not provided, the error disappears into the ether.
148+ """
149+ self._should_daemonize = daemonize
150+ self._pid_filename = pid_filename
151+ self._shutdown_when_done = shutdown_when_done
152+ self._emails = emails
153+ self._daemonized = False
154+
155+ def _daemonize(self):
156 """Turn the testrunner into a forked daemon process."""
157 # This also writes our pidfile to disk to a specific location. The
158 # ec2test.py --postmortem command will look there to find our PID,
159 # in order to control this process.
160- daemonize(self.pid_filename)
161- self.daemonized = True
162-
163- def remove_pidfile(self):
164- if os.path.exists(self.pid_filename):
165- os.remove(self.pid_filename)
166-
167- def ignore_line(self, line):
168- """Return True if the line should be excluded from the summary log.
169-
170- Defaults to False.
171+ daemonize(self._pid_filename)
172+ self._daemonized = True
173+
174+ def _shutdown_instance(self):
175+ """Shut down this EC2 instance."""
176+ # Make sure our process is daemonized, and has therefore disconnected
177+ # the controlling terminal. This also disconnects the ec2test.py SSH
178+ # connection, thus signalling ec2test.py that it may now try to take
179+ # control of the server.
180+ if not self._daemonized:
181+ # We only want to do this if we haven't already been daemonized.
182+ # Nesting daemons is bad.
183+ self._daemonize()
184+
185+ time.sleep(self.SHUTDOWN_DELAY)
186+
187+ # We'll only get here if --postmortem didn't kill us. This is our
188+ # fail-safe shutdown, in case the user got disconnected or suffered
189+ # some other mishap that would prevent them from shutting down this
190+ # server on their own.
191+ subprocess.call(['sudo', 'shutdown', '-P', 'now'])
192+
193+ def run(self, name, function, *args, **kwargs):
194+ try:
195+ if self._should_daemonize:
196+ print 'Starting %s daemon...' % (name,)
197+ self._daemonize()
198+
199+ return function(*args, **kwargs)
200+ except:
201+ config = bzrlib.config.GlobalConfig()
202+ # Handle exceptions thrown by the test() or daemonize() methods.
203+ if self._emails:
204+ bzrlib.email_message.EmailMessage.send(
205+ config, config.username(),
206+ self._emails, '%s FAILED' % (name,),
207+ traceback.format_exc())
208+ raise
209+ finally:
210+ # When everything is over, if we've been ask to shut down, then
211+ # make sure we're daemonized, then shutdown. Otherwise, if we're
212+ # daemonized, just clean up the pidfile.
213+ if self._shutdown_when_done:
214+ self._shutdown_instance()
215+ elif self._daemonized:
216+ # It would be nice to clean up after ourselves, since we won't
217+ # be shutting down.
218+ remove_pidfile(self._pid_filename)
219+ else:
220+ # We're not a daemon, and we're not shutting down. The user
221+ # most likely started this script manually, from a shell
222+ # running on the instance itself.
223+ pass
224+
225+
226+class LaunchpadTester:
227+ """Runs Launchpad tests and gathers their results in a useful way."""
228+
229+ # XXX: JonathanLange 2010-08-17: LaunchpadTester needs tests.
230+
231+ def __init__(self, logger, test_directory, test_options=()):
232+ """Construct a TestOnMergeRunner.
233+
234+ :param logger: The WebTestLogger to log to.
235+ :param test_directory: The directory to run the tests in. We expect
236+ this directory to have a fully-functional checkout of Launchpad
237+ and its dependent branches.
238+ :param test_options: A sequence of options to pass to the test runner.
239 """
240- return False
241+ self._logger = logger
242+ self._test_directory = test_directory
243+ self._test_options = ' '.join(test_options)
244
245 def build_test_command(self):
246 """Return the command that will execute the test suite.
247@@ -148,7 +236,8 @@
248
249 Subclasses must provide their own implementation of this method.
250 """
251- raise NotImplementedError
252+ command = ['make', 'check', 'TESTOPTS="%s"' % self._test_options]
253+ return command
254
255 def test(self):
256 """Run the tests, log the results.
257@@ -157,170 +246,327 @@
258 have completed. If necessary, submits the branch to PQM, and mails
259 the user the test results.
260 """
261- # We need to open the log files here because earlier calls to
262- # os.fork() may have tried to close them.
263- self.logger.prepare()
264-
265- out_file = self.logger.out_file
266- summary_file = self.logger.summary_file
267- config = bzrlib.config.GlobalConfig()
268+ self._logger.prepare()
269
270 call = self.build_test_command()
271
272 try:
273+ self._logger.write_line("Running %s" % (call,))
274+ # XXX: JonathanLange 2010-08-18: bufsize=-1 implies "use the
275+ # system buffering", when we actually want no buffering at all.
276 popen = subprocess.Popen(
277 call, bufsize=-1,
278 stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
279- cwd=self.test_dir)
280-
281- self._gather_test_output(popen.stdout, summary_file, out_file)
282-
283- # Grab the testrunner exit status.
284- result = popen.wait()
285-
286- if self.pqm_message is not None:
287- subject = self.pqm_message.get('Subject')
288- if result:
289- # failure
290- summary_file.write(
291- '\n\n**NOT** submitted to PQM:\n%s\n' % (subject,))
292- else:
293- # success
294- conn = bzrlib.smtp_connection.SMTPConnection(config)
295- conn.send_email(self.pqm_message)
296- summary_file.write(
297- '\n\nSUBMITTED TO PQM:\n%s\n' % (subject,))
298-
299- summary_file.write(
300- '\n(See the attached file for the complete log)\n')
301+ cwd=self._test_directory)
302+
303+ self._gather_test_output(popen.stdout, self._logger)
304+
305+ exit_status = popen.wait()
306 except:
307- summary_file.write('\n\nERROR IN TESTRUNNER\n\n')
308- traceback.print_exc(file=summary_file)
309- result = 1
310+ self._logger.error_in_testrunner(sys.exc_info())
311+ exit_status = 1
312 raise
313 finally:
314- # It probably isn't safe to close the log files ourselves,
315- # since someone else might try to write to them later.
316- try:
317- if self.email is not None:
318- self.send_email(
319- result, self.logger.summary_filename,
320- self.logger.out_filename, config)
321- finally:
322- summary_file.close()
323- # we do this at the end because this is a trigger to
324- # ec2test.py back at home that it is OK to kill the process
325- # and take control itself, if it wants to.
326- self.logger.close_logs()
327-
328- def send_email(self, result, summary_filename, out_filename, config):
329- """Send an email summarizing the test results.
330-
331- :param result: True for pass, False for failure.
332- :param summary_filename: The path to the file where the summary
333- information lives. This will be the body of the email.
334- :param out_filename: The path to the file where the full output
335- lives. This will be zipped and attached.
336- :param config: A Bazaar configuration object with SMTP details.
337+ self._logger.got_result(not exit_status)
338+
339+ def _gather_test_output(self, input_stream, logger):
340+ """Write the testrunner output to the logs."""
341+ summary_stream = logger.get_summary_stream()
342+ result = SummaryResult(summary_stream)
343+ subunit_server = subunit.TestProtocolServer(result, summary_stream)
344+ for line in input_stream:
345+ subunit_server.lineReceived(line)
346+ logger.got_line(line)
347+
348+
349+class Request:
350+ """A request to have a branch tested and maybe landed."""
351+
352+ def __init__(self, branch_url, revno, local_branch_path, sourcecode_path,
353+ emails=None, pqm_message=None):
354+ """Construct a `Request`.
355+
356+ :param branch_url: The public URL to the Launchpad branch we are
357+ testing.
358+ :param revno: The revision number of the branch we are testing.
359+ :param local_branch_path: A local path to the Launchpad branch we are
360+ testing. This must be a branch of Launchpad with a working tree.
361+ :param sourcecode_path: A local path to the sourcecode dependencies
362+ directory (normally '$local_branch_path/sourcecode'). This must
363+ contain up-to-date copies of all of Launchpad's sourcecode
364+ dependencies.
365+ :param emails: A list of emails to send the results to. If not
366+ provided, no emails are sent.
367+ :param pqm_message: The message to submit to PQM. If not provided, we
368+ don't submit to PQM.
369+ """
370+ self._branch_url = branch_url
371+ self._revno = revno
372+ self._local_branch_path = local_branch_path
373+ self._sourcecode_path = sourcecode_path
374+ self._emails = emails
375+ self._pqm_message = pqm_message
376+ # Used for figuring out how to send emails.
377+ self._bzr_config = bzrlib.config.GlobalConfig()
378+
379+ def _send_email(self, message):
380+ """Actually send 'message'."""
381+ conn = bzrlib.smtp_connection.SMTPConnection(self._bzr_config)
382+ conn.send_email(message)
383+
384+ def get_trunk_details(self):
385+ """Return (branch_url, revno) for trunk."""
386+ branch = bzrlib.branch.Branch.open(self._local_branch_path)
387+ return branch.get_parent().encode('utf-8'), branch.revno()
388+
389+ def get_branch_details(self):
390+ """Return (branch_url, revno) for the branch we're merging in.
391+
392+ If we're not merging in a branch, but instead just testing a trunk,
393+ then return None.
394+ """
395+ tree = bzrlib.workingtree.WorkingTree.open(self._local_branch_path)
396+ parent_ids = tree.get_parent_ids()
397+ if len(parent_ids) < 2:
398+ return None
399+ return self._branch_url.encode('utf-8'), self._revno
400+
401+ def _last_segment(self, url):
402+ """Return the last segment of a URL."""
403+ return url.strip('/').split('/')[-1]
404+
405+ def get_nick(self):
406+ """Get the nick of the branch we are testing."""
407+ details = self.get_branch_details()
408+ if not details:
409+ details = self.get_trunk_details()
410+ url, revno = details
411+ return self._last_segment(url)
412+
413+ def get_merge_description(self):
414+ """Get a description of the merge request.
415+
416+ If we're merging a branch, return '$SOURCE_NICK => $TARGET_NICK', if
417+ we're just running tests for a trunk branch without merging return
418+ '$TRUNK_NICK'.
419+ """
420+ # XXX: JonathanLange 2010-08-17: Not actually used yet. I think it
421+ # would be a great thing to have in the subject of the emails we
422+ # receive.
423+ source = self.get_branch_details()
424+ if not source:
425+ return self.get_nick()
426+ target = self.get_trunk_details()
427+ return '%s => %s' % (
428+ self._last_segment(source[0]), self._last_segment(target[0]))
429+
430+ def get_summary_commit(self):
431+ """Get a message summarizing the change from the commit log.
432+
433+ Returns the last commit message of the merged branch, or None.
434+ """
435+ # XXX: JonathanLange 2010-08-17: I don't actually know why we are
436+ # using this commit message as a summary message. It's used in the
437+ # test logs and the EC2 hosted web page.
438+ branch = bzrlib.branch.Branch.open(self._local_branch_path)
439+ tree = bzrlib.workingtree.WorkingTree.open(self._local_branch_path)
440+ parent_ids = tree.get_parent_ids()
441+ if len(parent_ids) == 1:
442+ return None
443+ summary = (
444+ branch.repository.get_revision(parent_ids[1]).get_summary())
445+ return summary.encode('utf-8')
446+
447+ def _build_report_email(self, successful, body_text, full_log_gz):
448+ """Build a MIME email summarizing the test results.
449+
450+ :param successful: True for pass, False for failure.
451+ :param body_text: The body of the email to send to the requesters.
452+ :param full_log_gz: A gzip of the full log.
453 """
454 message = MIMEMultipart.MIMEMultipart()
455- message['To'] = ', '.join(self.email)
456- message['From'] = config.username()
457- subject = 'Test results: %s' % (result and 'FAILURE' or 'SUCCESS')
458+ message['To'] = ', '.join(self._emails)
459+ message['From'] = self._bzr_config.username()
460+ if successful:
461+ status = 'SUCCESS'
462+ else:
463+ status = 'FAILURE'
464+ subject = 'Test results: %s' % (status,)
465 message['Subject'] = subject
466
467 # Make the body.
468- with open(summary_filename, 'r') as summary_fd:
469- summary = summary_fd.read()
470- body = MIMEText.MIMEText(summary, 'plain', 'utf8')
471+ body = MIMEText.MIMEText(body_text, 'plain', 'utf8')
472 body['Content-Disposition'] = 'inline'
473 message.attach(body)
474
475- # gzip up the full log.
476- fd, path = tempfile.mkstemp()
477- os.close(fd)
478- gz = gzip.open(path, 'wb')
479- full_log = open(out_filename, 'rb')
480- gz.writelines(full_log)
481- gz.close()
482-
483 # Attach the gzipped log.
484- zipped_log = MIMEApplication(open(path, 'rb').read(), 'x-gzip')
485+ zipped_log = MIMEApplication(full_log_gz, 'x-gzip')
486 zipped_log.add_header(
487 'Content-Disposition', 'attachment',
488 filename='%s.log.gz' % self.get_nick())
489 message.attach(zipped_log)
490-
491- bzrlib.smtp_connection.SMTPConnection(config).send_email(message)
492-
493- def get_nick(self):
494- """Return the nick name of the branch that we are testing."""
495- return self.public_branch.strip('/').split('/')[-1]
496-
497- def _gather_test_output(self, input_stream, summary_file, out_file):
498- """Write the testrunner output to the logs."""
499- # Only write to stdout if we are running as the foreground process.
500- echo_to_stdout = not self.daemonized
501- result = SummaryResult(summary_file)
502- subunit_server = subunit.TestProtocolServer(result, summary_file)
503- for line in input_stream:
504- subunit_server.lineReceived(line)
505- out_file.write(line)
506- out_file.flush()
507- if echo_to_stdout:
508- sys.stdout.write(line)
509- sys.stdout.flush()
510-
511-
512-class TestOnMergeRunner(BaseTestRunner):
513- """Executes the Launchpad test_on_merge.py test suite."""
514-
515- def build_test_command(self):
516- """See BaseTestRunner.build_test_command()."""
517- command = ['make', 'check', 'TESTOPTS=' + self.test_options]
518- return command
519+ return message
520+
521+ def send_report_email(self, successful, body_text, full_log_gz):
522+ """Send an email summarizing the test results.
523+
524+ :param successful: True for pass, False for failure.
525+ :param body_text: The body of the email to send to the requesters.
526+ :param full_log_gz: A gzip of the full log.
527+ """
528+ message = self._build_report_email(successful, body_text, full_log_gz)
529+ self._send_email(message)
530+
531+ def iter_dependency_branches(self):
532+ """Iterate through the Bazaar branches we depend on."""
533+ for name in sorted(os.listdir(self._sourcecode_path)):
534+ path = os.path.join(self._sourcecode_path, name)
535+ if os.path.isdir(path):
536+ try:
537+ branch = bzrlib.branch.Branch.open(path)
538+ except bzrlib.errors.NotBranchError:
539+ continue
540+ yield name, branch.get_parent(), branch.revno()
541+
542+ def submit_to_pqm(self, successful):
543+ """Submit this request to PQM, if successful & configured to do so."""
544+ if not self._pqm_message:
545+ return
546+ subject = self._pqm_message.get('Subject')
547+ if successful:
548+ self._send_email(self._pqm_message)
549+ return subject
550+
551+ @property
552+ def wants_email(self):
553+ """Do the requesters want emails sent to them?"""
554+ return bool(self._emails)
555
556
557 class WebTestLogger:
558 """Logs test output to disk and a simple web page."""
559
560- def __init__(self, test_dir, public_branch, public_branch_revno,
561- sourcecode_dir):
562- """ Class initialiser """
563- self.test_dir = test_dir
564- self.public_branch = public_branch
565- self.public_branch_revno = public_branch_revno
566- self.sourcecode_dir = sourcecode_dir
567-
568- self.www_dir = os.path.join(os.path.sep, 'var', 'www')
569- self.out_filename = os.path.join(self.www_dir, 'current_test.log')
570- self.summary_filename = os.path.join(self.www_dir, 'summary.log')
571- self.index_filename = os.path.join(self.www_dir, 'index.html')
572-
573- # We will set up the preliminary bits of the web-accessible log
574- # files. "out" holds all stdout and stderr; "summary" holds filtered
575- # output; and "index" holds an index page.
576- self.out_file = None
577- self.summary_file = None
578- self.index_file = None
579-
580- def open_logs(self):
581- """Open all of our log files for writing."""
582- self.out_file = open(self.out_filename, 'w')
583- self.summary_file = open(self.summary_filename, 'w')
584- self.index_file = open(self.index_filename, 'w')
585-
586- def flush_logs(self):
587- """Flush all of our log file buffers."""
588- self.out_file.flush()
589- self.summary_file.flush()
590- self.index_file.flush()
591-
592- def close_logs(self):
593- """Closes all of the open log file handles."""
594- self.out_file.close()
595- self.summary_file.close()
596- self.index_file.close()
597+ def __init__(self, full_log_filename, summary_filename, index_filename,
598+ request, echo_to_stdout):
599+ """Construct a WebTestLogger.
600+
601+ Because this writes an HTML file with links to the summary and full
602+ logs, you should construct this object with
603+ `WebTestLogger.make_in_directory`, which guarantees that the files
604+ are available in the correct locations.
605+
606+ :param full_log_filename: Path to a file that will have the full
607+ log output written to it. The file will be overwritten.
608+ :param summary_file: Path to a file that will have a human-readable
609+ summary written to it. The file will be overwritten.
610+ :param index_file: Path to a file that will have an HTML page
611+ written to it. The file will be overwritten.
612+ :param request: A `Request` object representing the thing that's being
613+ tested.
614+ :param echo_to_stdout: Whether or not we should echo output to stdout.
615+ """
616+ self._full_log_filename = full_log_filename
617+ self._summary_filename = summary_filename
618+ self._index_filename = index_filename
619+ self._request = request
620+ self._echo_to_stdout = echo_to_stdout
621+
622+ @classmethod
623+ def make_in_directory(cls, www_dir, request, echo_to_stdout):
624+ """Make a logger that logs to specific files in `www_dir`.
625+
626+ :param www_dir: The directory in which to log the files:
627+ current_test.log, summary.log and index.html. These files
628+ will be overwritten.
629+ :param request: A `Request` object representing the thing that's being
630+ tested.
631+ :param echo_to_stdout: Whether or not we should echo output to stdout.
632+ """
633+ files = [
634+ os.path.join(www_dir, 'current_test.log'),
635+ os.path.join(www_dir, 'summary.log'),
636+ os.path.join(www_dir, 'index.html')]
637+ files.extend([request, echo_to_stdout])
638+ return cls(*files)
639+
640+ def error_in_testrunner(self, exc_info):
641+ """Called when there is a catastrophic error in the test runner."""
642+ exc_type, exc_value, exc_tb = exc_info
643+ # XXX: JonathanLange 2010-08-17: This should probably log to the full
644+ # log as well.
645+ summary = self.get_summary_stream()
646+ summary.write('\n\nERROR IN TESTRUNNER\n\n')
647+ traceback.print_exception(exc_type, exc_value, exc_tb, file=summary)
648+ summary.flush()
649+
650+ def get_index_contents(self):
651+ """Return the contents of the index.html page."""
652+ return self._get_contents(self._index_filename)
653+
654+ def get_full_log_contents(self):
655+ """Return the contents of the complete log."""
656+ return self._get_contents(self._full_log_filename)
657+
658+ def get_summary_contents(self):
659+ """Return the contents of the summary log."""
660+ return self._get_contents(self._summary_filename)
661+
662+ def get_summary_stream(self):
663+ """Return a stream that, when written to, writes to the summary."""
664+ return open(self._summary_filename, 'a')
665+
666+ def got_line(self, line):
667+ """Called when we get a line of output from our child processes."""
668+ self._write_to_filename(self._full_log_filename, line)
669+ if self._echo_to_stdout:
670+ sys.stdout.write(line)
671+ sys.stdout.flush()
672+
673+ def _get_contents(self, filename):
674+ """Get the full contents of 'filename'."""
675+ try:
676+ return open(filename, 'r').read()
677+ except IOError, e:
678+ if e.errno == errno.ENOENT:
679+ return ''
680+
681+ def got_result(self, successful):
682+ """The tests are done and the results are known."""
683+ self._handle_pqm_submission(successful)
684+ if self._request.wants_email:
685+ self._write_to_filename(
686+ self._summary_filename,
687+ '\n(See the attached file for the complete log)\n')
688+ summary = self.get_summary_contents()
689+ full_log_gz = gzip_data(self.get_full_log_contents())
690+ self._request.send_report_email(successful, summary, full_log_gz)
691+
692+ def _handle_pqm_submission(self, successful):
693+ subject = self._request.submit_to_pqm(successful)
694+ if not subject:
695+ return
696+ self.write_line('')
697+ self.write_line('')
698+ if successful:
699+ self.write_line('SUBMITTED TO PQM:')
700+ else:
701+ self.write_line('**NOT** submitted to PQM:')
702+ self.write_line(subject)
703+
704+ def _write_to_filename(self, filename, msg):
705+ fd = open(filename, 'a')
706+ fd.write(msg)
707+ fd.flush()
708+ fd.close()
709+
710+ def _write(self, msg):
711+ """Write to the summary and full log file."""
712+ self._write_to_filename(self._full_log_filename, msg)
713+ self._write_to_filename(self._summary_filename, msg)
714+
715+ def write_line(self, msg):
716+ """Write to the summary and full log file with a newline."""
717+ self._write(msg + '\n')
718
719 def prepare(self):
720 """Prepares the log files on disk.
721@@ -328,20 +574,22 @@
722 Writes three log files: the raw output log, the filtered "summary"
723 log file, and a HTML index page summarizing the test run paramters.
724 """
725- self.open_logs()
726-
727- out_file = self.out_file
728- summary_file = self.summary_file
729- index_file = self.index_file
730-
731- def write(msg):
732- msg += '\n'
733- summary_file.write(msg)
734- out_file.write(msg)
735+ # XXX: JonathanLange 2010-07-18: Mostly untested.
736+ log = self.write_line
737+
738+ # Clear the existing index file.
739+ index = open(self._index_filename, 'w')
740+ index.truncate(0)
741+ index.close()
742+
743+ def add_to_html(html):
744+ return self._write_to_filename(
745+ self._index_filename, textwrap.dedent(html))
746+
747 msg = 'Tests started at approximately %(now)s UTC' % {
748 'now': datetime.datetime.utcnow().strftime(
749 '%a, %d %b %Y %H:%M:%S')}
750- index_file.write(textwrap.dedent('''\
751+ add_to_html('''\
752 <html>
753 <head>
754 <title>Testing</title>
755@@ -353,86 +601,76 @@
756 <li><a href="summary.log">Summary results</a></li>
757 <li><a href="current_test.log">Full results</a></li>
758 </ul>
759- ''' % (msg,)))
760- write(msg)
761+ ''' % (msg,))
762+ log(msg)
763
764- index_file.write(textwrap.dedent('''\
765+ add_to_html('''\
766 <h2>Branches Tested</h2>
767- '''))
768+ ''')
769
770 # Describe the trunk branch.
771- branch = bzrlib.branch.Branch.open_containing(self.test_dir)[0]
772- msg = '%(trunk)s, revision %(trunk_revno)d\n' % {
773- 'trunk': branch.get_parent().encode('utf-8'),
774- 'trunk_revno': branch.revno()}
775- index_file.write(textwrap.dedent('''\
776+ trunk, trunk_revno = self._request.get_trunk_details()
777+ msg = '%s, revision %d\n' % (trunk, trunk_revno)
778+ add_to_html('''\
779 <p><strong>%s</strong></p>
780- ''' % (escape(msg),)))
781- write(msg)
782- tree = bzrlib.workingtree.WorkingTree.open(self.test_dir)
783- parent_ids = tree.get_parent_ids()
784+ ''' % (escape(msg),))
785+ log(msg)
786
787- # Describe the merged branch.
788- if len(parent_ids) == 1:
789- index_file.write('<p>(no merged branch)</p>\n')
790- write('(no merged branch)')
791+ branch_details = self._request.get_branch_details()
792+ if not branch_details:
793+ add_to_html('<p>(no merged branch)</p>\n')
794+ log('(no merged branch)')
795 else:
796- summary = (
797- branch.repository.get_revision(parent_ids[1]).get_summary())
798- data = {'name': self.public_branch.encode('utf-8'),
799- 'revno': self.public_branch_revno,
800- 'commit': summary.encode('utf-8')}
801+ branch_name, branch_revno = branch_details
802+ data = {'name': branch_name,
803+ 'revno': branch_revno,
804+ 'commit': self._request.get_summary_commit()}
805 msg = ('%(name)s, revision %(revno)d '
806 '(commit message: %(commit)s)\n' % data)
807- index_file.write(textwrap.dedent('''\
808+ add_to_html('''\
809 <p>Merged with<br />%(msg)s</p>
810- ''' % {'msg': escape(msg)}))
811- write("Merged with")
812- write(msg)
813+ ''' % {'msg': escape(msg)})
814+ log("Merged with")
815+ log(msg)
816
817- index_file.write('<dl>\n')
818- write('\nDEPENDENCY BRANCHES USED\n')
819- for name in os.listdir(self.sourcecode_dir):
820- path = os.path.join(self.sourcecode_dir, name)
821- if os.path.isdir(path):
822- try:
823- branch = bzrlib.branch.Branch.open_containing(path)[0]
824- except bzrlib.errors.NotBranchError:
825- continue
826- data = {'name': name,
827- 'branch': branch.get_parent(),
828- 'revno': branch.revno()}
829- write(
830- '- %(name)s\n %(branch)s\n %(revno)d\n' % data)
831- escaped_data = {'name': escape(name),
832- 'branch': escape(branch.get_parent()),
833- 'revno': branch.revno()}
834- index_file.write(textwrap.dedent('''\
835- <dt>%(name)s</dt>
836- <dd>%(branch)s</dd>
837- <dd>%(revno)s</dd>
838- ''' % escaped_data))
839- index_file.write(textwrap.dedent('''\
840+ add_to_html('<dl>\n')
841+ log('\nDEPENDENCY BRANCHES USED\n')
842+ for name, branch, revno in self._request.iter_dependency_branches():
843+ data = {'name': name, 'branch': branch, 'revno': revno}
844+ log(
845+ '- %(name)s\n %(branch)s\n %(revno)d\n' % data)
846+ escaped_data = {'name': escape(name),
847+ 'branch': escape(branch),
848+ 'revno': revno}
849+ add_to_html('''\
850+ <dt>%(name)s</dt>
851+ <dd>%(branch)s</dd>
852+ <dd>%(revno)s</dd>
853+ ''' % escaped_data)
854+ add_to_html('''\
855 </dl>
856 </body>
857- </html>'''))
858- write('\n\nTEST RESULTS FOLLOW\n\n')
859- self.flush_logs()
860+ </html>''')
861+ log('\n\nTEST RESULTS FOLLOW\n\n')
862
863
864 def daemonize(pid_filename):
865 # this seems like the sort of thing that ought to be in the
866 # standard library :-/
867 pid = os.fork()
868- if (pid == 0): # child 1
869+ if (pid == 0): # Child 1
870 os.setsid()
871 pid = os.fork()
872- if (pid == 0): # child 2
873+ if (pid == 0): # Child 2, the daemon.
874 pass # lookie, we're ready to do work in the daemon
875 else:
876 os._exit(0)
877- else:
878- # give the pidfile a chance to be written before we exit.
879+ else: # Parent
880+ # Make sure the pidfile is written before we exit, so that people
881+ # who've chosen to daemonize can quickly rectify their mistake. Since
882+ # the daemon might terminate itself very, very quickly, we cannot poll
883+ # for the existence of the pidfile. Instead, we just sleep for a
884+ # reasonable amount of time.
885 time.sleep(1)
886 os._exit(0)
887
888@@ -454,6 +692,38 @@
889 os.dup2(0, 2)
890
891
892+def gunzip_data(data):
893+ """Decompress 'data'.
894+
895+ :param data: The gzip data to decompress.
896+ :return: The decompressed data.
897+ """
898+ fd, path = tempfile.mkstemp()
899+ os.write(fd, data)
900+ os.close(fd)
901+ try:
902+ return gzip.open(path, 'r').read()
903+ finally:
904+ os.unlink(path)
905+
906+
907+def gzip_data(data):
908+ """Compress 'data'.
909+
910+ :param data: The data to compress.
911+ :return: The gzip-compressed data.
912+ """
913+ fd, path = tempfile.mkstemp()
914+ os.close(fd)
915+ gz = gzip.open(path, 'wb')
916+ gz.writelines(data)
917+ gz.close()
918+ try:
919+ return open(path).read()
920+ finally:
921+ os.unlink(path)
922+
923+
924 def write_pidfile(pid_filename):
925 """Write a pidfile for the current process."""
926 pid_file = open(pid_filename, "w")
927@@ -461,7 +731,14 @@
928 pid_file.close()
929
930
931-if __name__ == '__main__':
932+def remove_pidfile(pid_filename):
933+ if os.path.exists(pid_filename):
934+ os.remove(pid_filename)
935+
936+
937+def parse_options(argv):
938+ """Make an `optparse.OptionParser` for running the tests remotely.
939+ """
940 parser = optparse.OptionParser(
941 usage="%prog [options] [-- test options]",
942 description=("Build and run tests for an instance."))
943@@ -494,7 +771,11 @@
944 type="int", default=None,
945 help=('The revision number of the public branch being tested.'))
946
947- options, args = parser.parse_args()
948+ return parser.parse_args(argv)
949+
950+
951+def main(argv):
952+ options, args = parse_options(argv)
953
954 if options.debug:
955 import pdb; pdb.set_trace()
956@@ -504,65 +785,28 @@
957 else:
958 pqm_message = None
959
960- runner = TestOnMergeRunner(
961- options.email,
962- pqm_message,
963- options.public_branch,
964- options.public_branch_revno,
965- ' '.join(args))
966-
967- try:
968- try:
969- if options.daemon:
970- print 'Starting testrunner daemon...'
971- runner.daemonize()
972-
973- runner.test()
974- except:
975- config = bzrlib.config.GlobalConfig()
976- # Handle exceptions thrown by the test() or daemonize() methods.
977- if options.email:
978- bzrlib.email_message.EmailMessage.send(
979- config, config.username(),
980- options.email,
981- 'Test Runner FAILED', traceback.format_exc())
982- raise
983- finally:
984-
985- # When everything is over, if we've been ask to shut down, then
986- # make sure we're daemonized, then shutdown. Otherwise, if we're
987- # daemonized, just clean up the pidfile.
988- if options.shutdown:
989- # Make sure our process is daemonized, and has therefore
990- # disconnected the controlling terminal. This also disconnects
991- # the ec2test.py SSH connection, thus signalling ec2test.py
992- # that it may now try to take control of the server.
993- if not runner.daemonized:
994- # We only want to do this if we haven't already been
995- # daemonized. Nesting daemons is bad.
996- runner.daemonize()
997-
998- # Give the script 60 seconds to clean itself up, and 60 seconds
999- # for the ec2test.py --postmortem option to grab control if
1000- # needed. If we don't give --postmortem enough time to log
1001- # in via SSH and take control, then this server will begin to
1002- # shutdown on it's own.
1003- #
1004- # (FWIW, "grab control" means sending SIGTERM to this script's
1005- # process id, thus preventing fail-safe shutdown.)
1006- time.sleep(60)
1007-
1008- # We'll only get here if --postmortem didn't kill us. This is
1009- # our fail-safe shutdown, in case the user got disconnected
1010- # or suffered some other mishap that would prevent them from
1011- # shutting down this server on their own.
1012- subprocess.call(['sudo', 'shutdown', '-P', 'now'])
1013- elif runner.daemonized:
1014- # It would be nice to clean up after ourselves, since we won't
1015- # be shutting down.
1016- runner.remove_pidfile()
1017- else:
1018- # We're not a daemon, and we're not shutting down. The user most
1019- # likely started this script manually, from a shell running on the
1020- # instance itself.
1021- pass
1022+ # Locations for Launchpad. These are actually defined by the configuration
1023+ # of the EC2 image that we use.
1024+ LAUNCHPAD_DIR = '/var/launchpad'
1025+ TEST_DIR = os.path.join(LAUNCHPAD_DIR, 'test')
1026+ SOURCECODE_DIR = os.path.join(TEST_DIR, 'sourcecode')
1027+
1028+ pid_filename = os.path.join(LAUNCHPAD_DIR, 'ec2test-remote.pid')
1029+
1030+ request = Request(
1031+ options.public_branch, options.public_branch_revno, TEST_DIR,
1032+ SOURCECODE_DIR, options.email, pqm_message)
1033+ # Only write to stdout if we are running as the foreground process.
1034+ echo_to_stdout = not options.daemon
1035+ logger = WebTestLogger.make_in_directory(
1036+ '/var/www', request, echo_to_stdout)
1037+
1038+ runner = EC2Runner(
1039+ options.daemon, pid_filename, options.shutdown, options.email)
1040+
1041+ tester = LaunchpadTester(logger, TEST_DIR, test_options=args[1:])
1042+ runner.run("Test runner", tester.test)
1043+
1044+
1045+if __name__ == '__main__':
1046+ main(sys.argv)
1047
1048=== modified file 'lib/devscripts/ec2test/testrunner.py'
1049--- lib/devscripts/ec2test/testrunner.py 2010-08-13 22:04:58 +0000
1050+++ lib/devscripts/ec2test/testrunner.py 2010-08-18 18:07:59 +0000
1051@@ -454,11 +454,8 @@
1052 # close ssh connection
1053 user_connection.close()
1054
1055- def run_tests(self):
1056- self.configure_system()
1057- self.prepare_tests()
1058- user_connection = self._instance.connect()
1059-
1060+ def _build_command(self):
1061+ """Build the command that we'll use to run the tests."""
1062 # Make sure we activate the failsafe --shutdown feature. This will
1063 # make the server shut itself down after the test run completes, or
1064 # if the test harness suffers a critical failure.
1065@@ -496,6 +493,12 @@
1066
1067 # Add any additional options for ec2test-remote.py
1068 cmd.extend(['--', self.test_options])
1069+ return ' '.join(cmd)
1070+
1071+ def run_tests(self):
1072+ self.configure_system()
1073+ self.prepare_tests()
1074+
1075 self.log(
1076 'Running tests... (output is available on '
1077 'http://%s/)\n' % self._instance.hostname)
1078@@ -513,7 +516,9 @@
1079
1080 # Run the remote script! Our execution will block here until the
1081 # remote side disconnects from the terminal.
1082- user_connection.perform(' '.join(cmd))
1083+ cmd = self._build_command()
1084+ user_connection = self._instance.connect()
1085+ user_connection.perform(cmd)
1086 self._running = True
1087
1088 if not self.headless:
1089
1090=== added file 'lib/devscripts/ec2test/tests/remote_daemonization_test.py'
1091--- lib/devscripts/ec2test/tests/remote_daemonization_test.py 1970-01-01 00:00:00 +0000
1092+++ lib/devscripts/ec2test/tests/remote_daemonization_test.py 2010-08-18 18:07:59 +0000
1093@@ -0,0 +1,49 @@
1094+# Copyright 2010 Canonical Ltd. This software is licensed under the
1095+# GNU Affero General Public License version 3 (see the file LICENSE).
1096+
1097+"""Script executed by test_remote.py to verify daemonization behaviour.
1098+
1099+See TestDaemonizationInteraction.
1100+"""
1101+
1102+import os
1103+import sys
1104+import traceback
1105+
1106+from devscripts.ec2test.remote import EC2Runner, WebTestLogger
1107+from devscripts.ec2test.tests.test_remote import TestRequest
1108+
1109+PID_FILENAME = os.path.abspath(sys.argv[1])
1110+DIRECTORY = os.path.abspath(sys.argv[2])
1111+LOG_FILENAME = os.path.abspath(sys.argv[3])
1112+
1113+
1114+def make_request():
1115+ """Just make a request."""
1116+ test = TestRequest('test_wants_email')
1117+ test.setUp()
1118+ try:
1119+ return test.make_request()
1120+ finally:
1121+ test.tearDown()
1122+
1123+
1124+def prepare_files(logger):
1125+ try:
1126+ logger.prepare()
1127+ except:
1128+ # If anything in the above fails, we want to be able to find out about
1129+ # it. We can't use stdout or stderr because this is a daemon.
1130+ error_log = open(LOG_FILENAME, 'w')
1131+ traceback.print_exc(file=error_log)
1132+ error_log.close()
1133+
1134+
1135+request = make_request()
1136+os.mkdir(DIRECTORY)
1137+logger = WebTestLogger.make_in_directory(DIRECTORY, request, True)
1138+runner = EC2Runner(
1139+ daemonize=True,
1140+ pid_filename=PID_FILENAME,
1141+ shutdown_when_done=False)
1142+runner.run("test daemonization interaction", prepare_files, logger)
1143
1144=== modified file 'lib/devscripts/ec2test/tests/test_remote.py'
1145--- lib/devscripts/ec2test/tests/test_remote.py 2010-04-29 09:23:08 +0000
1146+++ lib/devscripts/ec2test/tests/test_remote.py 2010-08-18 18:07:59 +0000
1147@@ -5,14 +5,72 @@
1148
1149 __metaclass__ = type
1150
1151+from email.mime.application import MIMEApplication
1152+from email.mime.text import MIMEText
1153+import gzip
1154+from itertools import izip
1155+import os
1156 from StringIO import StringIO
1157+import subprocess
1158 import sys
1159+import tempfile
1160+import time
1161+import traceback
1162 import unittest
1163
1164-from devscripts.ec2test.remote import SummaryResult
1165-
1166-
1167-class TestSummaryResult(unittest.TestCase):
1168+from bzrlib.config import GlobalConfig
1169+from bzrlib.tests import TestCaseWithTransport
1170+
1171+from testtools import TestCase
1172+from testtools.content import Content
1173+from testtools.content_type import ContentType
1174+
1175+from devscripts.ec2test.remote import (
1176+ FlagFallStream,
1177+ gunzip_data,
1178+ gzip_data,
1179+ remove_pidfile,
1180+ Request,
1181+ SummaryResult,
1182+ WebTestLogger,
1183+ write_pidfile,
1184+ )
1185+
1186+
1187+class TestFlagFallStream(TestCase):
1188+ """Tests for `FlagFallStream`."""
1189+
1190+ def test_doesnt_write_before_flag(self):
1191+ # A FlagFallStream does not forward any writes before it sees the
1192+ # 'flag'.
1193+ stream = StringIO()
1194+ flag = self.getUniqueString('flag')
1195+ flagfall = FlagFallStream(stream, flag)
1196+ flagfall.write('foo')
1197+ flagfall.flush()
1198+ self.assertEqual('', stream.getvalue())
1199+
1200+ def test_writes_after_flag(self):
1201+ # After a FlagFallStream sees the flag, it forwards all writes.
1202+ stream = StringIO()
1203+ flag = self.getUniqueString('flag')
1204+ flagfall = FlagFallStream(stream, flag)
1205+ flagfall.write('foo')
1206+ flagfall.write(flag)
1207+ flagfall.write('bar')
1208+ self.assertEqual('%sbar' % (flag,), stream.getvalue())
1209+
1210+ def test_mixed_write(self):
1211+ # If a single call to write has pre-flagfall and post-flagfall data in
1212+ # it, then only the post-flagfall data is forwarded to the stream.
1213+ stream = StringIO()
1214+ flag = self.getUniqueString('flag')
1215+ flagfall = FlagFallStream(stream, flag)
1216+ flagfall.write('foo%sbar' % (flag,))
1217+ self.assertEqual('%sbar' % (flag,), stream.getvalue())
1218+
1219+
1220+class TestSummaryResult(TestCase):
1221 """Tests for `SummaryResult`."""
1222
1223 def makeException(self, factory=None, *args, **kwargs):
1224@@ -23,50 +81,555 @@
1225 except:
1226 return sys.exc_info()
1227
1228- def test_printError(self):
1229- # SummaryResult.printError() prints out the name of the test, the kind
1230+ def test_formatError(self):
1231+ # SummaryResult._formatError() combines the name of the test, the kind
1232 # of error and the details of the error in a nicely-formatted way.
1233- stream = StringIO()
1234- result = SummaryResult(stream)
1235- result.printError('FOO', 'test', 'error')
1236- expected = '%sFOO: test\n%serror\n' % (
1237+ result = SummaryResult(None)
1238+ output = result._formatError('FOO', 'test', 'error')
1239+ expected = '%s\nFOO: test\n%s\nerror\n' % (
1240 result.double_line, result.single_line)
1241- self.assertEqual(expected, stream.getvalue())
1242+ self.assertEqual(expected, output)
1243
1244 def test_addError(self):
1245- # SummaryResult.addError() prints a nicely-formatted error.
1246- #
1247- # First, use printError to build the error text we expect.
1248+ # SummaryResult.addError doesn't write immediately.
1249+ stream = StringIO()
1250 test = self
1251- stream = StringIO()
1252+ error = self.makeException()
1253 result = SummaryResult(stream)
1254- error = self.makeException()
1255- result.printError(
1256+ expected = result._formatError(
1257 'ERROR', test, result._exc_info_to_string(error, test))
1258- expected = stream.getvalue()
1259- # Now, call addError and check that it matches.
1260- stream = StringIO()
1261- result = SummaryResult(stream)
1262 result.addError(test, error)
1263 self.assertEqual(expected, stream.getvalue())
1264
1265- def test_addFailure(self):
1266- # SummaryResult.addFailure() prints a nicely-formatted error.
1267- #
1268- # First, use printError to build the error text we expect.
1269+ def test_addFailure_does_not_write_immediately(self):
1270+ # SummaryResult.addFailure doesn't write immediately.
1271+ stream = StringIO()
1272 test = self
1273- stream = StringIO()
1274+ error = self.makeException()
1275 result = SummaryResult(stream)
1276- error = self.makeException(test.failureException)
1277- result.printError(
1278+ expected = result._formatError(
1279 'FAILURE', test, result._exc_info_to_string(error, test))
1280- expected = stream.getvalue()
1281- # Now, call addFailure and check that it matches.
1282- stream = StringIO()
1283- result = SummaryResult(stream)
1284 result.addFailure(test, error)
1285 self.assertEqual(expected, stream.getvalue())
1286
1287+ def test_stopTest_flushes_stream(self):
1288+ # SummaryResult.stopTest() flushes the stream.
1289+ stream = StringIO()
1290+ flush_calls = []
1291+ stream.flush = lambda: flush_calls.append(None)
1292+ result = SummaryResult(stream)
1293+ result.stopTest(self)
1294+ self.assertEqual(1, len(flush_calls))
1295+
1296+
1297+class TestPidfileHelpers(TestCase):
1298+ """Tests for `write_pidfile` and `remove_pidfile`."""
1299+
1300+ def test_write_pidfile(self):
1301+ fd, path = tempfile.mkstemp()
1302+ self.addCleanup(os.unlink, path)
1303+ os.close(fd)
1304+ write_pidfile(path)
1305+ self.assertEqual(os.getpid(), int(open(path, 'r').read()))
1306+
1307+ def test_remove_pidfile(self):
1308+ fd, path = tempfile.mkstemp()
1309+ os.close(fd)
1310+ write_pidfile(path)
1311+ remove_pidfile(path)
1312+ self.assertEqual(False, os.path.exists(path))
1313+
1314+ def test_remove_nonexistent_pidfile(self):
1315+ directory = tempfile.mkdtemp()
1316+ path = os.path.join(directory, 'doesntexist')
1317+ remove_pidfile(path)
1318+ self.assertEqual(False, os.path.exists(path))
1319+
1320+
1321+class TestGzip(TestCase):
1322+ """Tests for gzip helpers."""
1323+
1324+ def test_gzip_data(self):
1325+ data = 'foobarbaz\n'
1326+ compressed = gzip_data(data)
1327+ fd, path = tempfile.mkstemp()
1328+ os.write(fd, compressed)
1329+ os.close(fd)
1330+ self.assertEqual(data, gzip.open(path, 'r').read())
1331+
1332+ def test_gunzip_data(self):
1333+ data = 'foobarbaz\n'
1334+ compressed = gzip_data(data)
1335+ self.assertEqual(data, gunzip_data(compressed))
1336+
1337+
1338+class RequestHelpers:
1339+
1340+ def make_trunk(self, parent_url='http://example.com/bzr/trunk'):
1341+ """Make a trunk branch suitable for use with `Request`.
1342+
1343+ `Request` expects to be given a path to a working tree that has a
1344+ branch with a configured parent URL, so this helper returns such a
1345+ working tree.
1346+ """
1347+ nick = parent_url.strip('/').split('/')[-1]
1348+ tree = self.make_branch_and_tree(nick)
1349+ tree.branch.set_parent(parent_url)
1350+ return tree
1351+
1352+ def make_request(self, branch_url=None, revno=None,
1353+ trunk=None, sourcecode_path=None,
1354+ emails=None, pqm_message=None):
1355+ """Make a request to test, specifying only things we care about.
1356+
1357+ Note that the returned request object will not ever send email, but
1358+ will instead log "sent" emails to `request.emails_sent`.
1359+ """
1360+ if trunk is None:
1361+ trunk = self.make_trunk()
1362+ if sourcecode_path is None:
1363+ sourcecode_path = self.make_sourcecode(
1364+ [('a', 'http://example.com/bzr/a', 2),
1365+ ('b', 'http://example.com/bzr/b', 3),
1366+ ('c', 'http://example.com/bzr/c', 5)])
1367+ request = Request(
1368+ branch_url, revno, trunk.basedir, sourcecode_path, emails,
1369+ pqm_message)
1370+ request.emails_sent = []
1371+ request._send_email = request.emails_sent.append
1372+ return request
1373+
1374+ def make_sourcecode(self, branches):
1375+ """Make a sourcecode directory with sample branches.
1376+
1377+ :param branches: A list of (name, parent_url, revno) tuples.
1378+ :return: The path to the sourcecode directory.
1379+ """
1380+ self.build_tree(['sourcecode/'])
1381+ for name, parent_url, revno in branches:
1382+ tree = self.make_branch_and_tree('sourcecode/%s' % (name,))
1383+ tree.branch.set_parent(parent_url)
1384+ for i in range(revno):
1385+ tree.commit(message=str(i))
1386+ return 'sourcecode/'
1387+
1388+ def make_logger(self, request=None, echo_to_stdout=False):
1389+ if request is None:
1390+ request = self.make_request()
1391+ return WebTestLogger(
1392+ 'full.log', 'summary.log', 'index.html', request, echo_to_stdout)
1393+
1394+
1395+class TestRequest(TestCaseWithTransport, RequestHelpers):
1396+ """Tests for `Request`."""
1397+
1398+ def test_doesnt_want_email(self):
1399+ # If no email addresses were provided, then the user does not want to
1400+ # receive email.
1401+ req = self.make_request()
1402+ self.assertEqual(False, req.wants_email)
1403+
1404+ def test_wants_email(self):
1405+ # If some email addresses were provided, then the user wants to
1406+ # receive email.
1407+ req = self.make_request(emails=['foo@example.com'])
1408+ self.assertEqual(True, req.wants_email)
1409+
1410+ def test_get_trunk_details(self):
1411+ parent = 'http://example.com/bzr/branch'
1412+ tree = self.make_trunk(parent)
1413+ req = self.make_request(trunk=tree)
1414+ self.assertEqual(
1415+ (parent, tree.branch.revno()), req.get_trunk_details())
1416+
1417+ def test_get_branch_details_no_commits(self):
1418+ req = self.make_request(trunk=self.make_trunk())
1419+ self.assertEqual(None, req.get_branch_details())
1420+
1421+ def test_get_branch_details_no_merge(self):
1422+ tree = self.make_trunk()
1423+ tree.commit(message='foo')
1424+ req = self.make_request(trunk=tree)
1425+ self.assertEqual(None, req.get_branch_details())
1426+
1427+ def test_get_branch_details_merge(self):
1428+ tree = self.make_trunk()
1429+ # Fake a merge, giving silly revision ids.
1430+ tree.add_pending_merge('foo', 'bar')
1431+ req = self.make_request(
1432+ branch_url='https://example.com/bzr/thing', revno=42, trunk=tree)
1433+ self.assertEqual(
1434+ ('https://example.com/bzr/thing', 42), req.get_branch_details())
1435+
1436+ def test_get_nick_trunk_only(self):
1437+ tree = self.make_trunk(parent_url='http://example.com/bzr/db-devel')
1438+ req = self.make_request(trunk=tree)
1439+ self.assertEqual('db-devel', req.get_nick())
1440+
1441+ def test_get_nick_merge(self):
1442+ tree = self.make_trunk()
1443+ # Fake a merge, giving silly revision ids.
1444+ tree.add_pending_merge('foo', 'bar')
1445+ req = self.make_request(
1446+ branch_url='https://example.com/bzr/thing', revno=42, trunk=tree)
1447+ self.assertEqual('thing', req.get_nick())
1448+
1449+ def test_get_merge_description_trunk_only(self):
1450+ tree = self.make_trunk(parent_url='http://example.com/bzr/db-devel')
1451+ req = self.make_request(trunk=tree)
1452+ self.assertEqual('db-devel', req.get_merge_description())
1453+
1454+ def test_get_merge_description_merge(self):
1455+ tree = self.make_trunk(parent_url='http://example.com/bzr/db-devel/')
1456+ tree.add_pending_merge('foo', 'bar')
1457+ req = self.make_request(
1458+ branch_url='https://example.com/bzr/thing', revno=42, trunk=tree)
1459+ self.assertEqual('thing => db-devel', req.get_merge_description())
1460+
1461+ def test_get_summary_commit(self):
1462+ # The summary commit message is the last commit message of the branch
1463+ # we're merging in.
1464+ trunk = self.make_trunk()
1465+ trunk.commit(message="a starting point")
1466+ thing_bzrdir = trunk.branch.bzrdir.sprout('thing')
1467+ thing = thing_bzrdir.open_workingtree()
1468+ thing.commit(message="a new thing")
1469+ trunk.merge_from_branch(thing.branch)
1470+ req = self.make_request(
1471+ branch_url='https://example.com/bzr/thing',
1472+ revno=thing.branch.revno(),
1473+ trunk=trunk)
1474+ self.assertEqual("a new thing", req.get_summary_commit())
1475+
1476+ def test_iter_dependency_branches(self):
1477+ # iter_dependency_branches yields a list of branches in the sourcecode
1478+ # directory, along with their parent URLs and their revnos.
1479+ sourcecode_branches = [
1480+ ('b', 'http://example.com/parent-b', 3),
1481+ ('a', 'http://example.com/parent-a', 2),
1482+ ('c', 'http://example.com/parent-c', 5),
1483+ ]
1484+ sourcecode_path = self.make_sourcecode(sourcecode_branches)
1485+ self.build_tree(
1486+ ['%s/not-a-branch/' % sourcecode_path,
1487+ '%s/just-a-file' % sourcecode_path])
1488+ req = self.make_request(sourcecode_path=sourcecode_path)
1489+ branches = list(req.iter_dependency_branches())
1490+ self.assertEqual(sorted(sourcecode_branches), branches)
1491+
1492+ def test_submit_to_pqm_no_message(self):
1493+ # If there's no PQM message, then 'submit_to_pqm' returns None.
1494+ req = self.make_request(pqm_message=None)
1495+ subject = req.submit_to_pqm(successful=True)
1496+ self.assertIs(None, subject)
1497+
1498+ def test_submit_to_pqm_no_message_doesnt_send(self):
1499+ # If there's no PQM message, then 'submit_to_pqm' returns None.
1500+ req = self.make_request(pqm_message=None)
1501+ req.submit_to_pqm(successful=True)
1502+ self.assertEqual([], req.emails_sent)
1503+
1504+ def test_submit_to_pqm_unsuccessful(self):
1505+ # submit_to_pqm returns the subject of the PQM mail even if it's
1506+ # handling a failed test run.
1507+ message = {'Subject:': 'My PQM message'}
1508+ req = self.make_request(pqm_message=message)
1509+ subject = req.submit_to_pqm(successful=False)
1510+ self.assertIs(message.get('Subject'), subject)
1511+
1512+ def test_submit_to_pqm_unsuccessful_no_email(self):
1513+ # submit_to_pqm doesn't send any email if the run was unsuccessful.
1514+ message = {'Subject:': 'My PQM message'}
1515+ req = self.make_request(pqm_message=message)
1516+ req.submit_to_pqm(successful=False)
1517+ self.assertEqual([], req.emails_sent)
1518+
1519+ def test_submit_to_pqm_successful(self):
1520+ # submit_to_pqm returns the subject of the PQM mail.
1521+ message = {'Subject:': 'My PQM message'}
1522+ req = self.make_request(pqm_message=message)
1523+ subject = req.submit_to_pqm(successful=True)
1524+ self.assertIs(message.get('Subject'), subject)
1525+ self.assertEqual([message], req.emails_sent)
1526+
1527+ def test_report_email_subject_success(self):
1528+ req = self.make_request(emails=['foo@example.com'])
1529+ email = req._build_report_email(True, 'foo', 'gobbledygook')
1530+ self.assertEqual('Test results: SUCCESS', email['Subject'])
1531+
1532+ def test_report_email_subject_failure(self):
1533+ req = self.make_request(emails=['foo@example.com'])
1534+ email = req._build_report_email(False, 'foo', 'gobbledygook')
1535+ self.assertEqual('Test results: FAILURE', email['Subject'])
1536+
1537+ def test_report_email_recipients(self):
1538+ req = self.make_request(emails=['foo@example.com', 'bar@example.com'])
1539+ email = req._build_report_email(False, 'foo', 'gobbledygook')
1540+ self.assertEqual('foo@example.com, bar@example.com', email['To'])
1541+
1542+ def test_report_email_sender(self):
1543+ req = self.make_request(emails=['foo@example.com'])
1544+ email = req._build_report_email(False, 'foo', 'gobbledygook')
1545+ self.assertEqual(GlobalConfig().username(), email['From'])
1546+
1547+ def test_report_email_body(self):
1548+ req = self.make_request(emails=['foo@example.com'])
1549+ email = req._build_report_email(False, 'foo', 'gobbledygook')
1550+ [body, attachment] = email.get_payload()
1551+ self.assertIsInstance(body, MIMEText)
1552+ self.assertEqual('inline', body['Content-Disposition'])
1553+ self.assertEqual('text/plain; charset="utf-8"', body['Content-Type'])
1554+ self.assertEqual("foo", body.get_payload())
1555+
1556+ def test_report_email_attachment(self):
1557+ req = self.make_request(emails=['foo@example.com'])
1558+ email = req._build_report_email(False, "foo", "gobbledygook")
1559+ [body, attachment] = email.get_payload()
1560+ self.assertIsInstance(attachment, MIMEApplication)
1561+ self.assertEqual('application/x-gzip', attachment['Content-Type'])
1562+ self.assertEqual(
1563+ 'attachment; filename="%s.log.gz"' % req.get_nick(),
1564+ attachment['Content-Disposition'])
1565+ self.assertEqual(
1566+ "gobbledygook", attachment.get_payload().decode('base64'))
1567+
1568+ def test_send_report_email_sends_email(self):
1569+ req = self.make_request(emails=['foo@example.com'])
1570+ expected = req._build_report_email(False, "foo", "gobbledygook")
1571+ req.send_report_email(False, "foo", "gobbledygook")
1572+ [observed] = req.emails_sent
1573+ # The standard library sucks. None of the MIME objects have __eq__
1574+ # implementations.
1575+ for expected_part, observed_part in izip(
1576+ expected.walk(), observed.walk()):
1577+ self.assertEqual(type(expected_part), type(observed_part))
1578+ self.assertEqual(expected_part.items(), observed_part.items())
1579+ self.assertEqual(
1580+ expected_part.is_multipart(), observed_part.is_multipart())
1581+ if not expected_part.is_multipart():
1582+ self.assertEqual(
1583+ expected_part.get_payload(), observed_part.get_payload())
1584+
1585+
1586+class TestWebTestLogger(TestCaseWithTransport, RequestHelpers):
1587+
1588+ def patch(self, obj, name, value):
1589+ orig = getattr(obj, name)
1590+ setattr(obj, name, value)
1591+ self.addCleanup(setattr, obj, name, orig)
1592+ return orig
1593+
1594+ def test_make_in_directory(self):
1595+ # WebTestLogger.make_in_directory constructs a logger that writes to a
1596+ # bunch of specific files in a directory.
1597+ self.build_tree(['www/'])
1598+ request = self.make_request()
1599+ logger = WebTestLogger.make_in_directory('www', request, False)
1600+ # A method on logger that writes to _everything_.
1601+ logger.prepare()
1602+ self.assertEqual(
1603+ logger.get_summary_contents(), open('www/summary.log').read())
1604+ self.assertEqual(
1605+ logger.get_full_log_contents(),
1606+ open('www/current_test.log').read())
1607+ self.assertEqual(
1608+ logger.get_index_contents(), open('www/index.html').read())
1609+
1610+ def test_initial_full_log(self):
1611+ # Initially, the full log has nothing in it.
1612+ logger = self.make_logger()
1613+ self.assertEqual('', logger.get_full_log_contents())
1614+
1615+ def test_initial_summary_contents(self):
1616+ # Initially, the summary log has nothing in it.
1617+ logger = self.make_logger()
1618+ self.assertEqual('', logger.get_summary_contents())
1619+
1620+ def test_got_line_no_echo(self):
1621+ # got_line forwards the line to the full log, but does not forward to
1622+ # stdout if echo_to_stdout is False.
1623+ stdout = StringIO()
1624+ self.patch(sys, 'stdout', stdout)
1625+ logger = self.make_logger(echo_to_stdout=False)
1626+ logger.got_line("output from script\n")
1627+ self.assertEqual(
1628+ "output from script\n", logger.get_full_log_contents())
1629+ self.assertEqual("", stdout.getvalue())
1630+
1631+ def test_got_line_echo(self):
1632+ # got_line forwards the line to the full log, and to stdout if
1633+ # echo_to_stdout is True.
1634+ stdout = StringIO()
1635+ self.patch(sys, 'stdout', stdout)
1636+ logger = self.make_logger(echo_to_stdout=True)
1637+ logger.got_line("output from script\n")
1638+ self.assertEqual(
1639+ "output from script\n", logger.get_full_log_contents())
1640+ self.assertEqual("output from script\n", stdout.getvalue())
1641+
1642+ def test_write_line(self):
1643+ # write_line writes a line to both the full log and the summary log.
1644+ logger = self.make_logger()
1645+ logger.write_line('foo')
1646+ self.assertEqual('foo\n', logger.get_full_log_contents())
1647+ self.assertEqual('foo\n', logger.get_summary_contents())
1648+
1649+ def test_error_in_testrunner(self):
1650+ # error_in_testrunner logs the traceback to the summary log in a very
1651+ # prominent way.
1652+ try:
1653+ 1/0
1654+ except ZeroDivisionError:
1655+ exc_info = sys.exc_info()
1656+ stack = ''.join(traceback.format_exception(*exc_info))
1657+ logger = self.make_logger()
1658+ logger.error_in_testrunner(exc_info)
1659+ self.assertEqual(
1660+ "\n\nERROR IN TESTRUNNER\n\n%s" % (stack,),
1661+ logger.get_summary_contents())
1662+
1663+
1664+class TestDaemonizationInteraction(TestCaseWithTransport, RequestHelpers):
1665+
1666+ script_file = 'remote_daemonization_test.py'
1667+
1668+ def run_script(self, script_file, pidfile, directory, logfile):
1669+ path = os.path.join(os.path.dirname(__file__), script_file)
1670+ popen = subprocess.Popen(
1671+ [sys.executable, path, pidfile, directory, logfile],
1672+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1673+ stdout, stderr = popen.communicate()
1674+ self.assertEqual((0, '', ''), (popen.returncode, stdout, stderr))
1675+ # Make sure the daemon is finished doing its thing.
1676+ while os.path.exists(pidfile):
1677+ time.sleep(0.01)
1678+ # If anything was written to 'logfile' while the script was running,
1679+ # we want it to appear in our test errors. This way, stack traces in
1680+ # the script are visible to us as test runners.
1681+ if os.path.exists(logfile):
1682+ content_type = ContentType("text", "plain", {"charset": "utf8"})
1683+ content = Content(content_type, open(logfile).read)
1684+ self.addDetail('logfile', content)
1685+
1686+ def test_daemonization(self):
1687+ # Daemonizing something can do funny things to its behavior. This test
1688+ # runs a script that's very similar to remote.py but only does
1689+ # "logger.prepare".
1690+ pidfile = "%s.pid" % self.id()
1691+ directory = 'www'
1692+ logfile = "%s.log" % self.id()
1693+ self.run_script(self.script_file, pidfile, directory, logfile)
1694+ # Check that the output from the daemonized version matches the output
1695+ # from a normal version. Only checking one of the files, since this is
1696+ # more of a smoke test than a correctness test.
1697+ logger = self.make_logger()
1698+ logger.prepare()
1699+ expected_summary = logger.get_summary_contents()
1700+ observed_summary = open(os.path.join(directory, 'summary.log')).read()
1701+ # The first line contains a timestamp, so we ignore it.
1702+ self.assertEqual(
1703+ expected_summary.splitlines()[1:],
1704+ observed_summary.splitlines()[1:])
1705+
1706+
1707+class TestResultHandling(TestCaseWithTransport, RequestHelpers):
1708+ """Tests for how we handle the result at the end of the test suite."""
1709+
1710+ def get_body_text(self, email):
1711+ return email.get_payload()[0].get_payload()
1712+
1713+ def test_success_no_emails(self):
1714+ request = self.make_request(emails=[])
1715+ logger = self.make_logger(request=request)
1716+ logger.got_result(True)
1717+ self.assertEqual([], request.emails_sent)
1718+
1719+ def test_failure_no_emails(self):
1720+ request = self.make_request(emails=[])
1721+ logger = self.make_logger(request=request)
1722+ logger.got_result(False)
1723+ self.assertEqual([], request.emails_sent)
1724+
1725+ def test_submits_to_pqm_on_success(self):
1726+ message = {'Subject': 'foo'}
1727+ request = self.make_request(emails=[], pqm_message=message)
1728+ logger = self.make_logger(request=request)
1729+ logger.got_result(True)
1730+ self.assertEqual([message], request.emails_sent)
1731+
1732+ def test_records_pqm_submission_in_email(self):
1733+ message = {'Subject': 'foo'}
1734+ request = self.make_request(
1735+ emails=['foo@example.com'], pqm_message=message)
1736+ logger = self.make_logger(request=request)
1737+ logger.got_result(True)
1738+ [pqm_message, user_message] = request.emails_sent
1739+ self.assertEqual(message, pqm_message)
1740+ self.assertIn(
1741+ 'SUBMITTED TO PQM:\n%s' % (message['Subject'],),
1742+ self.get_body_text(user_message))
1743+
1744+ def test_doesnt_submit_to_pqm_no_failure(self):
1745+ message = {'Subject': 'foo'}
1746+ request = self.make_request(emails=[], pqm_message=message)
1747+ logger = self.make_logger(request=request)
1748+ logger.got_result(False)
1749+ self.assertEqual([], request.emails_sent)
1750+
1751+ def test_records_non_pqm_submission_in_email(self):
1752+ message = {'Subject': 'foo'}
1753+ request = self.make_request(
1754+ emails=['foo@example.com'], pqm_message=message)
1755+ logger = self.make_logger(request=request)
1756+ logger.got_result(False)
1757+ [user_message] = request.emails_sent
1758+ self.assertIn(
1759+ '**NOT** submitted to PQM:\n%s' % (message['Subject'],),
1760+ self.get_body_text(user_message))
1761+
1762+ def test_email_refers_to_attached_log(self):
1763+ request = self.make_request(emails=['foo@example.com'])
1764+ logger = self.make_logger(request=request)
1765+ logger.got_result(False)
1766+ [user_message] = request.emails_sent
1767+ self.assertIn(
1768+ '(See the attached file for the complete log)\n',
1769+ self.get_body_text(user_message))
1770+
1771+ def test_email_body_is_summary(self):
1772+ # The body of the email sent to the user is the summary file.
1773+ # XXX: JonathanLange 2010-08-17: We actually want to change this
1774+ # behaviour so that the summary log stays roughly the same as it is
1775+ # now and can vary independently from the contents of the sent
1776+ # email. We probably just want the contents of the email to be a list
1777+ # of failing tests.
1778+ request = self.make_request(emails=['foo@example.com'])
1779+ logger = self.make_logger(request=request)
1780+ logger.get_summary_stream().write('bar\nbaz\nqux\n')
1781+ logger.got_result(False)
1782+ [user_message] = request.emails_sent
1783+ self.assertEqual(
1784+ 'bar\nbaz\nqux\n\n(See the attached file for the complete log)\n',
1785+ self.get_body_text(user_message))
1786+
1787+ def test_gzip_of_full_log_attached(self):
1788+ # The full log is attached to the email.
1789+ request = self.make_request(emails=['foo@example.com'])
1790+ logger = self.make_logger(request=request)
1791+ logger.got_line("output from test process\n")
1792+ logger.got_line("more output\n")
1793+ logger.got_result(False)
1794+ [user_message] = request.emails_sent
1795+ [body, attachment] = user_message.get_payload()
1796+ self.assertEqual('application/x-gzip', attachment['Content-Type'])
1797+ self.assertEqual(
1798+ 'attachment; filename="%s.log.gz"' % request.get_nick(),
1799+ attachment['Content-Disposition'])
1800+ attachment_contents = attachment.get_payload().decode('base64')
1801+ uncompressed = gunzip_data(attachment_contents)
1802+ self.assertEqual(
1803+ "output from test process\nmore output\n", uncompressed)
1804+
1805
1806 def test_suite():
1807 return unittest.TestLoader().loadTestsFromName(__name__)