xref: /aosp_15_r20/external/pigweed/pw_arduino_build/py/pw_arduino_build/unit_test_runner.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1#!/usr/bin/env python3
2# Copyright 2020 The Pigweed Authors
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may not
5# use this file except in compliance with the License. You may obtain a copy of
6# the License at
7#
8#     https://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations under
14# the License.
15"""This script flashes and runs unit tests onto Arduino boards."""
16
17import argparse
18import logging
19import os
20import platform
21import re
22import subprocess
23import sys
24import time
25from pathlib import Path
26
27import serial
28import serial.tools.list_ports
29import pw_arduino_build.log
30from pw_arduino_build import teensy_detector
31from pw_arduino_build.file_operations import decode_file_json
32
33_LOG = logging.getLogger('unit_test_runner')
34
35# Verification of test pass/failure depends on these strings. If the formatting
36# or output of the simple_printing_event_handler changes, this may need to be
37# updated.
38_TESTS_STARTING_STRING = b'[==========] Running all tests.'
39_TESTS_DONE_STRING = b'[==========] Done running all tests.'
40_TEST_FAILURE_STRING = b'[  FAILED  ]'
41
42# How long to wait for the first byte of a test to be emitted. This is longer
43# than the user-configurable timeout as there's a delay while the device is
44# flashed.
45_FLASH_TIMEOUT = 5.0
46
47
48class TestingFailure(Exception):
49    """A simple exception to be raised when a testing step fails."""
50
51
52class DeviceNotFound(Exception):
53    """A simple exception to be raised when unable to connect to a device."""
54
55
56class ArduinoCoreNotSupported(Exception):
57    """Exception raised when a given core does not support unit testing."""
58
59
60def valid_file_name(arg):
61    file_path = Path(os.path.expandvars(arg)).absolute()
62    if not file_path.is_file():
63        raise argparse.ArgumentTypeError(f"'{arg}' does not exist.")
64    return file_path
65
66
67def parse_args():
68    """Parses command-line arguments."""
69
70    parser = argparse.ArgumentParser(description=__doc__)
71    parser.add_argument(
72        'binary', help='The target test binary to run', type=valid_file_name
73    )
74    parser.add_argument(
75        '--port',
76        help='The name of the serial port to connect to when ' 'running tests',
77    )
78    parser.add_argument(
79        '--baud',
80        type=int,
81        default=115200,
82        help='Target baud rate to use for serial communication'
83        ' with target device',
84    )
85    parser.add_argument(
86        '--test-timeout',
87        type=float,
88        default=5.0,
89        help='Maximum communication delay in seconds before a '
90        'test is considered unresponsive and aborted',
91    )
92    parser.add_argument(
93        '--verbose',
94        '-v',
95        dest='verbose',
96        action='store_true',
97        help='Output additional logs as the script runs',
98    )
99
100    parser.add_argument(
101        '--flash-only',
102        action='store_true',
103        help="Don't check for test output after flashing.",
104    )
105
106    # arduino_builder arguments
107    # TODO(tonymd): Get these args from __main__.py or elsewhere.
108    parser.add_argument(
109        "-c", "--config-file", required=True, help="Path to a config file."
110    )
111    parser.add_argument(
112        "--arduino-package-path",
113        help="Path to the arduino IDE install location.",
114    )
115    parser.add_argument(
116        "--arduino-package-name",
117        help="Name of the Arduino board package to use.",
118    )
119    parser.add_argument(
120        "--compiler-path-override",
121        help="Path to arm-none-eabi-gcc bin folder. "
122        "Default: Arduino core specified gcc",
123    )
124    parser.add_argument("--board", help="Name of the Arduino board to use.")
125    parser.add_argument(
126        "--upload-tool",
127        required=True,
128        help="Name of the Arduino upload tool to use.",
129    )
130    parser.add_argument(
131        "--set-variable",
132        action="append",
133        metavar='some.variable=NEW_VALUE',
134        help="Override an Arduino recipe variable. May be "
135        "specified multiple times. For example: "
136        "--set-variable 'serial.port.label=/dev/ttyACM0' "
137        "--set-variable 'serial.port.protocol=Teensy'",
138    )
139    return parser.parse_args()
140
141
142def log_subprocess_output(level, output):
143    """Logs subprocess output line-by-line."""
144
145    lines = output.decode('utf-8', errors='replace').splitlines()
146    for line in lines:
147        _LOG.log(level, line)
148
149
150def read_serial(port, baud_rate, test_timeout) -> bytes:
151    """Reads lines from a serial port until a line read times out.
152
153    Returns bytes object containing the read serial data.
154    """
155
156    serial_data = bytearray()
157    device = serial.Serial(
158        baudrate=baud_rate, port=port, timeout=_FLASH_TIMEOUT
159    )
160    if not device.is_open:
161        raise TestingFailure('Failed to open device')
162
163    # Flush input buffer and reset the device to begin the test.
164    device.reset_input_buffer()
165
166    # Block and wait for the first byte.
167    serial_data += device.read()
168    if not serial_data:
169        raise TestingFailure('Device not producing output')
170
171    device.timeout = test_timeout
172
173    # Read with a reasonable timeout until we stop getting characters.
174    while True:
175        bytes_read = device.readline()
176        if not bytes_read:
177            break
178        serial_data += bytes_read
179        if serial_data.rfind(_TESTS_DONE_STRING) != -1:
180            # Set to much more aggressive timeout since the last one or two
181            # lines should print out immediately. (one line if all fails or all
182            # passes, two lines if mixed.)
183            device.timeout = 0.01
184
185    # Remove carriage returns.
186    serial_data = serial_data.replace(b'\r', b'')
187
188    # Try to trim captured results to only contain most recent test run.
189    test_start_index = serial_data.rfind(_TESTS_STARTING_STRING)
190    return (
191        serial_data
192        if test_start_index == -1
193        else serial_data[test_start_index:]
194    )
195
196
197def wait_for_port(port):
198    """Wait for the serial port to be available."""
199    while port not in [sp.device for sp in serial.tools.list_ports.comports()]:
200        time.sleep(1)
201
202
203def flash_device(test_runner_args, upload_tool):
204    """Flash binary to a connected device using the provided configuration."""
205
206    # TODO(tonymd): Create a library function to call rather than launching
207    # the arduino_builder script.
208    flash_tool = 'arduino_builder'
209    cmd = (
210        [flash_tool, "--quiet"]
211        + test_runner_args
212        + ["--run-objcopy", "--run-postbuilds", "--run-upload", upload_tool]
213    )
214    _LOG.info('Flashing firmware to device')
215    _LOG.debug('Running: %s', " ".join(cmd))
216
217    env = os.environ.copy()
218    process = subprocess.run(
219        cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env
220    )
221    if process.returncode:
222        log_subprocess_output(logging.ERROR, process.stdout)
223        raise TestingFailure('Failed to flash target device')
224
225    log_subprocess_output(logging.DEBUG, process.stdout)
226
227    _LOG.debug('Successfully flashed firmware to device')
228
229
230def handle_test_results(test_output):
231    """Parses test output to determine whether tests passed or failed."""
232
233    if test_output.find(_TESTS_STARTING_STRING) == -1:
234        raise TestingFailure('Failed to find test start')
235
236    if test_output.rfind(_TESTS_DONE_STRING) == -1:
237        log_subprocess_output(logging.INFO, test_output)
238        raise TestingFailure('Tests did not complete')
239
240    if test_output.rfind(_TEST_FAILURE_STRING) != -1:
241        log_subprocess_output(logging.INFO, test_output)
242        raise TestingFailure('Test suite had one or more failures')
243
244    log_subprocess_output(logging.DEBUG, test_output)
245
246    _LOG.info('Test passed!')
247
248
249def run_device_test(
250    binary,
251    flash_only,
252    port,
253    baud,
254    test_timeout,
255    upload_tool,
256    arduino_package_path,
257    test_runner_args,
258) -> bool:
259    """Flashes, runs, and checks an on-device test binary.
260
261    Returns true on test pass.
262    """
263    if test_runner_args is None:
264        test_runner_args = []
265
266    if "teensy" not in arduino_package_path:
267        raise ArduinoCoreNotSupported(arduino_package_path)
268
269    if port is None or "--set-variable" not in test_runner_args:
270        _LOG.debug('Attempting to automatically detect dev board')
271        boards = teensy_detector.detect_boards(arduino_package_path)
272        if not boards:
273            error = 'Could not find an attached device'
274            _LOG.error(error)
275            raise DeviceNotFound(error)
276        test_runner_args += boards[0].test_runner_args()
277        upload_tool = boards[0].arduino_upload_tool_name
278        if port is None:
279            port = boards[0].dev_name
280
281    # TODO(tonymd): Remove this when teensy_ports is working in teensy_detector
282    if platform.system() == "Windows":
283        # Delete the incorrect serial port.
284        index_of_port = [
285            i
286            for i, l in enumerate(test_runner_args)
287            if l.startswith('serial.port=')
288        ]
289        if index_of_port:
290            # Delete the '--set-variable' arg
291            del test_runner_args[index_of_port[0] - 1]
292            # Delete the 'serial.port=*' arg
293            del test_runner_args[index_of_port[0] - 1]
294
295    _LOG.debug('Launching test binary %s', binary)
296    try:
297        result: list[bytes] = []
298        _LOG.info('Running test')
299        # Warning: A race condition is possible here. This assumes the host is
300        # able to connect to the port and that there isn't a test running on
301        # this serial port.
302        flash_device(test_runner_args, upload_tool)
303        wait_for_port(port)
304        if flash_only:
305            return True
306        result.append(read_serial(port, baud, test_timeout))
307        if result:
308            handle_test_results(result[0])
309    except TestingFailure as err:
310        _LOG.error(err)
311        return False
312
313    return True
314
315
316def get_option(key, config_file_values, args, required=False):
317    command_line_option = getattr(args, key, None)
318    final_option = config_file_values.get(key, command_line_option)
319    if required and command_line_option is None and final_option is None:
320        # Print a similar error message to argparse
321        executable = os.path.basename(sys.argv[0])
322        option = "--" + key.replace("_", "-")
323        print(
324            f"{executable}: error: the following arguments are required: "
325            f"{option}"
326        )
327        sys.exit(1)
328    return final_option
329
330
331def main():
332    """Set up runner, and then flash/run device test."""
333    args = parse_args()
334
335    json_file_options, unused_config_path = decode_file_json(args.config_file)
336
337    log_level = logging.DEBUG if args.verbose else logging.INFO
338    pw_arduino_build.log.install(log_level)
339
340    # Construct arduino_builder flash arguments for a given .elf binary.
341    arduino_package_path = get_option(
342        "arduino_package_path", json_file_options, args, required=True
343    )
344    # Arduino core args.
345    arduino_builder_args = [
346        "--arduino-package-path",
347        arduino_package_path,
348        "--arduino-package-name",
349        get_option(
350            "arduino_package_name", json_file_options, args, required=True
351        ),
352    ]
353
354    # Use CIPD installed compilers.
355    compiler_path_override = get_option(
356        "compiler_path_override", json_file_options, args
357    )
358    if compiler_path_override:
359        arduino_builder_args += [
360            "--compiler-path-override",
361            compiler_path_override,
362        ]
363
364    # Run subcommand with board selection arg.
365    arduino_builder_args += [
366        "run",
367        "--board",
368        get_option("board", json_file_options, args, required=True),
369    ]
370
371    # .elf file location args.
372    binary = args.binary
373    build_path = binary.parent.as_posix()
374    arduino_builder_args += ["--build-path", build_path]
375    build_project_name = binary.name
376    # Remove '.elf' extension.
377    match_result = re.match(r'(.*?)\.elf$', binary.name, re.IGNORECASE)
378    if match_result:
379        build_project_name = match_result[1]
380        arduino_builder_args += ["--build-project-name", build_project_name]
381
382    # USB port is passed to arduino_builder_args via --set-variable args.
383    if args.set_variable:
384        for var in args.set_variable:
385            arduino_builder_args += ["--set-variable", var]
386
387    if run_device_test(
388        binary.as_posix(),
389        args.flash_only,
390        args.port,
391        args.baud,
392        args.test_timeout,
393        args.upload_tool,
394        arduino_package_path,
395        test_runner_args=arduino_builder_args,
396    ):
397        sys.exit(0)
398    else:
399        sys.exit(1)
400
401
402if __name__ == '__main__':
403    main()
404