b92081863e6eeaed0069f3c597a9b07e36623a21
[platform/kernel/u-boot.git] / tools / buildman / func_test.py
1 # SPDX-License-Identifier: GPL-2.0+
2 # Copyright (c) 2014 Google, Inc
3 #
4
5 import os
6 import shutil
7 import sys
8 import tempfile
9 import unittest
10
11 from buildman import board
12 from buildman import bsettings
13 from buildman import cmdline
14 from buildman import control
15 from buildman import toolchain
16 from patman import command
17 from patman import gitutil
18 from patman import terminal
19 from patman import test_util
20 from patman import tools
21
22 settings_data = '''
23 # Buildman settings file
24
25 [toolchain]
26
27 [toolchain-alias]
28
29 [make-flags]
30 src=/home/sjg/c/src
31 chroot=/home/sjg/c/chroot
32 vboot=VBOOT_DEBUG=1 MAKEFLAGS_VBOOT=DEBUG=1 CFLAGS_EXTRA_VBOOT=-DUNROLL_LOOPS VBOOT_SOURCE=${src}/platform/vboot_reference
33 chromeos_coreboot=VBOOT=${chroot}/build/link/usr ${vboot}
34 chromeos_daisy=VBOOT=${chroot}/build/daisy/usr ${vboot}
35 chromeos_peach=VBOOT=${chroot}/build/peach_pit/usr ${vboot}
36 '''
37
38 boards = [
39     ['Active', 'arm', 'armv7', '', 'Tester', 'ARM Board 1', 'board0',  ''],
40     ['Active', 'arm', 'armv7', '', 'Tester', 'ARM Board 2', 'board1', ''],
41     ['Active', 'powerpc', 'powerpc', '', 'Tester', 'PowerPC board 1', 'board2', ''],
42     ['Active', 'sandbox', 'sandbox', '', 'Tester', 'Sandbox board', 'board4', ''],
43 ]
44
45 commit_shortlog = """4aca821 patman: Avoid changing the order of tags
46 39403bb patman: Use --no-pager' to stop git from forking a pager
47 db6e6f2 patman: Remove the -a option
48 f2ccf03 patman: Correct unit tests to run correctly
49 1d097f9 patman: Fix indentation in terminal.py
50 d073747 patman: Support the 'reverse' option for 'git log
51 """
52
53 commit_log = ["""commit 7f6b8315d18f683c5181d0c3694818c1b2a20dcd
54 Author: Masahiro Yamada <yamada.m@jp.panasonic.com>
55 Date:   Fri Aug 22 19:12:41 2014 +0900
56
57     buildman: refactor help message
58
59     "buildman [options]" is displayed by default.
60
61     Append the rest of help messages to parser.usage
62     instead of replacing it.
63
64     Besides, "-b <branch>" is not mandatory since commit fea5858e.
65     Drop it from the usage.
66
67     Signed-off-by: Masahiro Yamada <yamada.m@jp.panasonic.com>
68 """,
69 """commit d0737479be6baf4db5e2cdbee123e96bc5ed0ba8
70 Author: Simon Glass <sjg@chromium.org>
71 Date:   Thu Aug 14 16:48:25 2014 -0600
72
73     patman: Support the 'reverse' option for 'git log'
74
75     This option is currently not supported, but needs to be, for buildman to
76     operate as expected.
77
78     Series-changes: 7
79     - Add new patch to fix the 'reverse' bug
80
81     Series-version: 8
82
83     Change-Id: I79078f792e8b390b8a1272a8023537821d45feda
84     Reported-by: York Sun <yorksun@freescale.com>
85     Signed-off-by: Simon Glass <sjg@chromium.org>
86
87 """,
88 """commit 1d097f9ab487c5019152fd47bda126839f3bf9fc
89 Author: Simon Glass <sjg@chromium.org>
90 Date:   Sat Aug 9 11:44:32 2014 -0600
91
92     patman: Fix indentation in terminal.py
93
94     This code came from a different project with 2-character indentation. Fix
95     it for U-Boot.
96
97     Series-changes: 6
98     - Add new patch to fix indentation in teminal.py
99
100     Change-Id: I5a74d2ebbb3cc12a665f5c725064009ac96e8a34
101     Signed-off-by: Simon Glass <sjg@chromium.org>
102
103 """,
104 """commit f2ccf03869d1e152c836515a3ceb83cdfe04a105
105 Author: Simon Glass <sjg@chromium.org>
106 Date:   Sat Aug 9 11:08:24 2014 -0600
107
108     patman: Correct unit tests to run correctly
109
110     It seems that doctest behaves differently now, and some of the unit tests
111     do not run. Adjust the tests to work correctly.
112
113      ./tools/patman/patman --test
114     <unittest.result.TestResult run=10 errors=0 failures=0>
115
116     Series-changes: 6
117     - Add new patch to fix patman unit tests
118
119     Change-Id: I3d2ca588f4933e1f9d6b1665a00e4ae58269ff3b
120
121 """,
122 """commit db6e6f2f9331c5a37647d6668768d4a40b8b0d1c
123 Author: Simon Glass <sjg@chromium.org>
124 Date:   Sat Aug 9 12:06:02 2014 -0600
125
126     patman: Remove the -a option
127
128     It seems that this is no longer needed, since checkpatch.pl will catch
129     whitespace problems in patches. Also the option is not widely used, so
130     it seems safe to just remove it.
131
132     Series-changes: 6
133     - Add new patch to remove patman's -a option
134
135     Suggested-by: Masahiro Yamada <yamada.m@jp.panasonic.com>
136     Change-Id: I5821a1c75154e532c46513486ca40b808de7e2cc
137
138 """,
139 """commit 39403bb4f838153028a6f21ca30bf100f3791133
140 Author: Simon Glass <sjg@chromium.org>
141 Date:   Thu Aug 14 21:50:52 2014 -0600
142
143     patman: Use --no-pager' to stop git from forking a pager
144
145 """,
146 """commit 4aca821e27e97925c039e69fd37375b09c6f129c
147 Author: Simon Glass <sjg@chromium.org>
148 Date:   Fri Aug 22 15:57:39 2014 -0600
149
150     patman: Avoid changing the order of tags
151
152     patman collects tags that it sees in the commit and places them nicely
153     sorted at the end of the patch. However, this is not really necessary and
154     in fact is apparently not desirable.
155
156     Series-changes: 9
157     - Add new patch to avoid changing the order of tags
158
159     Series-version: 9
160
161     Suggested-by: Masahiro Yamada <yamada.m@jp.panasonic.com>
162     Change-Id: Ib1518588c1a189ad5c3198aae76f8654aed8d0db
163 """]
164
165 TEST_BRANCH = '__testbranch'
166
167 class TestFunctional(unittest.TestCase):
168     """Functional test for buildman.
169
170     This aims to test from just below the invocation of buildman (parsing
171     of arguments) to 'make' and 'git' invocation. It is not a true
172     emd-to-end test, as it mocks git, make and the tool chain. But this
173     makes it easier to detect when the builder is doing the wrong thing,
174     since in many cases this test code will fail. For example, only a
175     very limited subset of 'git' arguments is supported - anything
176     unexpected will fail.
177     """
178     def setUp(self):
179         self._base_dir = tempfile.mkdtemp()
180         self._output_dir = tempfile.mkdtemp()
181         self._git_dir = os.path.join(self._base_dir, 'src')
182         self._buildman_pathname = sys.argv[0]
183         self._buildman_dir = os.path.dirname(os.path.realpath(sys.argv[0]))
184         command.test_result = self._HandleCommand
185         bsettings.Setup(None)
186         bsettings.AddFile(settings_data)
187         self.setupToolchains()
188         self._toolchains.Add('arm-gcc', test=False)
189         self._toolchains.Add('powerpc-gcc', test=False)
190         self._boards = board.Boards()
191         for brd in boards:
192             self._boards.AddBoard(board.Board(*brd))
193
194         # Directories where the source been cloned
195         self._clone_dirs = []
196         self._commits = len(commit_shortlog.splitlines()) + 1
197         self._total_builds = self._commits * len(boards)
198
199         # Number of calls to make
200         self._make_calls = 0
201
202         # Map of [board, commit] to error messages
203         self._error = {}
204
205         self._test_branch = TEST_BRANCH
206
207         # Avoid sending any output and clear all terminal output
208         terminal.SetPrintTestMode()
209         terminal.GetPrintTestLines()
210
211     def tearDown(self):
212         shutil.rmtree(self._base_dir)
213         #shutil.rmtree(self._output_dir)
214
215     def setupToolchains(self):
216         self._toolchains = toolchain.Toolchains()
217         self._toolchains.Add('gcc', test=False)
218
219     def _RunBuildman(self, *args):
220         return command.RunPipe([[self._buildman_pathname] + list(args)],
221                 capture=True, capture_stderr=True)
222
223     def _RunControl(self, *args, boards=None, clean_dir=False,
224                     test_thread_exceptions=False):
225         """Run buildman
226
227         Args:
228             args: List of arguments to pass
229             boards:
230             clean_dir: Used for tests only, indicates that the existing output_dir
231                 should be removed before starting the build
232             test_thread_exceptions: Uses for tests only, True to make the threads
233                 raise an exception instead of reporting their result. This simulates
234                 a failure in the code somewhere
235
236         Returns:
237             result code from buildman
238         """
239         sys.argv = [sys.argv[0]] + list(args)
240         options, args = cmdline.ParseArgs()
241         result = control.DoBuildman(options, args, toolchains=self._toolchains,
242                 make_func=self._HandleMake, boards=boards or self._boards,
243                 clean_dir=clean_dir,
244                 test_thread_exceptions=test_thread_exceptions)
245         self._builder = control.builder
246         return result
247
248     def testFullHelp(self):
249         command.test_result = None
250         result = self._RunBuildman('-H')
251         help_file = os.path.join(self._buildman_dir, 'README')
252         # Remove possible extraneous strings
253         extra = '::::::::::::::\n' + help_file + '\n::::::::::::::\n'
254         gothelp = result.stdout.replace(extra, '')
255         self.assertEqual(len(gothelp), os.path.getsize(help_file))
256         self.assertEqual(0, len(result.stderr))
257         self.assertEqual(0, result.return_code)
258
259     def testHelp(self):
260         command.test_result = None
261         result = self._RunBuildman('-h')
262         help_file = os.path.join(self._buildman_dir, 'README')
263         self.assertTrue(len(result.stdout) > 1000)
264         self.assertEqual(0, len(result.stderr))
265         self.assertEqual(0, result.return_code)
266
267     def testGitSetup(self):
268         """Test gitutils.Setup(), from outside the module itself"""
269         command.test_result = command.CommandResult(return_code=1)
270         gitutil.Setup()
271         self.assertEqual(gitutil.use_no_decorate, False)
272
273         command.test_result = command.CommandResult(return_code=0)
274         gitutil.Setup()
275         self.assertEqual(gitutil.use_no_decorate, True)
276
277     def _HandleCommandGitLog(self, args):
278         if args[-1] == '--':
279             args = args[:-1]
280         if '-n0' in args:
281             return command.CommandResult(return_code=0)
282         elif args[-1] == 'upstream/master..%s' % self._test_branch:
283             return command.CommandResult(return_code=0, stdout=commit_shortlog)
284         elif args[:3] == ['--no-color', '--no-decorate', '--reverse']:
285             if args[-1] == self._test_branch:
286                 count = int(args[3][2:])
287                 return command.CommandResult(return_code=0,
288                                             stdout=''.join(commit_log[:count]))
289
290         # Not handled, so abort
291         print('git log', args)
292         sys.exit(1)
293
294     def _HandleCommandGitConfig(self, args):
295         config = args[0]
296         if config == 'sendemail.aliasesfile':
297             return command.CommandResult(return_code=0)
298         elif config.startswith('branch.badbranch'):
299             return command.CommandResult(return_code=1)
300         elif config == 'branch.%s.remote' % self._test_branch:
301             return command.CommandResult(return_code=0, stdout='upstream\n')
302         elif config == 'branch.%s.merge' % self._test_branch:
303             return command.CommandResult(return_code=0,
304                                          stdout='refs/heads/master\n')
305
306         # Not handled, so abort
307         print('git config', args)
308         sys.exit(1)
309
310     def _HandleCommandGit(self, in_args):
311         """Handle execution of a git command
312
313         This uses a hacked-up parser.
314
315         Args:
316             in_args: Arguments after 'git' from the command line
317         """
318         git_args = []           # Top-level arguments to git itself
319         sub_cmd = None          # Git sub-command selected
320         args = []               # Arguments to the git sub-command
321         for arg in in_args:
322             if sub_cmd:
323                 args.append(arg)
324             elif arg[0] == '-':
325                 git_args.append(arg)
326             else:
327                 if git_args and git_args[-1] in ['--git-dir', '--work-tree']:
328                     git_args.append(arg)
329                 else:
330                     sub_cmd = arg
331         if sub_cmd == 'config':
332             return self._HandleCommandGitConfig(args)
333         elif sub_cmd == 'log':
334             return self._HandleCommandGitLog(args)
335         elif sub_cmd == 'clone':
336             return command.CommandResult(return_code=0)
337         elif sub_cmd == 'checkout':
338             return command.CommandResult(return_code=0)
339         elif sub_cmd == 'worktree':
340             return command.CommandResult(return_code=0)
341
342         # Not handled, so abort
343         print('git', git_args, sub_cmd, args)
344         sys.exit(1)
345
346     def _HandleCommandNm(self, args):
347         return command.CommandResult(return_code=0)
348
349     def _HandleCommandObjdump(self, args):
350         return command.CommandResult(return_code=0)
351
352     def _HandleCommandObjcopy(self, args):
353         return command.CommandResult(return_code=0)
354
355     def _HandleCommandSize(self, args):
356         return command.CommandResult(return_code=0)
357
358     def _HandleCommand(self, **kwargs):
359         """Handle a command execution.
360
361         The command is in kwargs['pipe-list'], as a list of pipes, each a
362         list of commands. The command should be emulated as required for
363         testing purposes.
364
365         Returns:
366             A CommandResult object
367         """
368         pipe_list = kwargs['pipe_list']
369         wc = False
370         if len(pipe_list) != 1:
371             if pipe_list[1] == ['wc', '-l']:
372                 wc = True
373             else:
374                 print('invalid pipe', kwargs)
375                 sys.exit(1)
376         cmd = pipe_list[0][0]
377         args = pipe_list[0][1:]
378         result = None
379         if cmd == 'git':
380             result = self._HandleCommandGit(args)
381         elif cmd == './scripts/show-gnu-make':
382             return command.CommandResult(return_code=0, stdout='make')
383         elif cmd.endswith('nm'):
384             return self._HandleCommandNm(args)
385         elif cmd.endswith('objdump'):
386             return self._HandleCommandObjdump(args)
387         elif cmd.endswith('objcopy'):
388             return self._HandleCommandObjcopy(args)
389         elif cmd.endswith( 'size'):
390             return self._HandleCommandSize(args)
391
392         if not result:
393             # Not handled, so abort
394             print('unknown command', kwargs)
395             sys.exit(1)
396
397         if wc:
398             result.stdout = len(result.stdout.splitlines())
399         return result
400
401     def _HandleMake(self, commit, brd, stage, cwd, *args, **kwargs):
402         """Handle execution of 'make'
403
404         Args:
405             commit: Commit object that is being built
406             brd: Board object that is being built
407             stage: Stage that we are at (mrproper, config, build)
408             cwd: Directory where make should be run
409             args: Arguments to pass to make
410             kwargs: Arguments to pass to command.RunPipe()
411         """
412         self._make_calls += 1
413         if stage == 'mrproper':
414             return command.CommandResult(return_code=0)
415         elif stage == 'config':
416             return command.CommandResult(return_code=0,
417                     combined='Test configuration complete')
418         elif stage == 'build':
419             stderr = ''
420             out_dir = ''
421             for arg in args:
422                 if arg.startswith('O='):
423                     out_dir = arg[2:]
424             fname = os.path.join(cwd or '', out_dir, 'u-boot')
425             tools.write_file(fname, b'U-Boot')
426             if type(commit) is not str:
427                 stderr = self._error.get((brd.target, commit.sequence))
428             if stderr:
429                 return command.CommandResult(return_code=1, stderr=stderr)
430             return command.CommandResult(return_code=0)
431
432         # Not handled, so abort
433         print('make', stage)
434         sys.exit(1)
435
436     # Example function to print output lines
437     def print_lines(self, lines):
438         print(len(lines))
439         for line in lines:
440             print(line)
441         #self.print_lines(terminal.GetPrintTestLines())
442
443     def testNoBoards(self):
444         """Test that buildman aborts when there are no boards"""
445         self._boards = board.Boards()
446         with self.assertRaises(SystemExit):
447             self._RunControl()
448
449     def testCurrentSource(self):
450         """Very simple test to invoke buildman on the current source"""
451         self.setupToolchains();
452         self._RunControl('-o', self._output_dir)
453         lines = terminal.GetPrintTestLines()
454         self.assertIn('Building current source for %d boards' % len(boards),
455                       lines[0].text)
456
457     def testBadBranch(self):
458         """Test that we can detect an invalid branch"""
459         with self.assertRaises(ValueError):
460             self._RunControl('-b', 'badbranch')
461
462     def testBadToolchain(self):
463         """Test that missing toolchains are detected"""
464         self.setupToolchains();
465         ret_code = self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
466         lines = terminal.GetPrintTestLines()
467
468         # Buildman always builds the upstream commit as well
469         self.assertIn('Building %d commits for %d boards' %
470                 (self._commits, len(boards)), lines[0].text)
471         self.assertEqual(self._builder.count, self._total_builds)
472
473         # Only sandbox should succeed, the others don't have toolchains
474         self.assertEqual(self._builder.fail,
475                          self._total_builds - self._commits)
476         self.assertEqual(ret_code, 100)
477
478         for commit in range(self._commits):
479             for board in self._boards.GetList():
480                 if board.arch != 'sandbox':
481                   errfile = self._builder.GetErrFile(commit, board.target)
482                   fd = open(errfile)
483                   self.assertEqual(fd.readlines(),
484                           ['No tool chain for %s\n' % board.arch])
485                   fd.close()
486
487     def testBranch(self):
488         """Test building a branch with all toolchains present"""
489         self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
490         self.assertEqual(self._builder.count, self._total_builds)
491         self.assertEqual(self._builder.fail, 0)
492
493     def testCount(self):
494         """Test building a specific number of commitst"""
495         self._RunControl('-b', TEST_BRANCH, '-c2', '-o', self._output_dir)
496         self.assertEqual(self._builder.count, 2 * len(boards))
497         self.assertEqual(self._builder.fail, 0)
498         # Each board has a config, and then one make per commit
499         self.assertEqual(self._make_calls, len(boards) * (1 + 2))
500
501     def testIncremental(self):
502         """Test building a branch twice - the second time should do nothing"""
503         self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
504
505         # Each board has a mrproper, config, and then one make per commit
506         self.assertEqual(self._make_calls, len(boards) * (self._commits + 1))
507         self._make_calls = 0
508         self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir, clean_dir=False)
509         self.assertEqual(self._make_calls, 0)
510         self.assertEqual(self._builder.count, self._total_builds)
511         self.assertEqual(self._builder.fail, 0)
512
513     def testForceBuild(self):
514         """The -f flag should force a rebuild"""
515         self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
516         self._make_calls = 0
517         self._RunControl('-b', TEST_BRANCH, '-f', '-o', self._output_dir, clean_dir=False)
518         # Each board has a config and one make per commit
519         self.assertEqual(self._make_calls, len(boards) * (self._commits + 1))
520
521     def testForceReconfigure(self):
522         """The -f flag should force a rebuild"""
523         self._RunControl('-b', TEST_BRANCH, '-C', '-o', self._output_dir)
524         # Each commit has a config and make
525         self.assertEqual(self._make_calls, len(boards) * self._commits * 2)
526
527     def testForceReconfigure(self):
528         """The -f flag should force a rebuild"""
529         self._RunControl('-b', TEST_BRANCH, '-C', '-o', self._output_dir)
530         # Each commit has a config and make
531         self.assertEqual(self._make_calls, len(boards) * self._commits * 2)
532
533     def testMrproper(self):
534         """The -f flag should force a rebuild"""
535         self._RunControl('-b', TEST_BRANCH, '-m', '-o', self._output_dir)
536         # Each board has a mkproper, config and then one make per commit
537         self.assertEqual(self._make_calls, len(boards) * (self._commits + 2))
538
539     def testErrors(self):
540         """Test handling of build errors"""
541         self._error['board2', 1] = 'fred\n'
542         self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
543         self.assertEqual(self._builder.count, self._total_builds)
544         self.assertEqual(self._builder.fail, 1)
545
546         # Remove the error. This should have no effect since the commit will
547         # not be rebuilt
548         del self._error['board2', 1]
549         self._make_calls = 0
550         self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir, clean_dir=False)
551         self.assertEqual(self._builder.count, self._total_builds)
552         self.assertEqual(self._make_calls, 0)
553         self.assertEqual(self._builder.fail, 1)
554
555         # Now use the -F flag to force rebuild of the bad commit
556         self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir, '-F', clean_dir=False)
557         self.assertEqual(self._builder.count, self._total_builds)
558         self.assertEqual(self._builder.fail, 0)
559         self.assertEqual(self._make_calls, 2)
560
561     def testBranchWithSlash(self):
562         """Test building a branch with a '/' in the name"""
563         self._test_branch = '/__dev/__testbranch'
564         self._RunControl('-b', self._test_branch, clean_dir=False)
565         self.assertEqual(self._builder.count, self._total_builds)
566         self.assertEqual(self._builder.fail, 0)
567
568     def testEnvironment(self):
569         """Test that the done and environment files are written to out-env"""
570         self._RunControl('-o', self._output_dir)
571         board0_dir = os.path.join(self._output_dir, 'current', 'board0')
572         self.assertTrue(os.path.exists(os.path.join(board0_dir, 'done')))
573         self.assertTrue(os.path.exists(os.path.join(board0_dir, 'out-env')))
574
575     def testEnvironmentUnicode(self):
576         """Test there are no unicode errors when the env has non-ASCII chars"""
577         try:
578             varname = b'buildman_test_var'
579             os.environb[varname] = b'strange\x80chars'
580             self.assertEqual(0, self._RunControl('-o', self._output_dir))
581             board0_dir = os.path.join(self._output_dir, 'current', 'board0')
582             self.assertTrue(os.path.exists(os.path.join(board0_dir, 'done')))
583             self.assertTrue(os.path.exists(os.path.join(board0_dir, 'out-env')))
584         finally:
585             del os.environb[varname]
586
587     def testWorkInOutput(self):
588         """Test the -w option which should write directly to the output dir"""
589         board_list = board.Boards()
590         board_list.AddBoard(board.Board(*boards[0]))
591         self._RunControl('-o', self._output_dir, '-w', clean_dir=False,
592                          boards=board_list)
593         self.assertTrue(
594             os.path.exists(os.path.join(self._output_dir, 'u-boot')))
595         self.assertTrue(
596             os.path.exists(os.path.join(self._output_dir, 'done')))
597         self.assertTrue(
598             os.path.exists(os.path.join(self._output_dir, 'out-env')))
599
600     def testWorkInOutputFail(self):
601         """Test the -w option failures"""
602         with self.assertRaises(SystemExit) as e:
603             self._RunControl('-o', self._output_dir, '-w', clean_dir=False)
604         self.assertIn("single board", str(e.exception))
605         self.assertFalse(
606             os.path.exists(os.path.join(self._output_dir, 'u-boot')))
607
608         board_list = board.Boards()
609         board_list.AddBoard(board.Board(*boards[0]))
610         with self.assertRaises(SystemExit) as e:
611             self._RunControl('-b', self._test_branch, '-o', self._output_dir,
612                              '-w', clean_dir=False, boards=board_list)
613         self.assertIn("single commit", str(e.exception))
614
615         board_list = board.Boards()
616         board_list.AddBoard(board.Board(*boards[0]))
617         with self.assertRaises(SystemExit) as e:
618             self._RunControl('-w', clean_dir=False)
619         self.assertIn("specify -o", str(e.exception))
620
621     def testThreadExceptions(self):
622         """Test that exceptions in threads are reported"""
623         with test_util.capture_sys_output() as (stdout, stderr):
624             self.assertEqual(102, self._RunControl('-o', self._output_dir,
625                                                    test_thread_exceptions=True))
626         self.assertIn(
627             'Thread exception (use -T0 to run without threads): test exception',
628             stdout.getvalue())