Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 83 additions & 14 deletions .github/workflows/cpp-car.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,17 @@ jobs:

- name: Run QEMU tests
if: matrix.qemu
run: |
source $IDF_PATH/export.sh
pytest ${{ matrix.pytest_pattern }} --embedded-services idf,qemu -v
working-directory: ${{ matrix.path }}
run: car/scripts/run_qemu_tests.sh ${{ matrix.path }} ${{ matrix.pytest_pattern }}

- name: Upload test results to Codecov
if: ${{ !cancelled() && matrix.qemu }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ matrix.path }}/results.xml
flags: cpp
fail_ci_if_error: false
report_type: test_results

clang-format:
needs: build-docker
Expand Down Expand Up @@ -189,10 +196,10 @@ jobs:
matrix:
include:
- name: telemetry
path: car/components/telemetry/test_apps
component_path: components/telemetry/test_apps
pytest_pattern: pytest_telemetry_qemu.py
- name: web_server
path: car/components/web_server/test_apps
component_path: components/web_server/test_apps
pytest_pattern: pytest_web_server_qemu.py

container:
Expand All @@ -205,6 +212,7 @@ jobs:
defaults:
run:
shell: bash
working-directory: car

steps:
- name: Checkout
Expand All @@ -225,16 +233,77 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
SDKCONFIG_DEFAULTS: sdkconfig.defaults;sdkconfig.defaults.qemu;sdkconfig.defaults.ubsan
run: |
source $IDF_PATH/export.sh
idf.py build
working-directory: ${{ matrix.path }}
run: ./scripts/run_build.sh ${{ matrix.component_path }}

- name: Run QEMU tests
run: |
source $IDF_PATH/export.sh
pytest ${{ matrix.pytest_pattern }} --embedded-services idf,qemu -v
working-directory: ${{ matrix.path }}
run: ./scripts/run_qemu_tests.sh ${{ matrix.component_path }} ${{ matrix.pytest_pattern }}

coverage:
needs: build-docker
permissions:
contents: read
actions: read

runs-on: ubuntu-latest

strategy:
matrix:
include:
- name: telemetry
component_path: components/telemetry/test_apps
pytest_pattern: pytest_telemetry_qemu_coverage.py
- name: web_server
component_path: components/web_server/test_apps
pytest_pattern: pytest_web_server_qemu_coverage.py

container:
image: ghcr.io/ltowarek/dust-mite-cpp-devcontainer
options: --user root
credentials:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

defaults:
run:
shell: bash
working-directory: car

steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive

- name: Cache ccache
uses: actions/cache@v5
with:
path: ${{ github.workspace }}/.ccache
key: ccache-${{ runner.os }}-idf-coverage-${{ matrix.name }}-${{ github.run_id }}
restore-keys: |
ccache-${{ runner.os }}-idf-coverage-${{ matrix.name }}-
ccache-${{ runner.os }}-idf-coverage-

- name: Build
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
SDKCONFIG_DEFAULTS: sdkconfig.defaults;sdkconfig.defaults.qemu;sdkconfig.defaults.coverage
run: ./scripts/run_build.sh ${{ matrix.component_path }}

- name: Run QEMU coverage tests
run: ./scripts/run_qemu_tests.sh ${{ matrix.component_path }} ${{ matrix.pytest_pattern }}

- name: Generate coverage report
run: ./scripts/run_coverage_report.sh ${{ matrix.component_path }}

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: car/${{ matrix.component_path }}/coverage.info
flags: cpp
fail_ci_if_error: false



ci-status-cpp-car:
needs: [build-docker, build, clang-format, clang-tidy, ubsan]
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/js-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,25 @@ jobs:
HOME: /root
run: ./scripts/run_tests.sh

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: web/coverage/cobertura-coverage.xml
flags: js
fail_ci_if_error: false

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: web/test-report.junit.xml
flags: js
fail_ci_if_error: false
report_type: test_results


ci-status-js-web:
needs: [build-docker, run-checks]
if: always()
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/python-controller.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ jobs:
- name: Run tests
run: ./scripts/run_tests.sh

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: controller/coverage.xml
flags: python
fail_ci_if_error: false

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: controller/results.xml
flags: python
fail_ci_if_error: false
report_type: test_results


