1# SPDX-License-Identifier: GPL-2.0+
2# Copyright 2022 Google LLC
3# Written by Simon Glass <sjg@chromium.org>
4#
5
6"""Tests for the Bintool class"""
7
8import collections
9import os
10import shutil
11import tempfile
12import unittest
13import unittest.mock
14import urllib.error
15
16from binman import bintool
17from binman.bintool import Bintool
18
19from u_boot_pylib import command
20from u_boot_pylib import terminal
21from u_boot_pylib import test_util
22from u_boot_pylib import tools
23
24# pylint: disable=R0904
25class TestBintool(unittest.TestCase):
26    """Tests for the Bintool class"""
27    def setUp(self):
28        # Create a temporary directory for test files
29        self._indir = tempfile.mkdtemp(prefix='bintool.')
30        self.seq = None
31        self.count = None
32        self.fname = None
33        self.btools = None
34
35    def tearDown(self):
36        """Remove the temporary input directory and its contents"""
37        if self._indir:
38            shutil.rmtree(self._indir)
39        self._indir = None
40
41    def test_missing_btype(self):
42        """Test that unknown bintool types are detected"""
43        with self.assertRaises(ValueError) as exc:
44            Bintool.create('missing')
45        self.assertIn("No module named 'binman.btool.missing'",
46                      str(exc.exception))
47
48    def test_fresh_bintool(self):
49        """Check that the _testing bintool is not cached"""
50        btest = Bintool.create('_testing')
51        btest.present = True
52        btest2 = Bintool.create('_testing')
53        self.assertFalse(btest2.present)
54
55    def test_version(self):
56        """Check handling of a tool being present or absent"""
57        btest = Bintool.create('_testing')
58        with test_util.capture_sys_output() as (stdout, _):
59            btest.show()
60        self.assertFalse(btest.is_present())
61        self.assertIn('-', stdout.getvalue())
62        btest.present = True
63        self.assertTrue(btest.is_present())
64        self.assertEqual('123', btest.version())
65        with test_util.capture_sys_output() as (stdout, _):
66            btest.show()
67        self.assertIn('123', stdout.getvalue())
68
69    def test_fetch_present(self):
70        """Test fetching of a tool"""
71        btest = Bintool.create('_testing')
72        btest.present = True
73        col = terminal.Color()
74        self.assertEqual(bintool.PRESENT,
75                         btest.fetch_tool(bintool.FETCH_ANY, col, True))
76
77    @classmethod
78    def check_fetch_url(cls, fake_download, method):
79        """Check the output from fetching a tool
80
81        Args:
82            fake_download (function): Function to call instead of
83                tools.download()
84            method (bintool.FETCH_...: Fetch method to use
85
86        Returns:
87            str: Contents of stdout
88        """
89        btest = Bintool.create('_testing')
90        col = terminal.Color()
91        with unittest.mock.patch.object(tools, 'download',
92                                        side_effect=fake_download):
93            with test_util.capture_sys_output() as (stdout, _):
94                btest.fetch_tool(method, col, False)
95        return stdout.getvalue()
96
97    def test_fetch_url_err(self):
98        """Test an error while fetching a tool from a URL"""
99        def fail_download(url):
100            """Take the tools.download() function by raising an exception"""
101            raise urllib.error.URLError('my error')
102
103        stdout = self.check_fetch_url(fail_download, bintool.FETCH_ANY)
104        self.assertIn('my error', stdout)
105
106    def test_fetch_url_exception(self):
107        """Test an exception while fetching a tool from a URL"""
108        def cause_exc(url):
109            raise ValueError('exc error')
110
111        stdout = self.check_fetch_url(cause_exc, bintool.FETCH_ANY)
112        self.assertIn('exc error', stdout)
113
114    def test_fetch_method(self):
115        """Test fetching using a particular method"""
116        def fail_download(url):
117            """Take the tools.download() function by raising an exception"""
118            raise urllib.error.URLError('my error')
119
120        stdout = self.check_fetch_url(fail_download, bintool.FETCH_BIN)
121        self.assertIn('my error', stdout)
122
123    def test_fetch_pass_fail(self):
124        """Test fetching multiple tools with some passing and some failing"""
125        def handle_download(_):
126            """Take the tools.download() function by writing a file"""
127            if self.seq:
128                raise urllib.error.URLError('not found')
129            self.seq += 1
130            tools.write_file(fname, expected)
131            return fname, dirname
132
133        expected = b'this is a test'
134        dirname = os.path.join(self._indir, 'download_dir')
135        os.mkdir(dirname)
136        fname = os.path.join(dirname, 'downloaded')
137
138        # Rely on bintool to create this directory
139        destdir = os.path.join(self._indir, 'dest_dir')
140
141        dest_fname = os.path.join(destdir, '_testing')
142        self.seq = 0
143
144        with unittest.mock.patch.object(bintool.Bintool, 'tooldir', destdir):
145            with unittest.mock.patch.object(tools, 'download',
146                                            side_effect=handle_download):
147                with test_util.capture_sys_output() as (stdout, _):
148                    Bintool.fetch_tools(bintool.FETCH_ANY, ['_testing'] * 2)
149        self.assertTrue(os.path.exists(dest_fname))
150        data = tools.read_file(dest_fname)
151        self.assertEqual(expected, data)
152
153        lines = stdout.getvalue().splitlines()
154        self.assertTrue(len(lines) > 2)
155        self.assertEqual('Tools fetched:    1: _testing', lines[-2])
156        self.assertEqual('Failures:         1: _testing', lines[-1])
157
158    def test_tool_list(self):
159        """Test listing available tools"""
160        self.assertGreater(len(Bintool.get_tool_list()), 3)
161
162    def check_fetch_all(self, method):
163        """Helper to check the operation of fetching all tools"""
164
165        # pylint: disable=W0613
166        def fake_fetch(method, col, skip_present):
167            """Fakes the Binutils.fetch() function
168
169            Returns FETCHED and FAIL on alternate calls
170            """
171            self.seq += 1
172            result = bintool.FETCHED if self.seq & 1 else bintool.FAIL
173            self.count[result] += 1
174            return result
175
176        self.seq = 0
177        self.count = collections.defaultdict(int)
178        with unittest.mock.patch.object(bintool.Bintool, 'fetch_tool',
179                                        side_effect=fake_fetch):
180            with test_util.capture_sys_output() as (stdout, _):
181                Bintool.fetch_tools(method, ['all'])
182        lines = stdout.getvalue().splitlines()
183        self.assertIn(f'{self.count[bintool.FETCHED]}: ', lines[-2])
184        self.assertIn(f'{self.count[bintool.FAIL]}: ', lines[-1])
185
186    def test_fetch_all(self):
187        """Test fetching all tools"""
188        self.check_fetch_all(bintool.FETCH_ANY)
189
190    def test_fetch_all_specific(self):
191        """Test fetching all tools with a specific method"""
192        self.check_fetch_all(bintool.FETCH_BIN)
193
194    def test_fetch_missing(self):
195        """Test fetching missing tools"""
196        # pylint: disable=W0613
197        def fake_fetch2(method, col, skip_present):
198            """Fakes the Binutils.fetch() function
199
200            Returns PRESENT only for the '_testing' bintool
201            """
202            btool = list(self.btools.values())[self.seq]
203            self.seq += 1
204            print('fetch', btool.name)
205            if btool.name == '_testing':
206                return bintool.PRESENT
207            return bintool.FETCHED
208
209        # Preload a list of tools to return when get_tool_list() and create()
210        # are called
211        all_tools = Bintool.get_tool_list(True)
212        self.btools = collections.OrderedDict()
213        for name in all_tools:
214            self.btools[name] = Bintool.create(name)
215        self.seq = 0
216        with unittest.mock.patch.object(bintool.Bintool, 'fetch_tool',
217                                        side_effect=fake_fetch2):
218            with unittest.mock.patch.object(bintool.Bintool,
219                                            'get_tool_list',
220                                            side_effect=[all_tools]):
221                with unittest.mock.patch.object(bintool.Bintool, 'create',
222                                                side_effect=self.btools.values()):
223                    with test_util.capture_sys_output() as (stdout, _):
224                        Bintool.fetch_tools(bintool.FETCH_ANY, ['missing'])
225        lines = stdout.getvalue().splitlines()
226        num_tools = len(self.btools)
227        fetched = [line for line in lines if 'Tools fetched:' in line].pop()
228        present = [line for line in lines if 'Already present:' in line].pop()
229        self.assertIn(f'{num_tools - 1}: ', fetched)
230        self.assertIn('1: ', present)
231
232    def check_build_method(self, write_file):
233        """Check the output from fetching using the BUILD method
234
235        Args:
236            write_file (bool): True to write the output file when 'make' is
237                called
238
239        Returns:
240            tuple:
241                str: Filename of written file (or missing 'make' output)
242                str: Contents of stdout
243        """
244        def fake_run(*cmd):
245            if cmd[0] == 'make':
246                # See Bintool.build_from_git()
247                tmpdir = cmd[2]
248                self.fname = os.path.join(tmpdir, 'pathname')
249                if write_file:
250                    tools.write_file(self.fname, b'hello')
251
252        btest = Bintool.create('_testing')
253        col = terminal.Color()
254        self.fname = None
255        with unittest.mock.patch.object(bintool.Bintool, 'tooldir',
256                                        self._indir):
257            with unittest.mock.patch.object(tools, 'run', side_effect=fake_run):
258                with test_util.capture_sys_output() as (stdout, _):
259                    btest.fetch_tool(bintool.FETCH_BUILD, col, False)
260        fname = os.path.join(self._indir, '_testing')
261        return fname if write_file else self.fname, stdout.getvalue()
262
263    def test_build_method(self):
264        """Test fetching using the build method"""
265        fname, stdout = self.check_build_method(write_file=True)
266        self.assertTrue(os.path.exists(fname))
267        self.assertIn(f"writing to '{fname}", stdout)
268
269    def test_build_method_fail(self):
270        """Test fetching using the build method when no file is produced"""
271        fname, stdout = self.check_build_method(write_file=False)
272        self.assertFalse(os.path.exists(fname))
273        self.assertIn(f"File '{fname}' was not produced", stdout)
274
275    def test_install(self):
276        """Test fetching using the install method"""
277        btest = Bintool.create('_testing')
278        btest.install = True
279        col = terminal.Color()
280        with unittest.mock.patch.object(tools, 'run', return_value=None):
281            with test_util.capture_sys_output() as _:
282                result = btest.fetch_tool(bintool.FETCH_BIN, col, False)
283        self.assertEqual(bintool.FETCHED, result)
284
285    def test_no_fetch(self):
286        """Test fetching when there is no method"""
287        btest = Bintool.create('_testing')
288        btest.disable = True
289        col = terminal.Color()
290        with test_util.capture_sys_output() as _:
291            result = btest.fetch_tool(bintool.FETCH_BIN, col, False)
292        self.assertEqual(bintool.FAIL, result)
293
294    def test_all_bintools(self):
295        """Test that all bintools can handle all available fetch types"""
296        def handle_download(_):
297            """Take the tools.download() function by writing a file"""
298            tools.write_file(fname, expected)
299            return fname, dirname
300
301        def fake_run(*cmd):
302            if cmd[0] == 'make':
303                # See Bintool.build_from_git()
304                tmpdir = cmd[2]
305                self.fname = os.path.join(tmpdir, 'pathname')
306                tools.write_file(self.fname, b'hello')
307
308        expected = b'this is a test'
309        dirname = os.path.join(self._indir, 'download_dir')
310        os.mkdir(dirname)
311        fname = os.path.join(dirname, 'downloaded')
312
313        with unittest.mock.patch.object(tools, 'run', side_effect=fake_run):
314            with unittest.mock.patch.object(tools, 'download',
315                                            side_effect=handle_download):
316                with test_util.capture_sys_output() as _:
317                    for name in Bintool.get_tool_list():
318                        btool = Bintool.create(name)
319                        for method in range(bintool.FETCH_COUNT):
320                            result = btool.fetch(method)
321                            self.assertTrue(result is not False)
322                            if result is not True and result is not None:
323                                result_fname, _ = result
324                                self.assertTrue(os.path.exists(result_fname))
325                                data = tools.read_file(result_fname)
326                                self.assertEqual(expected, data)
327                                os.remove(result_fname)
328
329    def test_all_bintool_versions(self):
330        """Test handling of bintool version when it cannot be run"""
331        all_tools = Bintool.get_tool_list()
332        for name in all_tools:
333            btool = Bintool.create(name)
334            with unittest.mock.patch.object(
335                btool, 'run_cmd_result', return_value=command.CommandResult()):
336                self.assertEqual('unknown', btool.version())
337
338    def test_force_missing(self):
339        btool = Bintool.create('_testing')
340        btool.present = True
341        self.assertTrue(btool.is_present())
342
343        btool.present = None
344        Bintool.set_missing_list(['_testing'])
345        self.assertFalse(btool.is_present())
346
347    def test_failed_command(self):
348        """Check that running a command that does not exist returns None"""
349        destdir = os.path.join(self._indir, 'dest_dir')
350        os.mkdir(destdir)
351        with unittest.mock.patch.object(bintool.Bintool, 'tooldir', destdir):
352            btool = Bintool.create('_testing')
353            result = btool.run_cmd_result('fred')
354        self.assertIsNone(result)
355
356
357if __name__ == "__main__":
358    unittest.main()
359