diff --git a/.github/workflows/release_build.yml b/.github/workflows/release_build.yml index 7ca8d86..075da61 100644 --- a/.github/workflows/release_build.yml +++ b/.github/workflows/release_build.yml @@ -135,6 +135,14 @@ jobs: bazel build -c opt --config=${{ matrix.arch }} //:polaris_client bazel build -c opt --config=${{ matrix.arch }} //examples:* + # Run unit tests. + - name: Run Unit Tests + if: matrix.arch == 'x64' + env: + POLARIS_API_KEY: ${{ secrets.POLARIS_API_KEY }} + run: | + ./test/run_unit_tests.sh --tool=${{ matrix.tool }} --unique-id-prefix=${{ matrix.tool }}_cpp + # Package artifacts (Bazel only -- no need to do this for multiple build # tools). - name: Create artifact @@ -241,16 +249,25 @@ jobs: bazel build -c opt --config=${{ matrix.arch }} //:polaris_client bazel build -c opt --config=${{ matrix.arch }} //examples:* - # Package artifacts (GNU Make only -- no need to do this for multiple build + # Run unit tests. + - name: Run Unit Tests + if: matrix.arch == 'x64' + env: + POLARIS_API_KEY: ${{ secrets.POLARIS_API_KEY }} + run: | + ./test/run_unit_tests.sh --tool=${{ matrix.tool }} --unique-id-prefix=${{ matrix.tool }}_c_ + + # Package artifacts (Bazel only -- no need to do this for multiple build # tools). - name: Create artifact - if: matrix.tool == 'make' + if: matrix.tool == 'bazel' run: | - make print_applications | - xargs tar czfv polaris_examples.tar.gz --transform 's|^|polaris/c/|' + bazel query 'kind("cc_binary", //examples:*)' 2>/dev/null | + sed -e 's|//examples:|bazel-bin/examples/|' | + xargs tar czf polaris_examples.tar.gz --transform 's|^bazel-bin|polaris/c|' - name: Upload artifact - if: matrix.tool == 'make' + if: matrix.tool == 'bazel' uses: actions/upload-artifact@v1 with: path: c/polaris_examples.tar.gz diff --git a/.gitignore b/.gitignore index 73b90c4..7b097e3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ bazel-* # CMake build/ +# Python +*.pyc + # Visual Studio Code .vscode # Clion diff --git a/c/src/point_one/polaris/polaris.c b/c/src/point_one/polaris/polaris.c index b2f3655..093bc21 100644 --- a/c/src/point_one/polaris/polaris.c +++ b/c/src/point_one/polaris/polaris.c @@ -177,15 +177,15 @@ int Polaris_Authenticate(PolarisContext_t* context, const char* api_key, } if (strlen(unique_id) > POLARIS_MAX_UNIQUE_ID_SIZE) { - P1_Print("Unique ID must be a maximum of %d characters.\n", - POLARIS_MAX_UNIQUE_ID_SIZE); + P1_Print("Unique ID must be a maximum of %d characters. [id='%s']\n", + POLARIS_MAX_UNIQUE_ID_SIZE, unique_id); return POLARIS_ERROR; } else { for (const char* ptr = unique_id; *ptr != '\0'; ++ptr) { char c = *ptr; if (c != '-' && c != '_' && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9')) { - P1_Print("Invalid unique ID specified.\n"); + P1_Print("Invalid unique ID specified. [id='%s']\n", unique_id); return POLARIS_ERROR; } } diff --git a/c/test/application_base.py b/c/test/application_base.py new file mode 100644 index 0000000..abdbe99 --- /dev/null +++ b/c/test/application_base.py @@ -0,0 +1,227 @@ +from argparse import ArgumentParser +import copy +import os +import re +import signal +import subprocess +import sys +import threading + + +class TestApplicationBase(object): + TEST_PASSED = 0 + TEST_FAILED = 1 + ARGUMENT_ERROR = 2 + EXECUTION_ERROR = 3 + NONZERO_EXIT = 4 + + DEFAULT_ROOT_DIR = os.path.abspath(os.path.normpath(os.path.join(os.path.dirname(__file__), '..'))) + DEFAULT_COMMAND = ['%(path)s', '%(polaris_api_key)s', '%(unique_id)s'] + + def __init__(self, application_name, root_dir=None): + if root_dir is None: + self.root_dir = self.DEFAULT_ROOT_DIR + else: + self.root_dir = root_dir + + self.application_name = application_name + + # Define and parse arguments. + self.parser = ArgumentParser(usage='%(prog)s [OPTIONS]...') + + self.parser.add_argument( + '-p', '--path', metavar='PATH', + help="The path to the application to be run.") + self.parser.add_argument( + '--polaris-api-key', metavar='KEY', + help="The Polaris API key to be used. If not set, defaults to the POLARIS_API_KEY environment variable if " + "specified.") + self.parser.add_argument( + '-t', '--timeout', metavar='SEC', type=float, default=30.0, + help="The maximum test duration (in seconds).") + self.parser.add_argument( + '--tool', metavar='TOOL', default='bazel', + help="The tool used to compile the application (bazel, cmake, make), used to determine the default " + "application path. Ignored if --path is specified.") + self.parser.add_argument( + '--unique-id', metavar='ID', default=application_name, + help="The unique ID to assign to this instance. The ID will be prepended with PREFIX if --unique-id-prefix " + "is specified.") + self.parser.add_argument( + '--unique-id-prefix', metavar='PREFIX', + help="An optional prefix to prepend to the unique ID.") + + self.options = None + + self.program_args = [] + + self.proc = None + + def parse_args(self): + self.options = self.parser.parse_args() + + if self.options.polaris_api_key is None: + self.options.polaris_api_key = os.getenv('POLARIS_API_KEY') + if self.options.polaris_api_key is None: + print('Error: Polaris API key not specified.') + sys.exit(self.ARGUMENT_ERROR) + + if self.options.path is None: + if self.options.tool == 'bazel': + self.options.path = os.path.join(self.root_dir, 'bazel-bin/examples', self.application_name) + elif self.options.tool == 'cmake': + for build_dir in ('build', 'cmake_build'): + path = os.path.join(self.root_dir, build_dir, 'examples', self.application_name) + if os.path.exists(path): + self.options.path = path + break + if self.options.path is None: + print('Error: Unable to locate CMake build directory.') + sys.exit(self.ARGUMENT_ERROR) + elif self.options.tool == 'make': + self.options.path = os.path.join(self.root_dir, 'examples', self.application_name) + else: + print('Error: Unsupported --tool value.') + sys.exit(self.ARGUMENT_ERROR) + + if self.options.unique_id_prefix is not None: + self.options.unique_id = self.options.unique_id_prefix + self.options.unique_id + if len(self.options.unique_id) > 36: + self.options.unique_id = self.options.unique_id[:36] + print("Unique ID too long. Truncating to '%s'." % self.options.unique_id) + + return self.options + + def run(self, return_result=False): + # Setup the command to be run. + command = copy.deepcopy(self.DEFAULT_COMMAND) + command.extend(self.program_args) + api_key_standin = '%s...' % self.options.polaris_api_key[:4] + for i in range(len(command)): + if command[i].endswith('%(polaris_api_key)s'): + # We temporarily replace the API key placeholder with the first 4 chars of the key before printing to + # the console to avoid printing the actual key to the console. It will be swapped with the real key + # below. + command[i] = command[i].replace('%(polaris_api_key)s', api_key_standin) + else: + command[i] = command[i] % self.options.__dict__ + + print('Executing: %s' % ' '.join(command)) + + command.insert(0, 'stdbuf') + command.insert(1, '-o0') + for i in range(len(command)): + if command[i].endswith(api_key_standin): + command[i] = command[i].replace(api_key_standin, self.options.polaris_api_key) + + # Run the command. + def ignore_signal(sig, frame): + signal.signal(sig, signal.SIG_DFL) + + def preexec_function(): + # Disable forwarding of SIGINT/SIGTERM from the parent process (this script) to the child process (the + # application under test). + os.setpgrp() + signal.signal(signal.SIGINT, ignore_signal) + signal.signal(signal.SIGTERM, ignore_signal) + + self.proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8', + preexec_fn=preexec_function) + + # Capture SIGINT and SIGTERM and shutdown the application gracefully. + def request_shutdown(sig, frame): + self.stop() + signal.signal(sig, signal.SIG_DFL) + + signal.signal(signal.SIGINT, request_shutdown) + signal.signal(signal.SIGTERM, request_shutdown) + + # Stop the test after a max duration. + def timeout_elapsed(): + print('Maximum test duration (%.1f sec) elapsed.' % self.options.timeout) + self.stop() + + watchdog = threading.Timer(self.options.timeout, timeout_elapsed) + watchdog.start() + + # Check for a pass/fail condition and forward output to the console. + while True: + try: + line = self.proc.stdout.readline().rstrip('\n') + if line != '': + print(line.rstrip('\n')) + self.on_stdout(line) + elif self.proc.poll() is not None: + exit_code = self.proc.poll() + break + except KeyboardInterrupt: + print('Execution interrupted unexpectedly.') + if return_result: + return self.EXECUTION_ERROR + else: + sys.exit(self.EXECUTION_ERROR) + + watchdog.cancel() + self.proc = None + + result = self.check_pass_fail(exit_code) + if result == self.TEST_PASSED: + print('Test result: success') + else: + print('Test result: FAIL') + + if return_result: + return result + else: + sys.exit(result) + + def stop(self): + if self.proc is not None: + print('Sending shutdown request to the application.') + self.proc.terminate() + + def check_pass_fail(self, exit_code): + if exit_code != 0: + print('Application exited with non-zero exit code %s.' % repr(exit_code)) + return self.NONZERO_EXIT + else: + return self.TEST_PASSED + + def on_stdout(self, line): + pass + + +class StandardApplication(TestApplicationBase): + """! + @brief Unit test for an example application that prints a data received + message and requires no outside input. + + The data received message must be formatted as: + ``` + Application received N bytes. + ``` + """ + + def __init__(self, application_name): + super().__init__(application_name=application_name) + self.data_received = False + + def check_pass_fail(self, exit_code): + # Note: There is currently a race condition when the subprocess is shutdown (SIGTERM) where either the + # application itself exits cleanly with code 0 as expected, or the Python fork running it exits first with + # -SIGTERM before the application gets a chance to exit. The preexec stuff above doesn't seem to be enough to + # fix it. For now, we simply treat the combination of -SIGTERM + data received as a pass. + if exit_code == 0 or exit_code == -signal.SIGTERM: + if self.data_received: + return self.TEST_PASSED + else: + print('No corrections data received.') + return self.TEST_FAILED + else: + return super().check_pass_fail(exit_code) + + def on_stdout(self, line): + if re.match(r'.*Application received \d+ bytes.', line): + print('Corrections data detected.') + self.data_received = True + self.stop() diff --git a/c/test/run_unit_tests.sh b/c/test/run_unit_tests.sh new file mode 100755 index 0000000..ed18288 --- /dev/null +++ b/c/test/run_unit_tests.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +echo "Testing simple_polaris_client..." +python3 test/test_simple_polaris_client.py $* + +echo "Testing connection_retry..." +python3 test/test_connection_retry.py $* diff --git a/c/test/test_connection_retry.py b/c/test/test_connection_retry.py new file mode 100755 index 0000000..8aa7586 --- /dev/null +++ b/c/test/test_connection_retry.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 + +from application_base import StandardApplication + +class Test(StandardApplication): + def __init__(self): + super().__init__(application_name='connection_retry') + +test = Test() +test.parse_args() +test.run() diff --git a/c/test/test_simple_polaris_client.py b/c/test/test_simple_polaris_client.py new file mode 100755 index 0000000..6d679cb --- /dev/null +++ b/c/test/test_simple_polaris_client.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 + +from application_base import StandardApplication + +class Test(StandardApplication): + def __init__(self): + super().__init__(application_name='simple_polaris_client') + +test = Test() +test.parse_args() +test.run() diff --git a/examples/BUILD b/examples/BUILD index e5fd71a..d5e1edd 100644 --- a/examples/BUILD +++ b/examples/BUILD @@ -2,7 +2,7 @@ package(default_visibility = ["//visibility:public"]) # Simple example of connecting to the Polaris service. cc_binary( - name = "simple_polaris_client", + name = "simple_polaris_cpp_client", srcs = ["simple_polaris_client.cc"], deps = [ "//:polaris_client", diff --git a/examples/simple_polaris_client.cc b/examples/simple_polaris_client.cc index 464c7fd..582340c 100644 --- a/examples/simple_polaris_client.cc +++ b/examples/simple_polaris_client.cc @@ -36,7 +36,7 @@ PolarisClient* polaris_client = nullptr; // Process receiver incoming messages. This example code expects received data // to be ascii nmea messages. void ReceivedData(const uint8_t* data, size_t length) { - LOG(INFO) << "Received " << length << " bytes."; + LOG(INFO) << "Application received " << length << " bytes."; } void HandleSignal(int sig) { diff --git a/readme.md b/readme.md index 160da22..1381078 100644 --- a/readme.md +++ b/readme.md @@ -361,7 +361,7 @@ they are more commonly run using the `bazel run` command. For example, to run th [Simple Polaris Client](#simple-polaris-client-1) example application, run the following: ```bash -bazel run -c opt //examples:simple_polaris_client -- --polaris_api_key= +bazel run -c opt //examples:simple_polaris_cpp_client -- --polaris_api_key= ``` See [Simple Polaris Client](#simple-polaris-client-1) for more details. @@ -375,7 +375,7 @@ specify the `--config` argument to Bazel with one of the following values: For example: ``` -bazel build --config=aarch64 //examples:simple_polaris_client +bazel build --config=aarch64 //examples:simple_polaris_cpp_client ``` #### CMake #### @@ -407,7 +407,7 @@ example, to run the [Simple Polaris Client](#simple-polaris-client-1) example ap run the following: ```bash -./examples/simple_polaris_client --polaris_api_key= +./examples/simple_polaris_cpp_client --polaris_api_key= ``` See [Simple Polaris Client](#simple-polaris-client-1) for more details. @@ -477,7 +477,7 @@ A small example of establishing a Polaris connection and receiving RTCM correcti To run the application, run the following command: ``` -bazel run //examples:simple_polaris_client -- --polaris_api_key= +bazel run //examples:simple_polaris_cpp_client -- --polaris_api_key= ``` where `` is the API key assigned to you by Point One. The application uses a built-in unique ID by default, but you may change the unique ID using the `--polaris_unique_id` argument. See diff --git a/test/cpp_application_base.py b/test/cpp_application_base.py new file mode 100644 index 0000000..88f91af --- /dev/null +++ b/test/cpp_application_base.py @@ -0,0 +1,15 @@ +import os +import sys + +# Add the polaris/c/test/ directory to the search path, then import the C library's test base class. +repo_root_dir = os.path.normpath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.append(os.path.join(repo_root_dir, 'c/test')) + +from application_base import StandardApplication + +class CppStandardApplication(StandardApplication): + DEFAULT_ROOT_DIR = repo_root_dir + DEFAULT_COMMAND = ['%(path)s', '--polaris_api_key=%(polaris_api_key)s', '--polaris_unique_id=%(unique_id)s'] + + def __init__(self, application_name): + super().__init__(application_name=application_name) diff --git a/test/run_unit_tests.sh b/test/run_unit_tests.sh new file mode 100755 index 0000000..ce036a0 --- /dev/null +++ b/test/run_unit_tests.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +echo "Testing simple_polaris_client..." +python3 test/test_simple_polaris_cpp_client.py $* diff --git a/test/test_simple_polaris_cpp_client.py b/test/test_simple_polaris_cpp_client.py new file mode 100755 index 0000000..f4cdcea --- /dev/null +++ b/test/test_simple_polaris_cpp_client.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 + +from cpp_application_base import CppStandardApplication + +class Test(CppStandardApplication): + def __init__(self): + super().__init__(application_name='simple_polaris_cpp_client') + +test = Test() +test.parse_args() +test.run()