ci-status-python-controller:
needs: [build-docker, run-checks]
if: always()
Expand Down
52 changes: 51 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,10 @@ and is known to fail at the final link step for this project due to TLS-relocati
issues in vendored dependencies (protobuf/abseil/opentelemetry-cpp). `run_clang_tidy.sh`
tolerates that link failure and only requires `compile_commands.json` to be generated. The
`clang-tidy` CI job is blocking (part of `ci-status-cpp-car`); since `WarningsAsErrors` is
empty, it only fails on a genuine clang-tidy tool error, not on findings.
empty, it only fails on a genuine clang-tidy tool error, not on findings. The enabled
check set includes `misc-include-cleaner`, which flags headers included but not used
directly and symbols sourced only through transitive includes — the Xtensa-aware
equivalent of [IWYU](https://include-what-you-use.org/).

#### UndefinedBehaviorSanitizer (UBSan)

Expand All @@ -475,6 +478,53 @@ job builds and runs each opted-in test app with `sdkconfig.defaults.ubsan` layer
default test apps keep building without the sanitizer. The `ubsan` CI job is blocking (part of
`ci-status-cpp-car`).

#### GCC Coverage (gcov)

A QEMU test app can opt in to building its component under test with GCC coverage instrumentation.
Enable it by adding a `CONFIG_<COMPONENT>_TEST_COVERAGE` Kconfig boolean (default `n`) to the test
app's `main/Kconfig.projbuild`, a matching `sdkconfig.defaults.coverage` overlay that sets it to
`y`, and guarding the coverage flags in the test app's `CMakeLists.txt`:

```cmake
idf_component_register(SRCS ${srcs} ... PRIV_REQUIRES ... gcov_uart_vfs ...)
```

`gcov_uart_vfs` is listed unconditionally — ESP-IDF's component resolution ordering does not
reliably support Kconfig-gated `PRIV_REQUIRES` entries. The component is small and its entry point
is never called in non-coverage builds (guarded by `#ifdef` in `main.cpp`).

The `gcov_uart_vfs` component (in `car/components/gcov_uart_vfs/`) handles everything shared: the
VFS registration, the base64-over-UART encoding, the `GCOV_PREFIX_STRIP_COUNT` computation (applied
at CMake time so paths are correct regardless of checkout location), and the gcov flush sequence.

The test app's `main.cpp` drives the dump after `UNITY_END()` with a single call:

```cpp
#ifdef CONFIG_<COMPONENT>_TEST_COVERAGE
gcov_uart_vfs_dump();
#endif
```

`gcov_uart_vfs_dump()` registers the VFS at `/gcov`, sets `GCOV_PREFIX`/`GCOV_PREFIX_STRIP`, calls
`__gcov_dump()`, and prints `GCOV_DUMP_DONE`. The VFS intercepts gcov's file I/O and streams each
`.gcda` file as base64 over UART, framed with `GCOV_FILE_START:`, `GCOV_B64:`, and `GCOV_FILE_END`
sentinels. The matching `pytest_<component>_qemu_coverage.py` script decodes the UART stream and
reconstructs `.gcda` files inside `build/`. This approach avoids semihosting (not available without
an OpenOCD connection) and does not require a separate QEMU invocation.

Scoping coverage flags to just the component under test mirrors the UBSan pattern and keeps binary
size manageable. A dedicated `coverage` CI job builds each opted-in test app with
`sdkconfig.defaults;sdkconfig.defaults.qemu;sdkconfig.defaults.coverage`, runs the coverage pytest,
and uploads coverage data to Codecov. The `coverage` CI job is non-blocking (not part of
`ci-status-cpp-car`).

To run locally (from the `car/` directory in the C++ devcontainer):

```bash
./scripts/run_coverage.sh components/telemetry/test_apps pytest_telemetry_qemu_coverage.py
./scripts/run_coverage.sh components/web_server/test_apps pytest_web_server_qemu_coverage.py
```

### Car test types

Test scope and execution environment are independent choices. Scope determines what is under test; environment determines where it runs.
Expand Down
4 changes: 4 additions & 0 deletions car/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,7 @@ env/

# clang-tidy
warnings.txt

# gcov coverage
coverage.info
coverage/
13 changes: 13 additions & 0 deletions car/components/gcov_uart_vfs/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
idf_component_register(SRCS "gcov_uart_vfs.c"
INCLUDE_DIRS "."
PRIV_REQUIRES vfs)

# Compute how many leading path components to strip from the gcda path so
# GCOV_PREFIX=/gcov lands the files relative to the test app root regardless
# of checkout location. CMAKE_SOURCE_DIR is the test app project root in IDF
# builds.
string(REPLACE "/" ";" _gcov_parts "${CMAKE_SOURCE_DIR}")
list(LENGTH _gcov_parts _gcov_depth)
math(EXPR _gcov_strip "${_gcov_depth} - 1")
idf_component_get_property(lib ${COMPONENT_NAME} COMPONENT_LIB)
target_compile_definitions(${lib} PRIVATE "GCOV_PREFIX_STRIP_COUNT=${_gcov_strip}")
105 changes: 105 additions & 0 deletions car/components/gcov_uart_vfs/gcov_uart_vfs.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#include "gcov_uart_vfs.h"
#include "esp_vfs.h"
#include "esp_vfs_ops.h"
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define VFS_MOUNT "/gcov"
#define GCOV_MAX_FDS 4

static bool s_fd_used[GCOV_MAX_FDS];

static const char s_b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

static void b64_print(const uint8_t* data, size_t len) {
for (size_t i = 0; i < len; i += 3) {
uint8_t a = data[i];
uint8_t b = (i + 1 < len) ? data[i + 1] : 0;
uint8_t c = (i + 2 < len) ? data[i + 2] : 0;
size_t rem = len - i;
putchar(s_b64[a >> 2]);
putchar(s_b64[((a & 0x3) << 4) | (b >> 4)]);
putchar(rem > 1 ? s_b64[((b & 0xf) << 2) | (c >> 6)] : '=');
putchar(rem > 2 ? s_b64[c & 0x3f] : '=');
}
}

static int gcov_open_p(void* ctx, const char* path, int flags, int mode) {
(void)ctx;
(void)flags;
(void)mode;
for (int fd = 0; fd < GCOV_MAX_FDS; fd++) {
if (!s_fd_used[fd]) {
s_fd_used[fd] = true;
printf("GCOV_FILE_START:%s\n", path);
fflush(stdout);
return fd;
}
}
errno = ENFILE;
return -1;
}

static ssize_t gcov_write_p(void* ctx, int fd, const void* data, size_t size) {
(void)ctx;
if (fd < 0 || fd >= GCOV_MAX_FDS || !s_fd_used[fd]) {
errno = EBADF;
return -1;
}
printf("GCOV_B64:");
b64_print((const uint8_t*)data, size);
printf("\n");
fflush(stdout);
return (ssize_t)size;
}

static int gcov_close_p(void* ctx, int fd) {
(void)ctx;
if (fd < 0 || fd >= GCOV_MAX_FDS || !s_fd_used[fd]) {
errno = EBADF;
return -1;
}
s_fd_used[fd] = false;
printf("GCOV_FILE_END\n");
fflush(stdout);
return 0;
}

static off_t gcov_lseek_p(void* ctx, int fd, off_t offset, int whence) {
(void)ctx;
(void)fd;
(void)offset;
(void)whence;
return 0;
}

static const esp_vfs_fs_ops_t s_gcov_vfs = {
.open_p = &gcov_open_p,
.write_p = &gcov_write_p,
.close_p = &gcov_close_p,
.lseek_p = &gcov_lseek_p,
};

void gcov_uart_vfs_init(void) {
memset(s_fd_used, 0, sizeof(s_fd_used));
ESP_ERROR_CHECK(esp_vfs_register_fs(VFS_MOUNT, &s_gcov_vfs,
ESP_VFS_FLAG_STATIC | ESP_VFS_FLAG_CONTEXT_PTR, NULL));
}

#define _GCOV_STRIP_STR(x) #x
#define GCOV_STRIP_STR(x) _GCOV_STRIP_STR(x)
extern void __gcov_dump(void) __attribute__((weak));

void gcov_uart_vfs_dump(void) {
if (!__gcov_dump) return;
gcov_uart_vfs_init();
setenv("GCOV_PREFIX", "/gcov", 1);
setenv("GCOV_PREFIX_STRIP", GCOV_STRIP_STR(GCOV_PREFIX_STRIP_COUNT), 1);
__gcov_dump();
printf("GCOV_DUMP_DONE\n");
fflush(stdout);
}
12 changes: 12 additions & 0 deletions car/components/gcov_uart_vfs/gcov_uart_vfs.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#pragma once

#ifdef __cplusplus
extern "C" {
#endif

void gcov_uart_vfs_init(void);
void gcov_uart_vfs_dump(void);

#ifdef __cplusplus
}
#endif
Loading
Loading