Merge https://gitlab.denx.de/u-boot/custodians/u-boot-spi
[oweals/u-boot.git] / test / py / conftest.py
1 # SPDX-License-Identifier: GPL-2.0
2 # Copyright (c) 2015 Stephen Warren
3 # Copyright (c) 2015-2016, NVIDIA CORPORATION. All rights reserved.
4
5 # Implementation of pytest run-time hook functions. These are invoked by
6 # pytest at certain points during operation, e.g. startup, for each executed
7 # test, at shutdown etc. These hooks perform functions such as:
8 # - Parsing custom command-line options.
9 # - Pullilng in user-specified board configuration.
10 # - Creating the U-Boot console test fixture.
11 # - Creating the HTML log file.
12 # - Monitoring each test's results.
13 # - Implementing custom pytest markers.
14
15 import atexit
16 import configparser
17 import errno
18 import io
19 import os
20 import os.path
21 import pytest
22 import re
23 from _pytest.runner import runtestprotocol
24 import sys
25
26 # Globals: The HTML log file, and the connection to the U-Boot console.
27 log = None
28 console = None
29
30 def mkdir_p(path):
31     """Create a directory path.
32
33     This includes creating any intermediate/parent directories. Any errors
34     caused due to already extant directories are ignored.
35
36     Args:
37         path: The directory path to create.
38
39     Returns:
40         Nothing.
41     """
42
43     try:
44         os.makedirs(path)
45     except OSError as exc:
46         if exc.errno == errno.EEXIST and os.path.isdir(path):
47             pass
48         else:
49             raise
50
51 def pytest_addoption(parser):
52     """pytest hook: Add custom command-line options to the cmdline parser.
53
54     Args:
55         parser: The pytest command-line parser.
56
57     Returns:
58         Nothing.
59     """
60
61     parser.addoption('--build-dir', default=None,
62         help='U-Boot build directory (O=)')
63     parser.addoption('--result-dir', default=None,
64         help='U-Boot test result/tmp directory')
65     parser.addoption('--persistent-data-dir', default=None,
66         help='U-Boot test persistent generated data directory')
67     parser.addoption('--board-type', '--bd', '-B', default='sandbox',
68         help='U-Boot board type')
69     parser.addoption('--board-identity', '--id', default='na',
70         help='U-Boot board identity/instance')
71     parser.addoption('--build', default=False, action='store_true',
72         help='Compile U-Boot before running tests')
73     parser.addoption('--buildman', default=False, action='store_true',
74         help='Use buildman to build U-Boot (assuming --build is given)')
75     parser.addoption('--gdbserver', default=None,
76         help='Run sandbox under gdbserver. The argument is the channel '+
77         'over which gdbserver should communicate, e.g. localhost:1234')
78
79 def pytest_configure(config):
80     """pytest hook: Perform custom initialization at startup time.
81
82     Args:
83         config: The pytest configuration.
84
85     Returns:
86         Nothing.
87     """
88     def parse_config(conf_file):
89         """Parse a config file, loading it into the ubconfig container
90
91         Args:
92             conf_file: Filename to load (within build_dir)
93
94         Raises
95             Exception if the file does not exist
96         """
97         dot_config = build_dir + '/' + conf_file
98         if not os.path.exists(dot_config):
99             raise Exception(conf_file + ' does not exist; ' +
100                             'try passing --build option?')
101
102         with open(dot_config, 'rt') as f:
103             ini_str = '[root]\n' + f.read()
104             ini_sio = io.StringIO(ini_str)
105             parser = configparser.RawConfigParser()
106             parser.read_file(ini_sio)
107             ubconfig.buildconfig.update(parser.items('root'))
108
109     global log
110     global console
111     global ubconfig
112
113     test_py_dir = os.path.dirname(os.path.abspath(__file__))
114     source_dir = os.path.dirname(os.path.dirname(test_py_dir))
115
116     board_type = config.getoption('board_type')
117     board_type_filename = board_type.replace('-', '_')
118
119     board_identity = config.getoption('board_identity')
120     board_identity_filename = board_identity.replace('-', '_')
121
122     build_dir = config.getoption('build_dir')
123     if not build_dir:
124         build_dir = source_dir + '/build-' + board_type
125     mkdir_p(build_dir)
126
127     result_dir = config.getoption('result_dir')
128     if not result_dir:
129         result_dir = build_dir
130     mkdir_p(result_dir)
131
132     persistent_data_dir = config.getoption('persistent_data_dir')
133     if not persistent_data_dir:
134         persistent_data_dir = build_dir + '/persistent-data'
135     mkdir_p(persistent_data_dir)
136
137     gdbserver = config.getoption('gdbserver')
138     if gdbserver and not board_type.startswith('sandbox'):
139         raise Exception('--gdbserver only supported with sandbox targets')
140
141     import multiplexed_log
142     log = multiplexed_log.Logfile(result_dir + '/test-log.html')
143
144     if config.getoption('build'):
145         if config.getoption('buildman'):
146             if build_dir != source_dir:
147                 dest_args = ['-o', build_dir, '-w']
148             else:
149                 dest_args = ['-i']
150             cmds = (['buildman', '--board', board_type] + dest_args,)
151             name = 'buildman'
152         else:
153             if build_dir != source_dir:
154                 o_opt = 'O=%s' % build_dir
155             else:
156                 o_opt = ''
157             cmds = (
158                 ['make', o_opt, '-s', board_type + '_defconfig'],
159                 ['make', o_opt, '-s', '-j{}'.format(os.cpu_count())],
160             )
161             name = 'make'
162
163         with log.section(name):
164             runner = log.get_runner(name, sys.stdout)
165             for cmd in cmds:
166                 runner.run(cmd, cwd=source_dir)
167             runner.close()
168             log.status_pass('OK')
169
170     class ArbitraryAttributeContainer(object):
171         pass
172
173     ubconfig = ArbitraryAttributeContainer()
174     ubconfig.brd = dict()
175     ubconfig.env = dict()
176
177     modules = [
178         (ubconfig.brd, 'u_boot_board_' + board_type_filename),
179         (ubconfig.env, 'u_boot_boardenv_' + board_type_filename),
180         (ubconfig.env, 'u_boot_boardenv_' + board_type_filename + '_' +
181             board_identity_filename),
182     ]
183     for (dict_to_fill, module_name) in modules:
184         try:
185             module = __import__(module_name)
186         except ImportError:
187             continue
188         dict_to_fill.update(module.__dict__)
189
190     ubconfig.buildconfig = dict()
191
192     # buildman -k puts autoconf.mk in the rootdir, so handle this as well
193     # as the standard U-Boot build which leaves it in include/autoconf.mk
194     parse_config('.config')
195     if os.path.exists(build_dir + '/' + 'autoconf.mk'):
196         parse_config('autoconf.mk')
197     else:
198         parse_config('include/autoconf.mk')
199
200     ubconfig.test_py_dir = test_py_dir
201     ubconfig.source_dir = source_dir
202     ubconfig.build_dir = build_dir
203     ubconfig.result_dir = result_dir
204     ubconfig.persistent_data_dir = persistent_data_dir
205     ubconfig.board_type = board_type
206     ubconfig.board_identity = board_identity
207     ubconfig.gdbserver = gdbserver
208     ubconfig.dtb = build_dir + '/arch/sandbox/dts/test.dtb'
209
210     env_vars = (
211         'board_type',
212         'board_identity',
213         'source_dir',
214         'test_py_dir',
215         'build_dir',
216         'result_dir',
217         'persistent_data_dir',
218     )
219     for v in env_vars:
220         os.environ['U_BOOT_' + v.upper()] = getattr(ubconfig, v)
221
222     if board_type.startswith('sandbox'):
223         import u_boot_console_sandbox
224         console = u_boot_console_sandbox.ConsoleSandbox(log, ubconfig)
225     else:
226         import u_boot_console_exec_attach
227         console = u_boot_console_exec_attach.ConsoleExecAttach(log, ubconfig)
228
229 re_ut_test_list = re.compile(r'_u_boot_list_2_(.*)_test_2_\1_test_(.*)\s*$')
230 def generate_ut_subtest(metafunc, fixture_name):
231     """Provide parametrization for a ut_subtest fixture.
232
233     Determines the set of unit tests built into a U-Boot binary by parsing the
234     list of symbols generated by the build process. Provides this information
235     to test functions by parameterizing their ut_subtest fixture parameter.
236
237     Args:
238         metafunc: The pytest test function.
239         fixture_name: The fixture name to test.
240
241     Returns:
242         Nothing.
243     """
244
245     fn = console.config.build_dir + '/u-boot.sym'
246     try:
247         with open(fn, 'rt') as f:
248             lines = f.readlines()
249     except:
250         lines = []
251     lines.sort()
252
253     vals = []
254     for l in lines:
255         m = re_ut_test_list.search(l)
256         if not m:
257             continue
258         vals.append(m.group(1) + ' ' + m.group(2))
259
260     ids = ['ut_' + s.replace(' ', '_') for s in vals]
261     metafunc.parametrize(fixture_name, vals, ids=ids)
262
263 def generate_config(metafunc, fixture_name):
264     """Provide parametrization for {env,brd}__ fixtures.
265
266     If a test function takes parameter(s) (fixture names) of the form brd__xxx
267     or env__xxx, the brd and env configuration dictionaries are consulted to
268     find the list of values to use for those parameters, and the test is
269     parametrized so that it runs once for each combination of values.
270
271     Args:
272         metafunc: The pytest test function.
273         fixture_name: The fixture name to test.
274
275     Returns:
276         Nothing.
277     """
278
279     subconfigs = {
280         'brd': console.config.brd,
281         'env': console.config.env,
282     }
283     parts = fixture_name.split('__')
284     if len(parts) < 2:
285         return
286     if parts[0] not in subconfigs:
287         return
288     subconfig = subconfigs[parts[0]]
289     vals = []
290     val = subconfig.get(fixture_name, [])
291     # If that exact name is a key in the data source:
292     if val:
293         # ... use the dict value as a single parameter value.
294         vals = (val, )
295     else:
296         # ... otherwise, see if there's a key that contains a list of
297         # values to use instead.
298         vals = subconfig.get(fixture_name+ 's', [])
299     def fixture_id(index, val):
300         try:
301             return val['fixture_id']
302         except:
303             return fixture_name + str(index)
304     ids = [fixture_id(index, val) for (index, val) in enumerate(vals)]
305     metafunc.parametrize(fixture_name, vals, ids=ids)
306
307 def pytest_generate_tests(metafunc):
308     """pytest hook: parameterize test functions based on custom rules.
309
310     Check each test function parameter (fixture name) to see if it is one of
311     our custom names, and if so, provide the correct parametrization for that
312     parameter.
313
314     Args:
315         metafunc: The pytest test function.
316
317     Returns:
318         Nothing.
319     """
320
321     for fn in metafunc.fixturenames:
322         if fn == 'ut_subtest':
323             generate_ut_subtest(metafunc, fn)
324             continue
325         generate_config(metafunc, fn)
326
327 @pytest.fixture(scope='session')
328 def u_boot_log(request):
329      """Generate the value of a test's log fixture.
330
331      Args:
332          request: The pytest request.
333
334      Returns:
335          The fixture value.
336      """
337
338      return console.log
339
340 @pytest.fixture(scope='session')
341 def u_boot_config(request):
342      """Generate the value of a test's u_boot_config fixture.
343
344      Args:
345          request: The pytest request.
346
347      Returns:
348          The fixture value.
349      """
350
351      return console.config
352
353 @pytest.fixture(scope='function')
354 def u_boot_console(request):
355     """Generate the value of a test's u_boot_console fixture.
356
357     Args:
358         request: The pytest request.
359
360     Returns:
361         The fixture value.
362     """
363
364     console.ensure_spawned()
365     return console
366
367 anchors = {}
368 tests_not_run = []
369 tests_failed = []
370 tests_xpassed = []
371 tests_xfailed = []
372 tests_skipped = []
373 tests_warning = []
374 tests_passed = []
375
376 def pytest_itemcollected(item):
377     """pytest hook: Called once for each test found during collection.
378
379     This enables our custom result analysis code to see the list of all tests
380     that should eventually be run.
381
382     Args:
383         item: The item that was collected.
384
385     Returns:
386         Nothing.
387     """
388
389     tests_not_run.append(item.name)
390
391 def cleanup():
392     """Clean up all global state.
393
394     Executed (via atexit) once the entire test process is complete. This
395     includes logging the status of all tests, and the identity of any failed
396     or skipped tests.
397
398     Args:
399         None.
400
401     Returns:
402         Nothing.
403     """
404
405     if console:
406         console.close()
407     if log:
408         with log.section('Status Report', 'status_report'):
409             log.status_pass('%d passed' % len(tests_passed))
410             if tests_warning:
411                 log.status_warning('%d passed with warning' % len(tests_warning))
412                 for test in tests_warning:
413                     anchor = anchors.get(test, None)
414                     log.status_warning('... ' + test, anchor)
415             if tests_skipped:
416                 log.status_skipped('%d skipped' % len(tests_skipped))
417                 for test in tests_skipped:
418                     anchor = anchors.get(test, None)
419                     log.status_skipped('... ' + test, anchor)
420             if tests_xpassed:
421                 log.status_xpass('%d xpass' % len(tests_xpassed))
422                 for test in tests_xpassed:
423                     anchor = anchors.get(test, None)
424                     log.status_xpass('... ' + test, anchor)
425             if tests_xfailed:
426                 log.status_xfail('%d xfail' % len(tests_xfailed))
427                 for test in tests_xfailed:
428                     anchor = anchors.get(test, None)
429                     log.status_xfail('... ' + test, anchor)
430             if tests_failed:
431                 log.status_fail('%d failed' % len(tests_failed))
432                 for test in tests_failed:
433                     anchor = anchors.get(test, None)
434                     log.status_fail('... ' + test, anchor)
435             if tests_not_run:
436                 log.status_fail('%d not run' % len(tests_not_run))
437                 for test in tests_not_run:
438                     anchor = anchors.get(test, None)
439                     log.status_fail('... ' + test, anchor)
440         log.close()
441 atexit.register(cleanup)
442
443 def setup_boardspec(item):
444     """Process any 'boardspec' marker for a test.
445
446     Such a marker lists the set of board types that a test does/doesn't
447     support. If tests are being executed on an unsupported board, the test is
448     marked to be skipped.
449
450     Args:
451         item: The pytest test item.
452
453     Returns:
454         Nothing.
455     """
456
457     required_boards = []
458     for boards in item.iter_markers('boardspec'):
459         board = boards.args[0]
460         if board.startswith('!'):
461             if ubconfig.board_type == board[1:]:
462                 pytest.skip('board "%s" not supported' % ubconfig.board_type)
463                 return
464         else:
465             required_boards.append(board)
466     if required_boards and ubconfig.board_type not in required_boards:
467         pytest.skip('board "%s" not supported' % ubconfig.board_type)
468
469 def setup_buildconfigspec(item):
470     """Process any 'buildconfigspec' marker for a test.
471
472     Such a marker lists some U-Boot configuration feature that the test
473     requires. If tests are being executed on an U-Boot build that doesn't
474     have the required feature, the test is marked to be skipped.
475
476     Args:
477         item: The pytest test item.
478
479     Returns:
480         Nothing.
481     """
482
483     for options in item.iter_markers('buildconfigspec'):
484         option = options.args[0]
485         if not ubconfig.buildconfig.get('config_' + option.lower(), None):
486             pytest.skip('.config feature "%s" not enabled' % option.lower())
487     for options in item.iter_markers('notbuildconfigspec'):
488         option = options.args[0]
489         if ubconfig.buildconfig.get('config_' + option.lower(), None):
490             pytest.skip('.config feature "%s" enabled' % option.lower())
491
492 def tool_is_in_path(tool):
493     for path in os.environ["PATH"].split(os.pathsep):
494         fn = os.path.join(path, tool)
495         if os.path.isfile(fn) and os.access(fn, os.X_OK):
496             return True
497     return False
498
499 def setup_requiredtool(item):
500     """Process any 'requiredtool' marker for a test.
501
502     Such a marker lists some external tool (binary, executable, application)
503     that the test requires. If tests are being executed on a system that
504     doesn't have the required tool, the test is marked to be skipped.
505
506     Args:
507         item: The pytest test item.
508
509     Returns:
510         Nothing.
511     """
512
513     for tools in item.iter_markers('requiredtool'):
514         tool = tools.args[0]
515         if not tool_is_in_path(tool):
516             pytest.skip('tool "%s" not in $PATH' % tool)
517
518 def start_test_section(item):
519     anchors[item.name] = log.start_section(item.name)
520
521 def pytest_runtest_setup(item):
522     """pytest hook: Configure (set up) a test item.
523
524     Called once for each test to perform any custom configuration. This hook
525     is used to skip the test if certain conditions apply.
526
527     Args:
528         item: The pytest test item.
529
530     Returns:
531         Nothing.
532     """
533
534     start_test_section(item)
535     setup_boardspec(item)
536     setup_buildconfigspec(item)
537     setup_requiredtool(item)
538
539 def pytest_runtest_protocol(item, nextitem):
540     """pytest hook: Called to execute a test.
541
542     This hook wraps the standard pytest runtestprotocol() function in order
543     to acquire visibility into, and record, each test function's result.
544
545     Args:
546         item: The pytest test item to execute.
547         nextitem: The pytest test item that will be executed after this one.
548
549     Returns:
550         A list of pytest reports (test result data).
551     """
552
553     log.get_and_reset_warning()
554     reports = runtestprotocol(item, nextitem=nextitem)
555     was_warning = log.get_and_reset_warning()
556
557     # In pytest 3, runtestprotocol() may not call pytest_runtest_setup() if
558     # the test is skipped. That call is required to create the test's section
559     # in the log file. The call to log.end_section() requires that the log
560     # contain a section for this test. Create a section for the test if it
561     # doesn't already exist.
562     if not item.name in anchors:
563         start_test_section(item)
564
565     failure_cleanup = False
566     if not was_warning:
567         test_list = tests_passed
568         msg = 'OK'
569         msg_log = log.status_pass
570     else:
571         test_list = tests_warning
572         msg = 'OK (with warning)'
573         msg_log = log.status_warning
574     for report in reports:
575         if report.outcome == 'failed':
576             if hasattr(report, 'wasxfail'):
577                 test_list = tests_xpassed
578                 msg = 'XPASSED'
579                 msg_log = log.status_xpass
580             else:
581                 failure_cleanup = True
582                 test_list = tests_failed
583                 msg = 'FAILED:\n' + str(report.longrepr)
584                 msg_log = log.status_fail
585             break
586         if report.outcome == 'skipped':
587             if hasattr(report, 'wasxfail'):
588                 failure_cleanup = True
589                 test_list = tests_xfailed
590                 msg = 'XFAILED:\n' + str(report.longrepr)
591                 msg_log = log.status_xfail
592                 break
593             test_list = tests_skipped
594             msg = 'SKIPPED:\n' + str(report.longrepr)
595             msg_log = log.status_skipped
596
597     if failure_cleanup:
598         console.drain_console()
599
600     test_list.append(item.name)
601     tests_not_run.remove(item.name)
602
603     try:
604         msg_log(msg)
605     except:
606         # If something went wrong with logging, it's better to let the test
607         # process continue, which may report other exceptions that triggered
608         # the logging issue (e.g. console.log wasn't created). Hence, just
609         # squash the exception. If the test setup failed due to e.g. syntax
610         # error somewhere else, this won't be seen. However, once that issue
611         # is fixed, if this exception still exists, it will then be logged as
612         # part of the test's stdout.
613         import traceback
614         print('Exception occurred while logging runtest status:')
615         traceback.print_exc()
616         # FIXME: Can we force a test failure here?
617
618     log.end_section(item.name)
619
620     if failure_cleanup:
621         console.cleanup_spawn()
622
623     return reports