1# SPDX-License-Identifier: GPL-2.0+
2# Copyright (c) 2014 Google, Inc
3#
4
5import io
6import os
7from pathlib import Path
8import re
9import shutil
10import sys
11import tempfile
12import time
13import unittest
14
15from buildman import board
16from buildman import boards
17from buildman import bsettings
18from buildman import cmdline
19from buildman import control
20from buildman import toolchain
21from u_boot_pylib import command
22from u_boot_pylib import gitutil
23from u_boot_pylib import terminal
24from u_boot_pylib import test_util
25from u_boot_pylib import tools
26
27settings_data = '''
28# Buildman settings file
29[global]
30
31[toolchain]
32
33[toolchain-alias]
34
35[make-flags]
36src=/home/sjg/c/src
37chroot=/home/sjg/c/chroot
38vboot=VBOOT_DEBUG=1 MAKEFLAGS_VBOOT=DEBUG=1 CFLAGS_EXTRA_VBOOT=-DUNROLL_LOOPS VBOOT_SOURCE=${src}/platform/vboot_reference
39chromeos_coreboot=VBOOT=${chroot}/build/link/usr ${vboot}
40chromeos_daisy=VBOOT=${chroot}/build/daisy/usr ${vboot}
41chromeos_peach=VBOOT=${chroot}/build/peach_pit/usr ${vboot}
42'''
43
44BOARDS = [
45    ['Active', 'arm', 'armv7', '', 'Tester', 'ARM Board 0', 'board0',  ''],
46    ['Active', 'arm', 'armv7', '', 'Tester', 'ARM Board 1', 'board1', ''],
47    ['Active', 'powerpc', 'powerpc', '', 'Tester', 'PowerPC board 1', 'board2', ''],
48    ['Active', 'sandbox', 'sandbox', '', 'Tester', 'Sandbox board', 'board4', ''],
49]
50
51commit_shortlog = """4aca821 patman: Avoid changing the order of tags
5239403bb patman: Use --no-pager' to stop git from forking a pager
53db6e6f2 patman: Remove the -a option
54f2ccf03 patman: Correct unit tests to run correctly
551d097f9 patman: Fix indentation in terminal.py
56d073747 patman: Support the 'reverse' option for 'git log
57"""
58
59commit_log = ["""commit 7f6b8315d18f683c5181d0c3694818c1b2a20dcd
60Author: Masahiro Yamada <yamada.m@jp.panasonic.com>
61Date:   Fri Aug 22 19:12:41 2014 +0900
62
63    buildman: refactor help message
64
65    "buildman [options]" is displayed by default.
66
67    Append the rest of help messages to parser.usage
68    instead of replacing it.
69
70    Besides, "-b <branch>" is not mandatory since commit fea5858e.
71    Drop it from the usage.
72
73    Signed-off-by: Masahiro Yamada <yamada.m@jp.panasonic.com>
74""",
75"""commit d0737479be6baf4db5e2cdbee123e96bc5ed0ba8
76Author: Simon Glass <sjg@chromium.org>
77Date:   Thu Aug 14 16:48:25 2014 -0600
78
79    patman: Support the 'reverse' option for 'git log'
80
81    This option is currently not supported, but needs to be, for buildman to
82    operate as expected.
83
84    Series-changes: 7
85    - Add new patch to fix the 'reverse' bug
86
87    Series-version: 8
88
89    Change-Id: I79078f792e8b390b8a1272a8023537821d45feda
90    Reported-by: York Sun <yorksun@freescale.com>
91    Signed-off-by: Simon Glass <sjg@chromium.org>
92
93""",
94"""commit 1d097f9ab487c5019152fd47bda126839f3bf9fc
95Author: Simon Glass <sjg@chromium.org>
96Date:   Sat Aug 9 11:44:32 2014 -0600
97
98    patman: Fix indentation in terminal.py
99
100    This code came from a different project with 2-character indentation. Fix
101    it for U-Boot.
102
103    Series-changes: 6
104    - Add new patch to fix indentation in teminal.py
105
106    Change-Id: I5a74d2ebbb3cc12a665f5c725064009ac96e8a34
107    Signed-off-by: Simon Glass <sjg@chromium.org>
108
109""",
110"""commit f2ccf03869d1e152c836515a3ceb83cdfe04a105
111Author: Simon Glass <sjg@chromium.org>
112Date:   Sat Aug 9 11:08:24 2014 -0600
113
114    patman: Correct unit tests to run correctly
115
116    It seems that doctest behaves differently now, and some of the unit tests
117    do not run. Adjust the tests to work correctly.
118
119     ./tools/patman/patman --test
120    <unittest.result.TestResult run=10 errors=0 failures=0>
121
122    Series-changes: 6
123    - Add new patch to fix patman unit tests
124
125    Change-Id: I3d2ca588f4933e1f9d6b1665a00e4ae58269ff3b
126
127""",
128"""commit db6e6f2f9331c5a37647d6668768d4a40b8b0d1c
129Author: Simon Glass <sjg@chromium.org>
130Date:   Sat Aug 9 12:06:02 2014 -0600
131
132    patman: Remove the -a option
133
134    It seems that this is no longer needed, since checkpatch.pl will catch
135    whitespace problems in patches. Also the option is not widely used, so
136    it seems safe to just remove it.
137
138    Series-changes: 6
139    - Add new patch to remove patman's -a option
140
141    Suggested-by: Masahiro Yamada <yamada.m@jp.panasonic.com>
142    Change-Id: I5821a1c75154e532c46513486ca40b808de7e2cc
143
144""",
145"""commit 39403bb4f838153028a6f21ca30bf100f3791133
146Author: Simon Glass <sjg@chromium.org>
147Date:   Thu Aug 14 21:50:52 2014 -0600
148
149    patman: Use --no-pager' to stop git from forking a pager
150
151""",
152"""commit 4aca821e27e97925c039e69fd37375b09c6f129c
153Author: Simon Glass <sjg@chromium.org>
154Date:   Fri Aug 22 15:57:39 2014 -0600
155
156    patman: Avoid changing the order of tags
157
158    patman collects tags that it sees in the commit and places them nicely
159    sorted at the end of the patch. However, this is not really necessary and
160    in fact is apparently not desirable.
161
162    Series-changes: 9
163    - Add new patch to avoid changing the order of tags
164
165    Series-version: 9
166
167    Suggested-by: Masahiro Yamada <yamada.m@jp.panasonic.com>
168    Change-Id: Ib1518588c1a189ad5c3198aae76f8654aed8d0db
169"""]
170
171TEST_BRANCH = '__testbranch'
172
173class TestFunctional(unittest.TestCase):
174    """Functional test for buildman.
175
176    This aims to test from just below the invocation of buildman (parsing
177    of arguments) to 'make' and 'git' invocation. It is not a true
178    emd-to-end test, as it mocks git, make and the tool chain. But this
179    makes it easier to detect when the builder is doing the wrong thing,
180    since in many cases this test code will fail. For example, only a
181    very limited subset of 'git' arguments is supported - anything
182    unexpected will fail.
183    """
184    def setUp(self):
185        self._base_dir = tempfile.mkdtemp()
186        self._output_dir = tempfile.mkdtemp()
187        self._git_dir = os.path.join(self._base_dir, 'src')
188        self._buildman_pathname = sys.argv[0]
189        self._buildman_dir = os.path.dirname(os.path.realpath(sys.argv[0]))
190        command.TEST_RESULT = self._HandleCommand
191        bsettings.setup(None)
192        bsettings.add_file(settings_data)
193        self.setupToolchains()
194        self._toolchains.Add('arm-gcc', test=False)
195        self._toolchains.Add('powerpc-gcc', test=False)
196        self._boards = boards.Boards()
197        for brd in BOARDS:
198            self._boards.add_board(board.Board(*brd))
199
200        # Directories where the source been cloned
201        self._clone_dirs = []
202        self._commits = len(commit_shortlog.splitlines()) + 1
203        self._total_builds = self._commits * len(BOARDS)
204
205        # Number of calls to make
206        self._make_calls = 0
207
208        # Map of [board, commit] to error messages
209        self._error = {}
210
211        self._test_branch = TEST_BRANCH
212
213        # Set to True to report missing blobs
214        self._missing = False
215
216        self._buildman_dir = os.path.dirname(os.path.realpath(sys.argv[0]))
217        self._test_dir = os.path.join(self._buildman_dir, 'test')
218
219        # Set up some fake source files
220        shutil.copytree(self._test_dir, self._git_dir)
221
222        # Avoid sending any output and clear all terminal output
223        terminal.set_print_test_mode()
224        terminal.get_print_test_lines()
225
226    def tearDown(self):
227        shutil.rmtree(self._base_dir)
228        shutil.rmtree(self._output_dir)
229
230    def setupToolchains(self):
231        self._toolchains = toolchain.Toolchains()
232        self._toolchains.Add('gcc', test=False)
233
234    def _RunBuildman(self, *args):
235        all_args = [self._buildman_pathname] + list(args)
236        return command.run_one(*all_args, capture=True, capture_stderr=True)
237
238    def _RunControl(self, *args, brds=False, clean_dir=False,
239                    test_thread_exceptions=False, get_builder=True):
240        """Run buildman
241
242        Args:
243            args: List of arguments to pass
244            brds: Boards object, or False to pass self._boards, or None to pass
245                None
246            clean_dir: Used for tests only, indicates that the existing output_dir
247                should be removed before starting the build
248            test_thread_exceptions: Uses for tests only, True to make the threads
249                raise an exception instead of reporting their result. This simulates
250                a failure in the code somewhere
251            get_builder (bool): Set self._builder to the resulting builder
252
253        Returns:
254            result code from buildman
255        """
256        sys.argv = [sys.argv[0]] + list(args)
257        args = cmdline.parse_args()
258        if brds == False:
259            brds = self._boards
260        result = control.do_buildman(
261            args, toolchains=self._toolchains, make_func=self._HandleMake,
262            brds=brds, clean_dir=clean_dir,
263            test_thread_exceptions=test_thread_exceptions)
264        if get_builder:
265            self._builder = control.TEST_BUILDER
266        return result
267
268    def testFullHelp(self):
269        command.TEST_RESULT = None
270        result = self._RunBuildman('-H')
271        help_file = os.path.join(self._buildman_dir, 'README.rst')
272        # Remove possible extraneous strings
273        extra = '::::::::::::::\n' + help_file + '\n::::::::::::::\n'
274        gothelp = result.stdout.replace(extra, '')
275        self.assertEqual(len(gothelp), os.path.getsize(help_file))
276        self.assertEqual(0, len(result.stderr))
277        self.assertEqual(0, result.return_code)
278
279    def testHelp(self):
280        command.TEST_RESULT = None
281        result = self._RunBuildman('-h')
282        help_file = os.path.join(self._buildman_dir, 'README.rst')
283        self.assertTrue(len(result.stdout) > 1000)
284        self.assertEqual(0, len(result.stderr))
285        self.assertEqual(0, result.return_code)
286
287    def testGitSetup(self):
288        """Test gitutils.Setup(), from outside the module itself"""
289        command.TEST_RESULT = command.CommandResult(return_code=1)
290        gitutil.setup()
291        self.assertEqual(gitutil.USE_NO_DECORATE, False)
292
293        command.TEST_RESULT = command.CommandResult(return_code=0)
294        gitutil.setup()
295        self.assertEqual(gitutil.USE_NO_DECORATE, True)
296
297    def _HandleCommandGitLog(self, args):
298        if args[-1] == '--':
299            args = args[:-1]
300        if '-n0' in args:
301            return command.CommandResult(return_code=0)
302        elif args[-1] == 'upstream/master..%s' % self._test_branch:
303            return command.CommandResult(return_code=0, stdout=commit_shortlog)
304        elif args[:3] == ['--no-color', '--no-decorate', '--reverse']:
305            if args[-1] == self._test_branch:
306                count = int(args[3][2:])
307                return command.CommandResult(return_code=0,
308                                            stdout=''.join(commit_log[:count]))
309
310        # Not handled, so abort
311        print('git log', args)
312        sys.exit(1)
313
314    def _HandleCommandGitConfig(self, args):
315        config = args[0]
316        if config == 'sendemail.aliasesfile':
317            return command.CommandResult(return_code=0)
318        elif config.startswith('branch.badbranch'):
319            return command.CommandResult(return_code=1)
320        elif config == 'branch.%s.remote' % self._test_branch:
321            return command.CommandResult(return_code=0, stdout='upstream\n')
322        elif config == 'branch.%s.merge' % self._test_branch:
323            return command.CommandResult(return_code=0,
324                                         stdout='refs/heads/master\n')
325
326        # Not handled, so abort
327        print('git config', args)
328        sys.exit(1)
329
330    def _HandleCommandGit(self, in_args):
331        """Handle execution of a git command
332
333        This uses a hacked-up parser.
334
335        Args:
336            in_args: Arguments after 'git' from the command line
337        """
338        git_args = []           # Top-level arguments to git itself
339        sub_cmd = None          # Git sub-command selected
340        args = []               # Arguments to the git sub-command
341        for arg in in_args:
342            if sub_cmd:
343                args.append(arg)
344            elif arg[0] == '-':
345                git_args.append(arg)
346            else:
347                if git_args and git_args[-1] in ['--git-dir', '--work-tree']:
348                    git_args.append(arg)
349                else:
350                    sub_cmd = arg
351        if sub_cmd == 'config':
352            return self._HandleCommandGitConfig(args)
353        elif sub_cmd == 'log':
354            return self._HandleCommandGitLog(args)
355        elif sub_cmd == 'clone':
356            return command.CommandResult(return_code=0)
357        elif sub_cmd == 'checkout':
358            return command.CommandResult(return_code=0)
359        elif sub_cmd == 'worktree':
360            return command.CommandResult(return_code=0)
361
362        # Not handled, so abort
363        print('git', git_args, sub_cmd, args)
364        sys.exit(1)
365
366    def _HandleCommandNm(self, args):
367        return command.CommandResult(return_code=0)
368
369    def _HandleCommandObjdump(self, args):
370        return command.CommandResult(return_code=0)
371
372    def _HandleCommandObjcopy(self, args):
373        return command.CommandResult(return_code=0)
374
375    def _HandleCommandSize(self, args):
376        return command.CommandResult(return_code=0)
377
378    def _HandleCommandCpp(self, args):
379        # args ['-nostdinc', '-P', '-I', '/tmp/tmp7f17xk_o/src', '-undef',
380        # '-x', 'assembler-with-cpp', fname]
381        fname = args[7]
382        buf = io.StringIO()
383        for line in tools.read_file(fname, False).splitlines():
384            if line.startswith('#include'):
385                # Example: #include <configs/renesas_rcar2.config>
386                m_incfname = re.match('#include <(.*)>', line)
387                data = tools.read_file(m_incfname.group(1), False)
388                for line in data.splitlines():
389                    print(line, file=buf)
390            else:
391                print(line, file=buf)
392        return command.CommandResult(stdout=buf.getvalue(), return_code=0)
393
394    def _HandleCommand(self, **kwargs):
395        """Handle a command execution.
396
397        The command is in kwargs['pipe-list'], as a list of pipes, each a
398        list of commands. The command should be emulated as required for
399        testing purposes.
400
401        Returns:
402            A CommandResult object
403        """
404        pipe_list = kwargs['pipe_list']
405        wc = False
406        if len(pipe_list) != 1:
407            if pipe_list[1] == ['wc', '-l']:
408                wc = True
409            else:
410                print('invalid pipe', kwargs)
411                sys.exit(1)
412        cmd = pipe_list[0][0]
413        args = pipe_list[0][1:]
414        result = None
415        if cmd == 'git':
416            result = self._HandleCommandGit(args)
417        elif cmd == './scripts/show-gnu-make':
418            return command.CommandResult(return_code=0, stdout='make')
419        elif cmd.endswith('nm'):
420            return self._HandleCommandNm(args)
421        elif cmd.endswith('objdump'):
422            return self._HandleCommandObjdump(args)
423        elif cmd.endswith('objcopy'):
424            return self._HandleCommandObjcopy(args)
425        elif cmd.endswith( 'size'):
426            return self._HandleCommandSize(args)
427        elif cmd.endswith( 'cpp'):
428            return self._HandleCommandCpp(args)
429
430        if not result:
431            # Not handled, so abort
432            print('unknown command', kwargs)
433            sys.exit(1)
434
435        if wc:
436            result.stdout = len(result.stdout.splitlines())
437        return result
438
439    def _HandleMake(self, commit, brd, stage, cwd, *args, **kwargs):
440        """Handle execution of 'make'
441
442        Args:
443            commit: Commit object that is being built
444            brd: Board object that is being built
445            stage: Stage that we are at (mrproper, config, build)
446            cwd: Directory where make should be run
447            args: Arguments to pass to make
448            kwargs: Arguments to pass to command.run_one()
449        """
450        self._make_calls += 1
451        out_dir = ''
452        for arg in args:
453            if arg.startswith('O='):
454                out_dir = arg[2:]
455        if stage == 'mrproper':
456            return command.CommandResult(return_code=0)
457        elif stage == 'config':
458            fname = os.path.join(cwd or '', out_dir, '.config')
459            tools.write_file(fname, b'CONFIG_SOMETHING=1')
460            return command.CommandResult(return_code=0,
461                    combined='Test configuration complete')
462        elif stage == 'oldconfig':
463            return command.CommandResult(return_code=0)
464        elif stage == 'build':
465            stderr = ''
466            fname = os.path.join(cwd or '', out_dir, 'u-boot')
467            tools.write_file(fname, b'U-Boot')
468
469            # Handle missing blobs
470            if self._missing:
471                if 'BINMAN_ALLOW_MISSING=1' in args:
472                    stderr = '''+Image 'main-section' is missing external blobs and is non-functional: intel-descriptor intel-ifwi intel-fsp-m intel-fsp-s intel-vbt
473Image 'main-section' has faked external blobs and is non-functional: descriptor.bin fsp_m.bin fsp_s.bin vbt.bin
474
475Some images are invalid'''
476                else:
477                    stderr = "binman: Filename 'fsp.bin' not found in input path"
478            elif type(commit) is not str:
479                stderr = self._error.get((brd.target, commit.sequence))
480
481            if stderr:
482                return command.CommandResult(return_code=2, stderr=stderr)
483            return command.CommandResult(return_code=0)
484
485        # Not handled, so abort
486        print('_HandleMake failure: make', stage)
487        sys.exit(1)
488
489    # Example function to print output lines
490    def print_lines(self, lines):
491        print(len(lines))
492        for line in lines:
493            print(line)
494        #self.print_lines(terminal.get_print_test_lines())
495
496    def testNoBoards(self):
497        """Test that buildman aborts when there are no boards"""
498        self._boards = boards.Boards()
499        with self.assertRaises(SystemExit):
500            self._RunControl()
501
502    def testCurrentSource(self):
503        """Very simple test to invoke buildman on the current source"""
504        self.setupToolchains();
505        self._RunControl('-o', self._output_dir)
506        lines = terminal.get_print_test_lines()
507        self.assertIn('Building current source for %d boards' % len(BOARDS),
508                      lines[0].text)
509
510    def testBadBranch(self):
511        """Test that we can detect an invalid branch"""
512        with self.assertRaises(ValueError):
513            self._RunControl('-b', 'badbranch')
514
515    def testBadToolchain(self):
516        """Test that missing toolchains are detected"""
517        self.setupToolchains();
518        ret_code = self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
519        lines = terminal.get_print_test_lines()
520
521        # Buildman always builds the upstream commit as well
522        self.assertIn('Building %d commits for %d boards' %
523                (self._commits, len(BOARDS)), lines[0].text)
524        self.assertEqual(self._builder.count, self._total_builds)
525
526        # Only sandbox should succeed, the others don't have toolchains
527        self.assertEqual(self._builder.fail,
528                         self._total_builds - self._commits)
529        self.assertEqual(ret_code, 100)
530
531        for commit in range(self._commits):
532            for brd in self._boards.get_list():
533                if brd.arch != 'sandbox':
534                  errfile = self._builder.get_err_file(commit, brd.target)
535                  fd = open(errfile)
536                  self.assertEqual(
537                      fd.readlines(),
538                      [f'Tool chain error for {brd.arch}: '
539                       f"No tool chain found for arch '{brd.arch}'"])
540                  fd.close()
541
542    def testBranch(self):
543        """Test building a branch with all toolchains present"""
544        self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
545        self.assertEqual(self._builder.count, self._total_builds)
546        self.assertEqual(self._builder.fail, 0)
547
548    def testCount(self):
549        """Test building a specific number of commitst"""
550        self._RunControl('-b', TEST_BRANCH, '-c2', '-o', self._output_dir)
551        self.assertEqual(self._builder.count, 2 * len(BOARDS))
552        self.assertEqual(self._builder.fail, 0)
553        # Each board has a config, and then one make per commit
554        self.assertEqual(self._make_calls, len(BOARDS) * (1 + 2))
555
556    def testIncremental(self):
557        """Test building a branch twice - the second time should do nothing"""
558        self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
559
560        # Each board has a mrproper, config, and then one make per commit
561        self.assertEqual(self._make_calls, len(BOARDS) * (self._commits + 1))
562        self._make_calls = 0
563        self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir, clean_dir=False)
564        self.assertEqual(self._make_calls, 0)
565        self.assertEqual(self._builder.count, self._total_builds)
566        self.assertEqual(self._builder.fail, 0)
567
568    def testForceBuild(self):
569        """The -f flag should force a rebuild"""
570        self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
571        self._make_calls = 0
572        self._RunControl('-b', TEST_BRANCH, '-f', '-o', self._output_dir, clean_dir=False)
573        # Each board has a config and one make per commit
574        self.assertEqual(self._make_calls, len(BOARDS) * (self._commits + 1))
575
576    def testForceReconfigure(self):
577        """The -f flag should force a rebuild"""
578        self._RunControl('-b', TEST_BRANCH, '-C', '-o', self._output_dir)
579        # Each commit has a config and make
580        self.assertEqual(self._make_calls, len(BOARDS) * self._commits * 2)
581
582    def testMrproper(self):
583        """The -f flag should force a rebuild"""
584        self._RunControl('-b', TEST_BRANCH, '-m', '-o', self._output_dir)
585        # Each board has a mkproper, config and then one make per commit
586        self.assertEqual(self._make_calls, len(BOARDS) * (self._commits + 2))
587
588    def testErrors(self):
589        """Test handling of build errors"""
590        self._error['board2', 1] = 'fred\n'
591        self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir)
592        self.assertEqual(self._builder.count, self._total_builds)
593        self.assertEqual(self._builder.fail, 1)
594
595        # Remove the error. This should have no effect since the commit will
596        # not be rebuilt
597        del self._error['board2', 1]
598        self._make_calls = 0
599        self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir, clean_dir=False)
600        self.assertEqual(self._builder.count, self._total_builds)
601        self.assertEqual(self._make_calls, 0)
602        self.assertEqual(self._builder.fail, 1)
603
604        # Now use the -F flag to force rebuild of the bad commit
605        self._RunControl('-b', TEST_BRANCH, '-o', self._output_dir, '-F', clean_dir=False)
606        self.assertEqual(self._builder.count, self._total_builds)
607        self.assertEqual(self._builder.fail, 0)
608        self.assertEqual(self._make_calls, 2)
609
610    def testBranchWithSlash(self):
611        """Test building a branch with a '/' in the name"""
612        self._test_branch = '/__dev/__testbranch'
613        self._RunControl('-b', self._test_branch, '-o', self._output_dir,
614                         clean_dir=False)
615        self.assertEqual(self._builder.count, self._total_builds)
616        self.assertEqual(self._builder.fail, 0)
617
618    def testEnvironment(self):
619        """Test that the done and environment files are written to out-env"""
620        self._RunControl('-o', self._output_dir)
621        board0_dir = os.path.join(self._output_dir, 'current', 'board0')
622        self.assertTrue(os.path.exists(os.path.join(board0_dir, 'done')))
623        self.assertTrue(os.path.exists(os.path.join(board0_dir, 'out-env')))
624
625    def testEnvironmentUnicode(self):
626        """Test there are no unicode errors when the env has non-ASCII chars"""
627        try:
628            varname = b'buildman_test_var'
629            os.environb[varname] = b'strange\x80chars'
630            self.assertEqual(0, self._RunControl('-o', self._output_dir))
631            board0_dir = os.path.join(self._output_dir, 'current', 'board0')
632            self.assertTrue(os.path.exists(os.path.join(board0_dir, 'done')))
633            self.assertTrue(os.path.exists(os.path.join(board0_dir, 'out-env')))
634        finally:
635            del os.environb[varname]
636
637    def testWorkInOutput(self):
638        """Test the -w option which should write directly to the output dir"""
639        board_list = boards.Boards()
640        board_list.add_board(board.Board(*BOARDS[0]))
641        self._RunControl('-o', self._output_dir, '-w', clean_dir=False,
642                         brds=board_list)
643        self.assertTrue(
644            os.path.exists(os.path.join(self._output_dir, 'u-boot')))
645        self.assertTrue(
646            os.path.exists(os.path.join(self._output_dir, 'done')))
647        self.assertTrue(
648            os.path.exists(os.path.join(self._output_dir, 'out-env')))
649
650    def testWorkInOutputFail(self):
651        """Test the -w option failures"""
652        with self.assertRaises(SystemExit) as e:
653            self._RunControl('-o', self._output_dir, '-w', clean_dir=False)
654        self.assertIn("single board", str(e.exception))
655        self.assertFalse(
656            os.path.exists(os.path.join(self._output_dir, 'u-boot')))
657
658        board_list = boards.Boards()
659        board_list.add_board(board.Board(*BOARDS[0]))
660        with self.assertRaises(SystemExit) as e:
661            self._RunControl('-b', self._test_branch, '-o', self._output_dir,
662                             '-w', clean_dir=False, brds=board_list)
663        self.assertIn("single commit", str(e.exception))
664
665        board_list = boards.Boards()
666        board_list.add_board(board.Board(*BOARDS[0]))
667        with self.assertRaises(SystemExit) as e:
668            self._RunControl('-w', clean_dir=False)
669        self.assertIn("specify -o", str(e.exception))
670
671    def testThreadExceptions(self):
672        """Test that exceptions in threads are reported"""
673        with terminal.capture() as (stdout, stderr):
674            self.assertEqual(102, self._RunControl('-o', self._output_dir,
675                                                   test_thread_exceptions=True))
676        self.assertIn(
677            'Thread exception (use -T0 to run without threads): test exception',
678            stdout.getvalue())
679
680    def testBlobs(self):
681        """Test handling of missing blobs"""
682        self._missing = True
683
684        board0_dir = os.path.join(self._output_dir, 'current', 'board0')
685        errfile = os.path.join(board0_dir, 'err')
686        logfile = os.path.join(board0_dir, 'log')
687
688        # We expect failure when there are missing blobs
689        result = self._RunControl('board0', '-o', self._output_dir)
690        self.assertEqual(100, result)
691        self.assertTrue(os.path.exists(os.path.join(board0_dir, 'done')))
692        self.assertTrue(os.path.exists(errfile))
693        self.assertIn(b"Filename 'fsp.bin' not found in input path",
694                      tools.read_file(errfile))
695
696    def testBlobsAllowMissing(self):
697        """Allow missing blobs - still failure but a different exit code"""
698        self._missing = True
699        result = self._RunControl('board0', '-o', self._output_dir, '-M',
700                                  clean_dir=True)
701        self.assertEqual(101, result)
702        board0_dir = os.path.join(self._output_dir, 'current', 'board0')
703        errfile = os.path.join(board0_dir, 'err')
704        self.assertTrue(os.path.exists(errfile))
705        self.assertIn(b'Some images are invalid', tools.read_file(errfile))
706
707    def testBlobsWarning(self):
708        """Allow missing blobs and ignore warnings"""
709        self._missing = True
710        result = self._RunControl('board0', '-o', self._output_dir, '-MW')
711        self.assertEqual(0, result)
712        board0_dir = os.path.join(self._output_dir, 'current', 'board0')
713        errfile = os.path.join(board0_dir, 'err')
714        self.assertIn(b'Some images are invalid', tools.read_file(errfile))
715
716    def testBlobSettings(self):
717        """Test with no settings"""
718        self.assertEqual(False,
719                         control.get_allow_missing(False, False, 1, False))
720        self.assertEqual(True,
721                         control.get_allow_missing(True, False, 1, False))
722        self.assertEqual(False,
723                         control.get_allow_missing(True, True, 1, False))
724
725    def testBlobSettingsAlways(self):
726        """Test the 'always' policy"""
727        bsettings.set_item('global', 'allow-missing', 'always')
728        self.assertEqual(True,
729                         control.get_allow_missing(False, False, 1, False))
730        self.assertEqual(False,
731                         control.get_allow_missing(False, True, 1, False))
732
733    def testBlobSettingsBranch(self):
734        """Test the 'branch' policy"""
735        bsettings.set_item('global', 'allow-missing', 'branch')
736        self.assertEqual(False,
737                         control.get_allow_missing(False, False, 1, False))
738        self.assertEqual(True,
739                         control.get_allow_missing(False, False, 1, True))
740        self.assertEqual(False,
741                         control.get_allow_missing(False, True, 1, True))
742
743    def testBlobSettingsMultiple(self):
744        """Test the 'multiple' policy"""
745        bsettings.set_item('global', 'allow-missing', 'multiple')
746        self.assertEqual(False,
747                         control.get_allow_missing(False, False, 1, False))
748        self.assertEqual(True,
749                         control.get_allow_missing(False, False, 2, False))
750        self.assertEqual(False,
751                         control.get_allow_missing(False, True, 2, False))
752
753    def testBlobSettingsBranchMultiple(self):
754        """Test the 'branch multiple' policy"""
755        bsettings.set_item('global', 'allow-missing', 'branch multiple')
756        self.assertEqual(False,
757                         control.get_allow_missing(False, False, 1, False))
758        self.assertEqual(True,
759                         control.get_allow_missing(False, False, 1, True))
760        self.assertEqual(True,
761                         control.get_allow_missing(False, False, 2, False))
762        self.assertEqual(True,
763                         control.get_allow_missing(False, False, 2, True))
764        self.assertEqual(False,
765                         control.get_allow_missing(False, True, 2, True))
766
767    def check_command(self, *extra_args):
768        """Run a command with the extra arguments and return the commands used
769
770        Args:
771            extra_args (list of str): List of extra arguments
772
773        Returns:
774            list of str: Lines returned in the out-cmd file
775        """
776        self._RunControl('-o', self._output_dir, *extra_args)
777        board0_dir = os.path.join(self._output_dir, 'current', 'board0')
778        self.assertTrue(os.path.exists(os.path.join(board0_dir, 'done')))
779        cmd_fname = os.path.join(board0_dir, 'out-cmd')
780        self.assertTrue(os.path.exists(cmd_fname))
781        data = tools.read_file(cmd_fname)
782
783        config_fname = os.path.join(board0_dir, '.config')
784        self.assertTrue(os.path.exists(config_fname))
785        cfg_data = tools.read_file(config_fname)
786
787        return data.splitlines(), cfg_data
788
789    def testCmdFile(self):
790        """Test that the -cmd-out file is produced"""
791        lines = self.check_command()[0]
792        self.assertEqual(2, len(lines))
793        self.assertRegex(lines[0], b'make O=/.*board0_defconfig')
794        self.assertRegex(lines[0], b'make O=/.*-s.*')
795
796    def testNoLto(self):
797        """Test that the --no-lto flag works"""
798        lines = self.check_command('-L')[0]
799        self.assertIn(b'NO_LTO=1', lines[0])
800
801    def testReproducible(self):
802        """Test that the -r flag works"""
803        lines, cfg_data = self.check_command('-r')
804        self.assertIn(b'SOURCE_DATE_EPOCH=0', lines[0])
805
806        # We should see CONFIG_LOCALVERSION_AUTO unset
807        self.assertEqual(b'''CONFIG_SOMETHING=1
808# CONFIG_LOCALVERSION_AUTO is not set
809''', cfg_data)
810
811        with terminal.capture() as (stdout, stderr):
812            lines, cfg_data = self.check_command('-r', '-a', 'LOCALVERSION')
813        self.assertIn(b'SOURCE_DATE_EPOCH=0', lines[0])
814
815        # We should see CONFIG_LOCALVERSION_AUTO unset
816        self.assertEqual(b'''CONFIG_SOMETHING=1
817CONFIG_LOCALVERSION=y
818''', cfg_data)
819        self.assertIn('Not dropping LOCALVERSION_AUTO', stdout.getvalue())
820
821    def test_scan_defconfigs(self):
822        """Test scanning the defconfigs to obtain all the boards"""
823        src = self._git_dir
824
825        # Scan the test directory which contains a Kconfig and some *_defconfig
826        # files
827        params, warnings = self._boards.scan_defconfigs(src, src)
828
829        # We should get two boards
830        self.assertEqual(2, len(params))
831        self.assertFalse(warnings)
832        first = 0 if params[0]['target'] == 'board0' else 1
833        board0 = params[first]
834        board2 = params[1 - first]
835
836        self.assertEqual('arm', board0['arch'])
837        self.assertEqual('armv7', board0['cpu'])
838        self.assertEqual('-', board0['soc'])
839        self.assertEqual('Tester', board0['vendor'])
840        self.assertEqual('ARM Board 0', board0['board'])
841        self.assertEqual('config0', board0['config'])
842        self.assertEqual('board0', board0['target'])
843
844        self.assertEqual('powerpc', board2['arch'])
845        self.assertEqual('ppc', board2['cpu'])
846        self.assertEqual('mpc85xx', board2['soc'])
847        self.assertEqual('Tester', board2['vendor'])
848        self.assertEqual('PowerPC board 1', board2['board'])
849        self.assertEqual('config2', board2['config'])
850        self.assertEqual('board2', board2['target'])
851
852    def test_output_is_new(self):
853        """Test detecting new changes to Kconfig"""
854        base = self._base_dir
855        src = self._git_dir
856        config_dir = os.path.join(src, 'configs')
857        delay = 0.02
858
859        # Create a boards.cfg file
860        boards_cfg = os.path.join(base, 'boards.cfg')
861        content = b'''#
862# List of boards
863#   Automatically generated by buildman/boards.py: don't edit
864#
865# Status, Arch, CPU, SoC, Vendor, Board, Target, Config, Maintainers
866
867Active  aarch64     armv8 - armltd corstone1000 board0
868Active  aarch64     armv8 - armltd total_compute board2
869'''
870        # Check missing file
871        self.assertFalse(boards.output_is_new(boards_cfg, config_dir, src))
872
873        # Check that the board.cfg file is newer
874        time.sleep(delay)
875        tools.write_file(boards_cfg, content)
876        self.assertTrue(boards.output_is_new(boards_cfg, config_dir, src))
877
878        # Touch the Kconfig files after a show delay to avoid a race
879        time.sleep(delay)
880        Path(os.path.join(src, 'Kconfig')).touch()
881        self.assertFalse(boards.output_is_new(boards_cfg, config_dir, src))
882        Path(boards_cfg).touch()
883        self.assertTrue(boards.output_is_new(boards_cfg, config_dir, src))
884
885        # Touch a different Kconfig file
886        time.sleep(delay)
887        Path(os.path.join(src, 'Kconfig.something')).touch()
888        self.assertFalse(boards.output_is_new(boards_cfg, config_dir, src))
889        Path(boards_cfg).touch()
890        self.assertTrue(boards.output_is_new(boards_cfg, config_dir, src))
891
892        # Touch a MAINTAINERS file
893        time.sleep(delay)
894        Path(os.path.join(src, 'MAINTAINERS')).touch()
895        self.assertFalse(boards.output_is_new(boards_cfg, config_dir, src))
896
897        Path(boards_cfg).touch()
898        self.assertTrue(boards.output_is_new(boards_cfg, config_dir, src))
899
900        # Touch a defconfig file
901        time.sleep(delay)
902        Path(os.path.join(config_dir, 'board0_defconfig')).touch()
903        self.assertFalse(boards.output_is_new(boards_cfg, config_dir, src))
904        Path(boards_cfg).touch()
905        self.assertTrue(boards.output_is_new(boards_cfg, config_dir, src))
906
907        # Remove a board and check that the board.cfg file is now older
908        Path(os.path.join(config_dir, 'board0_defconfig')).unlink()
909        self.assertFalse(boards.output_is_new(boards_cfg, config_dir, src))
910
911    def test_maintainers(self):
912        """Test detecting boards without a MAINTAINERS entry"""
913        src = self._git_dir
914        main = os.path.join(src, 'boards', 'board0', 'MAINTAINERS')
915        other = os.path.join(src, 'boards', 'board2', 'MAINTAINERS')
916        kc_file = os.path.join(src, 'Kconfig')
917        config_dir = os.path.join(src, 'configs')
918        params_list, warnings = self._boards.build_board_list(config_dir, src)
919
920        # There should be two boards no warnings
921        self.assertEqual(2, len(params_list))
922        self.assertFalse(warnings)
923
924        # Set an invalid status line in the file
925        orig_data = tools.read_file(main, binary=False)
926        lines = ['S:      Other\n' if line.startswith('S:') else line
927                  for line in orig_data.splitlines(keepends=True)]
928        tools.write_file(main, ''.join(lines), binary=False)
929        params_list, warnings = self._boards.build_board_list(config_dir, src)
930        self.assertEqual(2, len(params_list))
931        params = params_list[0]
932        if params['target'] == 'board2':
933            params = params_list[1]
934        self.assertEqual('-', params['status'])
935        self.assertEqual(["WARNING: Other: unknown status for 'board0'"],
936                          warnings)
937
938        # Remove the status line (S:) from a file
939        lines = [line for line in orig_data.splitlines(keepends=True)
940                 if not line.startswith('S:')]
941        tools.write_file(main, ''.join(lines), binary=False)
942        params_list, warnings = self._boards.build_board_list(config_dir, src)
943        self.assertEqual(2, len(params_list))
944        self.assertEqual(["WARNING: -: unknown status for 'board0'"], warnings)
945
946        # Remove the configs/ line (F:) from a file - this is the last line
947        data = ''.join(orig_data.splitlines(keepends=True)[:-1])
948        tools.write_file(main, data, binary=False)
949        params_list, warnings = self._boards.build_board_list(config_dir, src)
950        self.assertEqual(2, len(params_list))
951        self.assertEqual(["WARNING: no maintainers for 'board0'"], warnings)
952
953        # Mark a board as orphaned - this should give a warning
954        lines = ['S: Orphaned' if line.startswith('S') else line
955                 for line in orig_data.splitlines(keepends=True)]
956        tools.write_file(main, ''.join(lines), binary=False)
957        params_list, warnings = self._boards.build_board_list(config_dir, src)
958        self.assertEqual(2, len(params_list))
959        self.assertEqual(["WARNING: no maintainers for 'board0'"], warnings)
960
961        # Change the maintainer to '-' - this should give a warning
962        lines = ['M: -' if line.startswith('M') else line
963                 for line in orig_data.splitlines(keepends=True)]
964        tools.write_file(main, ''.join(lines), binary=False)
965        params_list, warnings = self._boards.build_board_list(config_dir, src)
966        self.assertEqual(2, len(params_list))
967        self.assertEqual(["WARNING: -: unknown status for 'board0'"], warnings)
968
969        # Remove the maintainer line (M:) from a file
970        lines = [line for line in orig_data.splitlines(keepends=True)
971                 if not line.startswith('M:')]
972        tools.write_file(main, ''.join(lines), binary=False)
973        params_list, warnings = self._boards.build_board_list(config_dir, src)
974        self.assertEqual(2, len(params_list))
975        self.assertEqual(["WARNING: no maintainers for 'board0'"], warnings)
976
977        # Move the contents of the second file into this one, removing the
978        # second file, to check multiple records in a single file.
979        both_data = orig_data + tools.read_file(other, binary=False)
980        tools.write_file(main, both_data, binary=False)
981        os.remove(other)
982        params_list, warnings = self._boards.build_board_list(config_dir, src)
983        self.assertEqual(2, len(params_list))
984        self.assertFalse(warnings)
985
986        # Add another record, this should be ignored with a warning
987        extra = '\n\nAnother\nM: Fred\nF: configs/board9_defconfig\nS: other\n'
988        tools.write_file(main, both_data + extra, binary=False)
989        params_list, warnings = self._boards.build_board_list(config_dir, src)
990        self.assertEqual(2, len(params_list))
991        self.assertFalse(warnings)
992
993        # Add another TARGET to the Kconfig
994        tools.write_file(main, both_data, binary=False)
995        orig_kc_data = tools.read_file(kc_file)
996        extra = (b'''
997if TARGET_BOARD2
998config TARGET_OTHER
999\tbool "other"
1000\tdefault y
1001endif
1002''')
1003        tools.write_file(kc_file, orig_kc_data + extra)
1004        params_list, warnings = self._boards.build_board_list(config_dir, src,
1005                                                              warn_targets=True)
1006        self.assertEqual(2, len(params_list))
1007        self.assertEqual(
1008            ['WARNING: board2_defconfig: Duplicate TARGET_xxx: board2 and other'],
1009             warnings)
1010
1011        # Remove the TARGET_BOARD0 Kconfig option
1012        lines = [b'' if line == b'config TARGET_BOARD2\n' else line
1013                  for line in orig_kc_data.splitlines(keepends=True)]
1014        tools.write_file(kc_file, b''.join(lines))
1015        params_list, warnings = self._boards.build_board_list(config_dir, src,
1016                                                              warn_targets=True)
1017        self.assertEqual(2, len(params_list))
1018        self.assertEqual(
1019            ['WARNING: board2_defconfig: No TARGET_BOARD2 enabled'],
1020             warnings)
1021        tools.write_file(kc_file, orig_kc_data)
1022
1023        # Replace the last F: line of board 2 with an N: line
1024        data = ''.join(both_data.splitlines(keepends=True)[:-1])
1025        tools.write_file(main, data + 'N: oa.*2\n', binary=False)
1026        params_list, warnings = self._boards.build_board_list(config_dir, src)
1027        self.assertEqual(2, len(params_list))
1028        self.assertFalse(warnings)
1029
1030    def testRegenBoards(self):
1031        """Test that we can regenerate the boards.cfg file"""
1032        outfile = os.path.join(self._output_dir, 'test-boards.cfg')
1033        if os.path.exists(outfile):
1034            os.remove(outfile)
1035        with terminal.capture() as (stdout, stderr):
1036            result = self._RunControl('-R', outfile, brds=None,
1037                                      get_builder=False)
1038        self.assertTrue(os.path.exists(outfile))
1039
1040    def test_print_prefix(self):
1041        """Test that we can print the toolchain prefix"""
1042        with terminal.capture() as (stdout, stderr):
1043            result = self._RunControl('-A', 'board0')
1044        self.assertEqual('arm-\n', stdout.getvalue())
1045        self.assertEqual('', stderr.getvalue())
1046
1047    def test_exclude_one(self):
1048        """Test excluding a single board from an arch"""
1049        self._RunControl('arm', '-x', 'board1', '-o', self._output_dir)
1050        self.assertEqual(['board0'],
1051                         [b.target for b in self._boards.get_selected()])
1052
1053    def test_exclude_arch(self):
1054        """Test excluding an arch"""
1055        self._RunControl('-x', 'arm', '-o', self._output_dir)
1056        self.assertEqual(['board2', 'board4'],
1057                         [b.target for b in self._boards.get_selected()])
1058
1059    def test_exclude_comma(self):
1060        """Test excluding a comma-separated list of things"""
1061        self._RunControl('-x', 'arm,powerpc', '-o', self._output_dir)
1062        self.assertEqual(['board4'],
1063                         [b.target for b in self._boards.get_selected()])
1064
1065    def test_exclude_list(self):
1066        """Test excluding a list of things"""
1067        self._RunControl('-x', 'board2', '-x' 'board4', '-o', self._output_dir)
1068        self.assertEqual(['board0', 'board1'],
1069                         [b.target for b in self._boards.get_selected()])
1070
1071    def test_single_boards(self):
1072        """Test building single boards"""
1073        self._RunControl('--boards', 'board1', '-o', self._output_dir)
1074        self.assertEqual(1, self._builder.count)
1075
1076        self._RunControl('--boards', 'board1', '--boards', 'board2',
1077                         '-o', self._output_dir)
1078        self.assertEqual(2, self._builder.count)
1079
1080        self._RunControl('--boards', 'board1,board2', '--boards', 'board4',
1081                         '-o', self._output_dir)
1082        self.assertEqual(3, self._builder.count)
1083
1084    def test_print_arch(self):
1085        """Test that we can print the board architecture"""
1086        with terminal.capture() as (stdout, stderr):
1087            result = self._RunControl('--print-arch', 'board0')
1088        self.assertEqual('arm\n', stdout.getvalue())
1089        self.assertEqual('', stderr.getvalue())
1090
1091    def test_kconfig_scanner(self):
1092        """Test using the kconfig scanner to determine important values
1093
1094        Note that there is already a test_scan_defconfigs() which checks the
1095        higher-level scan_defconfigs() function. This test checks just the
1096        scanner itself
1097        """
1098        src = self._git_dir
1099        scanner = boards.KconfigScanner(src)
1100
1101        # First do a simple sanity check
1102        norm = os.path.join(src, 'board0_defconfig')
1103        tools.write_file(norm, 'CONFIG_TARGET_BOARD0=y', False)
1104        res = scanner.scan(norm, True)
1105        self.assertEqual(({
1106            'arch': 'arm',
1107            'cpu': 'armv7',
1108            'soc': '-',
1109            'vendor': 'Tester',
1110            'board': 'ARM Board 0',
1111            'config': 'config0',
1112            'target': 'board0'}, []), res)
1113
1114        # Check that the SoC cannot be changed and the filename does not affect
1115        # the resulting board
1116        tools.write_file(norm, '''CONFIG_TARGET_BOARD2=y
1117CONFIG_SOC="fred"
1118''', False)
1119        res = scanner.scan(norm, True)
1120        self.assertEqual(({
1121            'arch': 'powerpc',
1122            'cpu': 'ppc',
1123            'soc': 'mpc85xx',
1124            'vendor': 'Tester',
1125            'board': 'PowerPC board 1',
1126            'config': 'config2',
1127            'target': 'board0'}, []), res)
1128
1129        # Check handling of missing information
1130        tools.write_file(norm, '', False)
1131        res = scanner.scan(norm, True)
1132        self.assertEqual(({
1133            'arch': '-',
1134            'cpu': '-',
1135            'soc': '-',
1136            'vendor': '-',
1137            'board': '-',
1138            'config': '-',
1139            'target': 'board0'},
1140            ['WARNING: board0_defconfig: No TARGET_BOARD0 enabled']), res)
1141
1142        # check handling of #include files; see _HandleCommandCpp()
1143        inc = os.path.join(src, 'common')
1144        tools.write_file(inc, b'CONFIG_TARGET_BOARD0=y\n')
1145        tools.write_file(norm, f'#include <{inc}>', False)
1146        res = scanner.scan(norm, True)
1147        self.assertEqual(({
1148            'arch': 'arm',
1149            'cpu': 'armv7',
1150            'soc': '-',
1151            'vendor': 'Tester',
1152            'board': 'ARM Board 0',
1153            'config': 'config0',
1154            'target': 'board0'}, []), res)
1155
1156    def testTarget(self):
1157        """Test that the --target flag works"""
1158        lines = self.check_command('--target', 'u-boot.dtb')[0]
1159
1160        # It should not affect the defconfig line
1161        self.assertNotIn(b'u-boot.dtb', lines[0])
1162
1163        # It should appear at the end of the build line
1164        self.assertEqual(b'u-boot.dtb', lines[1].split()[-1])
1165