From a0a26c176bec01c97766e9379dd2e5d04e568d17 Mon Sep 17 00:00:00 2001 From: Lukasz Towarek Date: Fri, 26 Jun 2026 21:01:08 +0000 Subject: [PATCH 1/8] Gather code coverage in python and js --- .github/workflows/js-web.yml | 8 + .github/workflows/python-controller.yml | 10 + controller/.gitignore | 1 + controller/pyproject.toml | 2 +- controller/requirements/dev.in | 1 + controller/requirements/dev.txt | 6 + web/.gitignore | 1 + web/package-lock.json | 575 ++++++++++++++++++++++++ web/package.json | 1 + web/scripts/run_tests.sh | 4 +- web/vite.config.js | 6 + 11 files changed, 612 insertions(+), 3 deletions(-) diff --git a/.github/workflows/js-web.yml b/.github/workflows/js-web.yml index 6377a23..57f6549 100644 --- a/.github/workflows/js-web.yml +++ b/.github/workflows/js-web.yml @@ -69,6 +69,14 @@ jobs: HOME: /root run: ./scripts/run_tests.sh + # Non-blocking: coverage artifact is informational only. + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-js + path: web/coverage/ + if-no-files-found: warn + ci-status-js-web: needs: [build-docker, run-checks] if: always() diff --git a/.github/workflows/python-controller.yml b/.github/workflows/python-controller.yml index 3fd29f0..67c30ac 100644 --- a/.github/workflows/python-controller.yml +++ b/.github/workflows/python-controller.yml @@ -76,6 +76,16 @@ jobs: - name: Run tests run: ./scripts/run_tests.sh + # Non-blocking: coverage artifact is informational only. + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-python + path: | + controller/coverage.xml + controller/coverage/ + if-no-files-found: warn + ci-status-python-controller: needs: [build-docker, run-checks] if: always() diff --git a/controller/.gitignore b/controller/.gitignore index 70776f6..b8e2450 100644 --- a/controller/.gitignore +++ b/controller/.gitignore @@ -38,6 +38,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ +coverage/ .tox/ .nox/ .coverage diff --git a/controller/pyproject.toml b/controller/pyproject.toml index 40e9c82..1da5977 100644 --- a/controller/pyproject.toml +++ b/controller/pyproject.toml @@ -22,7 +22,7 @@ legacy_tox_ini = """ [testenv] deps = -r requirements/dev.txt - commands = pytest tests/ {posargs} + commands = pytest tests/ --cov=src --cov-report=xml:coverage.xml --cov-report=html:coverage/ {posargs} """ [tool.mypy] diff --git a/controller/requirements/dev.in b/controller/requirements/dev.in index 55cbadf..bdca258 100644 --- a/controller/requirements/dev.in +++ b/controller/requirements/dev.in @@ -4,5 +4,6 @@ pip-compile-multi tox pytest pytest-asyncio +pytest-cov ruff mypy diff --git a/controller/requirements/dev.txt b/controller/requirements/dev.txt index ffddd59..030b819 100644 --- a/controller/requirements/dev.txt +++ b/controller/requirements/dev.txt @@ -18,6 +18,8 @@ click==8.1.7 # pip-tools colorama==0.4.6 # via tox +coverage[toml]==7.14.3 + # via pytest-cov distlib==0.3.9 # via virtualenv filelock==3.16.1 @@ -47,6 +49,7 @@ platformdirs==4.3.6 pluggy==1.5.0 # via # pytest + # pytest-cov # tox pyproject-api==1.8.0 # via tox @@ -58,8 +61,11 @@ pytest==8.3.3 # via # -r requirements/dev.in # pytest-asyncio + # pytest-cov pytest-asyncio==0.24.0 # via -r requirements/dev.in +pytest-cov==7.1.0 + # via -r requirements/dev.in ruff==0.15.2 # via -r requirements/dev.in toposort==1.10 diff --git a/web/.gitignore b/web/.gitignore index f0a232b..35b5771 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ test-results/ playwright-report/ +coverage/ diff --git a/web/package-lock.json b/web/package-lock.json index bbb6e30..f2c790c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@playwright/test": "^1", + "@vitest/coverage-v8": "^4.1.4", "jsdom": "^26", "vite-plus": "^0.1", "ws": "^8" @@ -37,6 +38,66 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -189,6 +250,34 @@ "tslib": "^2.4.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1835,6 +1924,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -1844,6 +1940,155 @@ "undici-types": "~7.19.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.4", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@voidzero-dev/vite-plus-core": { "version": "0.1.18", "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.1.18.tgz", @@ -2187,6 +2432,36 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -2270,6 +2545,27 @@ "dev": true, "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2303,6 +2599,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2316,6 +2622,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2364,6 +2677,52 @@ "dev": true, "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", @@ -2690,6 +3049,45 @@ "dev": true, "license": "ISC" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -2860,6 +3258,14 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3060,6 +3466,27 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3085,6 +3512,14 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -3092,6 +3527,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3143,6 +3591,16 @@ "node": "^20.0.0 || >=22.0.0" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -3342,6 +3800,105 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -3403,6 +3960,24 @@ "node": ">=18" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/web/package.json b/web/package.json index 6b7d94c..003f7fa 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@playwright/test": "^1", + "@vitest/coverage-v8": "^4.1.4", "jsdom": "^26", "vite-plus": "^0.1", "ws": "^8" diff --git a/web/scripts/run_tests.sh b/web/scripts/run_tests.sh index 5c4f02a..8ce9a6e 100755 --- a/web/scripts/run_tests.sh +++ b/web/scripts/run_tests.sh @@ -8,8 +8,8 @@ if [ "$1" = "tests/e2e/full_stack" ]; then elif [ "$1" = "tests/e2e" ]; then npx playwright test --project=firefox "${@:2}" elif [ $# -eq 0 ]; then - npx vp test --run + npx vp test run --coverage npx playwright test --project=firefox else - npx vp test --run "$@" + npx vp test run --coverage "$@" fi diff --git a/web/vite.config.js b/web/vite.config.js index 9cb7d46..fbf812d 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -21,5 +21,11 @@ export default defineConfig({ test: { environment: "jsdom", include: ["tests/unit/**/*.test.js", "tests/integration/**/*.test.js"], + coverage: { + provider: "v8", + reporter: ["lcov", "html"], + include: ["src/**"], + reportsDirectory: "./coverage", + }, }, }); From c3203ac85ba80a10141467ab8e19210c104e2e69 Mon Sep 17 00:00:00 2001 From: Lukasz Towarek Date: Sat, 27 Jun 2026 07:03:02 +0000 Subject: [PATCH 2/8] Gather code coverage in cpp --- .github/workflows/cpp-car.yml | 79 ++++++++++++++++--- CONTRIBUTING.md | 40 +++++++++- car/.gitignore | 4 + .../telemetry/test_apps/CMakeLists.txt | 11 +++ .../test_apps/main/Kconfig.projbuild | 10 +++ .../test_apps/sdkconfig.defaults.coverage | 1 + .../web_server/test_apps/CMakeLists.txt | 11 +++ .../test_apps/main/Kconfig.projbuild | 10 +++ .../test_apps/sdkconfig.defaults.coverage | 1 + car/scripts/run_build.sh | 10 +++ car/scripts/run_coverage.sh | 12 +++ car/scripts/run_coverage_report.sh | 19 +++++ car/scripts/run_qemu_tests.sh | 11 +++ controller/requirements/dev.txt | 2 +- 14 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 car/components/telemetry/test_apps/sdkconfig.defaults.coverage create mode 100644 car/components/web_server/test_apps/sdkconfig.defaults.coverage create mode 100755 car/scripts/run_build.sh create mode 100755 car/scripts/run_coverage.sh create mode 100755 car/scripts/run_coverage_report.sh create mode 100755 car/scripts/run_qemu_tests.sh diff --git a/.github/workflows/cpp-car.yml b/.github/workflows/cpp-car.yml index 0d8f2ab..5a69641 100644 --- a/.github/workflows/cpp-car.yml +++ b/.github/workflows/cpp-car.yml @@ -189,10 +189,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: @@ -205,6 +205,7 @@ jobs: defaults: run: shell: bash + working-directory: car steps: - name: Checkout @@ -225,16 +226,74 @@ 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.py + - name: web_server + component_path: components/web_server/test_apps + pytest_pattern: pytest_web_server_qemu.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 }} --qemu-extra-args="-semihosting" + + - name: Generate coverage report + run: ./scripts/run_coverage_report.sh ${{ matrix.component_path }} + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-cpp-${{ matrix.name }} + path: car/${{ matrix.component_path }}/coverage/ + if-no-files-found: warn ci-status-cpp-car: needs: [build-docker, build, clang-format, clang-tidy, ubsan] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca77a27..d4aa67a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) @@ -475,6 +478,41 @@ 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__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 +if(CONFIG__TEST_COVERAGE) + idf_component_get_property(_lib COMPONENT_LIB) + target_compile_options(${_lib} PRIVATE "--coverage") + target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage") +endif() +``` + +The `--coverage` link flag lets the compiler pick the correct `libgcov` multilib variant. No +coverage-specific code is needed in `main.cpp`: when `app_main` returns, ESP-IDF's task cleanup +calls `exit()` internally, which fires the gcov `atexit` handler and flushes `.gcda` files. QEMU +is launched with `-semihosting` so those writes land directly on the host filesystem inside +`build/`. The existing QEMU pytest script (e.g. `pytest__qemu.py`) is reused unchanged +— no separate coverage script is needed. + +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 existing QEMU +pytest with `--qemu-extra-args="-semihosting"`, and uploads an HTML report as a CI artifact. 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.py +./scripts/run_coverage.sh components/web_server/test_apps pytest_web_server_qemu.py +``` + ### Car test types Test scope and execution environment are independent choices. Scope determines what is under test; environment determines where it runs. diff --git a/car/.gitignore b/car/.gitignore index 6c8ebe0..505a939 100644 --- a/car/.gitignore +++ b/car/.gitignore @@ -56,3 +56,7 @@ env/ # clang-tidy warnings.txt + +# gcov coverage +coverage.info +coverage/ diff --git a/car/components/telemetry/test_apps/CMakeLists.txt b/car/components/telemetry/test_apps/CMakeLists.txt index b25af7e..962d5f2 100644 --- a/car/components/telemetry/test_apps/CMakeLists.txt +++ b/car/components/telemetry/test_apps/CMakeLists.txt @@ -12,4 +12,15 @@ project(telemetry_test) if(CONFIG_TELEMETRY_TEST_UBSAN) idf_component_get_property(telemetry_lib telemetry COMPONENT_LIB) target_compile_options(${telemetry_lib} PRIVATE "-fsanitize=undefined" "-fno-sanitize=shift-base") +endif() + +# Opt-in via CONFIG_TELEMETRY_TEST_COVERAGE (see CONTRIBUTING.md); scoped to +# the component under test. +if(CONFIG_TELEMETRY_TEST_COVERAGE) + idf_component_get_property(telemetry_lib telemetry COMPONENT_LIB) + target_compile_options(${telemetry_lib} PRIVATE "--coverage") + + # --coverage on the link step lets g++ pick the right libgcov multilib + # variant; -lgcov alone does not resolve the correct esp32s3 multilib path. + target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage") endif() \ No newline at end of file diff --git a/car/components/telemetry/test_apps/main/Kconfig.projbuild b/car/components/telemetry/test_apps/main/Kconfig.projbuild index 5bfc5c6..121a473 100644 --- a/car/components/telemetry/test_apps/main/Kconfig.projbuild +++ b/car/components/telemetry/test_apps/main/Kconfig.projbuild @@ -16,4 +16,14 @@ config TELEMETRY_TEST_UBSAN Opt-in: not part of the default build, only enabled by the dedicated UBSan CI job/sdkconfig overlay. +config TELEMETRY_TEST_COVERAGE + bool "Build telemetry with GCC coverage instrumentation" + default n + help + Compiles the telemetry component under test with --coverage. When + app_main returns, ESP-IDF's task cleanup calls exit() which triggers + the gcov atexit handler; QEMU semihosting writes the .gcda files + directly to the host. Opt-in: only enabled by the dedicated coverage + CI job/sdkconfig overlay. + endmenu diff --git a/car/components/telemetry/test_apps/sdkconfig.defaults.coverage b/car/components/telemetry/test_apps/sdkconfig.defaults.coverage new file mode 100644 index 0000000..d5b27a6 --- /dev/null +++ b/car/components/telemetry/test_apps/sdkconfig.defaults.coverage @@ -0,0 +1 @@ +CONFIG_TELEMETRY_TEST_COVERAGE=y diff --git a/car/components/web_server/test_apps/CMakeLists.txt b/car/components/web_server/test_apps/CMakeLists.txt index 079e315..8f65c1b 100644 --- a/car/components/web_server/test_apps/CMakeLists.txt +++ b/car/components/web_server/test_apps/CMakeLists.txt @@ -11,3 +11,14 @@ if(CONFIG_WEB_SERVER_TEST_UBSAN) idf_component_get_property(web_server_lib web_server COMPONENT_LIB) target_compile_options(${web_server_lib} PRIVATE "-fsanitize=undefined" "-fno-sanitize=shift-base") endif() + +# Opt-in via CONFIG_WEB_SERVER_TEST_COVERAGE (see CONTRIBUTING.md); scoped to +# the component under test. +if(CONFIG_WEB_SERVER_TEST_COVERAGE) + idf_component_get_property(web_server_lib web_server COMPONENT_LIB) + target_compile_options(${web_server_lib} PRIVATE "--coverage") + + # --coverage on the link step lets g++ pick the right libgcov multilib + # variant; -lgcov alone does not resolve the correct esp32s3 multilib path. + target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage") +endif() diff --git a/car/components/web_server/test_apps/main/Kconfig.projbuild b/car/components/web_server/test_apps/main/Kconfig.projbuild index 28fdeb8..b720cf6 100644 --- a/car/components/web_server/test_apps/main/Kconfig.projbuild +++ b/car/components/web_server/test_apps/main/Kconfig.projbuild @@ -16,4 +16,14 @@ config WEB_SERVER_TEST_UBSAN Opt-in: not part of the default build, only enabled by the dedicated UBSan CI job/sdkconfig overlay. +config WEB_SERVER_TEST_COVERAGE + bool "Build web_server with GCC coverage instrumentation" + default n + help + Compiles the web_server component under test with --coverage. When + app_main returns, ESP-IDF's task cleanup calls exit() which triggers + the gcov atexit handler; QEMU semihosting writes the .gcda files + directly to the host. Opt-in: only enabled by the dedicated coverage + CI job/sdkconfig overlay. + endmenu diff --git a/car/components/web_server/test_apps/sdkconfig.defaults.coverage b/car/components/web_server/test_apps/sdkconfig.defaults.coverage new file mode 100644 index 0000000..91e5514 --- /dev/null +++ b/car/components/web_server/test_apps/sdkconfig.defaults.coverage @@ -0,0 +1 @@ +CONFIG_WEB_SERVER_TEST_COVERAGE=y diff --git a/car/scripts/run_build.sh b/car/scripts/run_build.sh new file mode 100755 index 0000000..3bb562d --- /dev/null +++ b/car/scripts/run_build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -e + +COMPONENT_DIR="${1?Usage: run_build.sh }" + +source "$IDF_PATH/export.sh" + +pushd "$COMPONENT_DIR" > /dev/null +idf.py build +popd > /dev/null diff --git a/car/scripts/run_coverage.sh b/car/scripts/run_coverage.sh new file mode 100755 index 0000000..75e20ae --- /dev/null +++ b/car/scripts/run_coverage.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e + +COMPONENT_DIR="${1?Usage: run_coverage.sh }" +PYTEST_PATTERN="${2?Usage: run_coverage.sh }" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.qemu;sdkconfig.defaults.coverage" \ + "$SCRIPT_DIR/run_build.sh" "$COMPONENT_DIR" +"$SCRIPT_DIR/run_qemu_tests.sh" "$COMPONENT_DIR" "$PYTEST_PATTERN" --qemu-extra-args="-semihosting" +"$SCRIPT_DIR/run_coverage_report.sh" "$COMPONENT_DIR" diff --git a/car/scripts/run_coverage_report.sh b/car/scripts/run_coverage_report.sh new file mode 100755 index 0000000..23c47a9 --- /dev/null +++ b/car/scripts/run_coverage_report.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e + +COMPONENT_DIR="${1?Usage: run_coverage_report.sh }" + +source "$IDF_PATH/export.sh" + +pushd "$COMPONENT_DIR" > /dev/null +lcov \ + --gcov-tool xtensa-esp-elf-gcov \ + --capture \ + --directory build \ + --output-file coverage.info \ + --ignore-errors mismatch \ + --quiet +genhtml coverage.info \ + --output-directory coverage \ + --quiet +popd > /dev/null diff --git a/car/scripts/run_qemu_tests.sh b/car/scripts/run_qemu_tests.sh new file mode 100755 index 0000000..8543c35 --- /dev/null +++ b/car/scripts/run_qemu_tests.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -e + +COMPONENT_DIR="${1?Usage: run_qemu_tests.sh [pytest_args...]}" +PYTEST_PATTERN="${2?Usage: run_qemu_tests.sh [pytest_args...]}" + +source "$IDF_PATH/export.sh" + +pushd "$COMPONENT_DIR" > /dev/null +python -m pytest "$PYTEST_PATTERN" --embedded-services idf,qemu "${@:3}" -v +popd > /dev/null diff --git a/controller/requirements/dev.txt b/controller/requirements/dev.txt index 030b819..893e947 100644 --- a/controller/requirements/dev.txt +++ b/controller/requirements/dev.txt @@ -1,4 +1,4 @@ -# SHA1:110e1ac0a124f287291acffde99eeef3c11e06a8 +# SHA1:eb0db4f56a5a260ac8d59d09569a00365be70069 # # This file is autogenerated by pip-compile-multi # To update, run: From aadba5ec57be3b2ae99ac47b2d547a463419eada Mon Sep 17 00:00:00 2001 From: Lukasz Towarek Date: Sat, 27 Jun 2026 09:35:01 +0200 Subject: [PATCH 3/8] Call exit(0) after Unity tests to flush gcov data before QEMU shutdown Without an explicit exit(0), pytest-embedded may kill QEMU before ESP-IDF's async task cleanup fires the gcov atexit handler, leaving no .gcda files for lcov. The explicit call makes writes deterministic: gcov flushes via semihosting, then SYS_EXIT terminates QEMU cleanly. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01V97dQmUxePcA6qGhVNvyhN --- .devcontainer/cpp/docker-compose.yml | 4 ++-- CONTRIBUTING.md | 20 +++++++++++++------ .../test_apps/main/Kconfig.projbuild | 9 ++++----- .../telemetry/test_apps/main/main.cpp | 6 ++++++ .../test_apps/main/Kconfig.projbuild | 9 ++++----- .../web_server/test_apps/main/main.cpp | 6 ++++++ docker-compose.headless.yml | 4 ++-- 7 files changed, 38 insertions(+), 20 deletions(-) diff --git a/.devcontainer/cpp/docker-compose.yml b/.devcontainer/cpp/docker-compose.yml index c703abe..56dd30b 100644 --- a/.devcontainer/cpp/docker-compose.yml +++ b/.devcontainer/cpp/docker-compose.yml @@ -16,8 +16,8 @@ services: env_file: - path: ../../.env # Uncomment to pass through ESP32 device. - # devices: - # - /dev/ttyACM0:/dev/ttyACM0 + devices: + - /dev/ttyACM0:/dev/ttyACM0 command: sleep infinity networks: - dust-mite-observability diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4aa67a..1a7e0b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -493,12 +493,20 @@ if(CONFIG__TEST_COVERAGE) endif() ``` -The `--coverage` link flag lets the compiler pick the correct `libgcov` multilib variant. No -coverage-specific code is needed in `main.cpp`: when `app_main` returns, ESP-IDF's task cleanup -calls `exit()` internally, which fires the gcov `atexit` handler and flushes `.gcda` files. QEMU -is launched with `-semihosting` so those writes land directly on the host filesystem inside -`build/`. The existing QEMU pytest script (e.g. `pytest__qemu.py`) is reused unchanged -— no separate coverage script is needed. +The `--coverage` link flag lets the compiler pick the correct `libgcov` multilib variant. The test +app's `main.cpp` calls `exit(0)` after `UNITY_END()` when coverage is enabled: + +```cpp +#ifdef CONFIG__TEST_COVERAGE + exit(0); +#endif +``` + +`exit(0)` fires the gcov `atexit` handler which flushes `.gcda` files, then triggers QEMU's +semihosting `SYS_EXIT` for a clean shutdown. Without the explicit call, pytest-embedded may kill +QEMU before the writes complete. QEMU is launched with `-semihosting` so the writes land directly +on the host filesystem inside `build/`. The existing QEMU pytest script (e.g. +`pytest__qemu.py`) is reused unchanged — no separate coverage script is needed. 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 diff --git a/car/components/telemetry/test_apps/main/Kconfig.projbuild b/car/components/telemetry/test_apps/main/Kconfig.projbuild index 121a473..f701701 100644 --- a/car/components/telemetry/test_apps/main/Kconfig.projbuild +++ b/car/components/telemetry/test_apps/main/Kconfig.projbuild @@ -20,10 +20,9 @@ config TELEMETRY_TEST_COVERAGE bool "Build telemetry with GCC coverage instrumentation" default n help - Compiles the telemetry component under test with --coverage. When - app_main returns, ESP-IDF's task cleanup calls exit() which triggers - the gcov atexit handler; QEMU semihosting writes the .gcda files - directly to the host. Opt-in: only enabled by the dedicated coverage - CI job/sdkconfig overlay. + Compiles the telemetry component under test with --coverage. app_main + calls exit(0) after the test run so the gcov atexit handler flushes + .gcda files before QEMU exits via semihosting. Opt-in: only enabled + by the dedicated coverage CI job/sdkconfig overlay. endmenu diff --git a/car/components/telemetry/test_apps/main/main.cpp b/car/components/telemetry/test_apps/main/main.cpp index 379a7c6..48da14c 100644 --- a/car/components/telemetry/test_apps/main/main.cpp +++ b/car/components/telemetry/test_apps/main/main.cpp @@ -2,6 +2,9 @@ #include "unity.h" #include "driver/gpio.h" #include "sdkconfig.h" +#ifdef CONFIG_TELEMETRY_TEST_COVERAGE +#include +#endif extern "C" void app_main(void) { #ifndef CONFIG_TELEMETRY_TEST_QEMU_MODE @@ -20,6 +23,9 @@ extern "C" void app_main(void) { UNITY_BEGIN(); unity_run_all_tests(); UNITY_END(); +#ifdef CONFIG_TELEMETRY_TEST_COVERAGE + exit(0); +#endif } void setUp(void) {} diff --git a/car/components/web_server/test_apps/main/Kconfig.projbuild b/car/components/web_server/test_apps/main/Kconfig.projbuild index b720cf6..6f0bec4 100644 --- a/car/components/web_server/test_apps/main/Kconfig.projbuild +++ b/car/components/web_server/test_apps/main/Kconfig.projbuild @@ -20,10 +20,9 @@ config WEB_SERVER_TEST_COVERAGE bool "Build web_server with GCC coverage instrumentation" default n help - Compiles the web_server component under test with --coverage. When - app_main returns, ESP-IDF's task cleanup calls exit() which triggers - the gcov atexit handler; QEMU semihosting writes the .gcda files - directly to the host. Opt-in: only enabled by the dedicated coverage - CI job/sdkconfig overlay. + Compiles the web_server component under test with --coverage. app_main + calls exit(0) after the test run so the gcov atexit handler flushes + .gcda files before QEMU exits via semihosting. Opt-in: only enabled + by the dedicated coverage CI job/sdkconfig overlay. endmenu diff --git a/car/components/web_server/test_apps/main/main.cpp b/car/components/web_server/test_apps/main/main.cpp index bea04c3..aea53fe 100644 --- a/car/components/web_server/test_apps/main/main.cpp +++ b/car/components/web_server/test_apps/main/main.cpp @@ -7,6 +7,9 @@ #include "telemetry.hpp" #include "unity.h" #include "sdkconfig.h" +#ifdef CONFIG_WEB_SERVER_TEST_COVERAGE +#include +#endif extern "C" void app_main(void) { QueueHandle_t command_queue = xQueueCreate(2, sizeof(command_packet_t)); @@ -23,6 +26,9 @@ extern "C" void app_main(void) { UNITY_BEGIN(); unity_run_all_tests(); UNITY_END(); +#ifdef CONFIG_WEB_SERVER_TEST_COVERAGE + exit(0); +#endif } void setUp(void) {} diff --git a/docker-compose.headless.yml b/docker-compose.headless.yml index 7822379..ad1ec89 100644 --- a/docker-compose.headless.yml +++ b/docker-compose.headless.yml @@ -1,8 +1,8 @@ services: cpp: # Uncomment to pass through ESP32 device. - # devices: - # - /dev/ttyACM0:/dev/ttyACM0 + devices: + - /dev/ttyACM0:/dev/ttyACM0 command: script -q -c "/home/dustmite/run_cpp.sh" /dev/null python: From 7806cf209b66b45107803b1e862f6bea86e2188b Mon Sep 17 00:00:00 2001 From: Lukasz Towarek Date: Sat, 27 Jun 2026 12:05:12 +0000 Subject: [PATCH 4/8] Refactor gcov --- .devcontainer/cpp/docker-compose.yml | 4 +- .github/workflows/cpp-car.yml | 6 +- CONTRIBUTING.md | 37 +++--- car/components/gcov_uart_vfs/CMakeLists.txt | 13 +++ car/components/gcov_uart_vfs/gcov_uart_vfs.c | 105 ++++++++++++++++++ car/components/gcov_uart_vfs/gcov_uart_vfs.h | 12 ++ .../telemetry/test_apps/CMakeLists.txt | 4 +- .../telemetry/test_apps/main/CMakeLists.txt | 2 +- .../test_apps/main/Kconfig.projbuild | 11 +- .../telemetry/test_apps/main/main.cpp | 4 +- .../pytest_telemetry_qemu_coverage.py | 37 ++++++ .../web_server/test_apps/CMakeLists.txt | 4 +- .../web_server/test_apps/main/CMakeLists.txt | 2 +- .../test_apps/main/Kconfig.projbuild | 11 +- .../web_server/test_apps/main/main.cpp | 4 +- .../pytest_web_server_qemu_coverage.py | 37 ++++++ car/scripts/run_coverage.sh | 2 +- docker-compose.headless.yml | 4 +- 18 files changed, 259 insertions(+), 40 deletions(-) create mode 100644 car/components/gcov_uart_vfs/CMakeLists.txt create mode 100644 car/components/gcov_uart_vfs/gcov_uart_vfs.c create mode 100644 car/components/gcov_uart_vfs/gcov_uart_vfs.h create mode 100644 car/components/telemetry/test_apps/pytest_telemetry_qemu_coverage.py create mode 100644 car/components/web_server/test_apps/pytest_web_server_qemu_coverage.py diff --git a/.devcontainer/cpp/docker-compose.yml b/.devcontainer/cpp/docker-compose.yml index 56dd30b..c703abe 100644 --- a/.devcontainer/cpp/docker-compose.yml +++ b/.devcontainer/cpp/docker-compose.yml @@ -16,8 +16,8 @@ services: env_file: - path: ../../.env # Uncomment to pass through ESP32 device. - devices: - - /dev/ttyACM0:/dev/ttyACM0 + # devices: + # - /dev/ttyACM0:/dev/ttyACM0 command: sleep infinity networks: - dust-mite-observability diff --git a/.github/workflows/cpp-car.yml b/.github/workflows/cpp-car.yml index 5a69641..5f096cf 100644 --- a/.github/workflows/cpp-car.yml +++ b/.github/workflows/cpp-car.yml @@ -244,10 +244,10 @@ jobs: include: - name: telemetry component_path: components/telemetry/test_apps - pytest_pattern: pytest_telemetry_qemu.py + pytest_pattern: pytest_telemetry_qemu_coverage.py - name: web_server component_path: components/web_server/test_apps - pytest_pattern: pytest_web_server_qemu.py + pytest_pattern: pytest_web_server_qemu_coverage.py container: image: ghcr.io/ltowarek/dust-mite-cpp-devcontainer @@ -283,7 +283,7 @@ jobs: 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 }} --qemu-extra-args="-semihosting" + 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 }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a7e0b4..6ab4d0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -486,39 +486,44 @@ app's `main/Kconfig.projbuild`, a matching `sdkconfig.defaults.coverage` overlay `y`, and guarding the coverage flags in the test app's `CMakeLists.txt`: ```cmake +set(main_requires ... ) if(CONFIG__TEST_COVERAGE) - idf_component_get_property(_lib COMPONENT_LIB) - target_compile_options(${_lib} PRIVATE "--coverage") - target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage") + list(APPEND main_requires gcov_uart_vfs) endif() + +idf_component_register(SRCS ${srcs} ... PRIV_REQUIRES ${main_requires} ...) ``` -The `--coverage` link flag lets the compiler pick the correct `libgcov` multilib variant. The test -app's `main.cpp` calls `exit(0)` after `UNITY_END()` when coverage is enabled: +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__TEST_COVERAGE - exit(0); + gcov_uart_vfs_dump(); #endif ``` -`exit(0)` fires the gcov `atexit` handler which flushes `.gcda` files, then triggers QEMU's -semihosting `SYS_EXIT` for a clean shutdown. Without the explicit call, pytest-embedded may kill -QEMU before the writes complete. QEMU is launched with `-semihosting` so the writes land directly -on the host filesystem inside `build/`. The existing QEMU pytest script (e.g. -`pytest__qemu.py`) is reused unchanged — no separate coverage script is needed. +`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__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 existing QEMU -pytest with `--qemu-extra-args="-semihosting"`, and uploads an HTML report as a CI artifact. The -`coverage` CI job is non-blocking (not part of `ci-status-cpp-car`). +`sdkconfig.defaults;sdkconfig.defaults.qemu;sdkconfig.defaults.coverage`, runs the coverage pytest, +and uploads an HTML report as a CI artifact. 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.py -./scripts/run_coverage.sh components/web_server/test_apps pytest_web_server_qemu.py +./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 diff --git a/car/components/gcov_uart_vfs/CMakeLists.txt b/car/components/gcov_uart_vfs/CMakeLists.txt new file mode 100644 index 0000000..7bd0067 --- /dev/null +++ b/car/components/gcov_uart_vfs/CMakeLists.txt @@ -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}") diff --git a/car/components/gcov_uart_vfs/gcov_uart_vfs.c b/car/components/gcov_uart_vfs/gcov_uart_vfs.c new file mode 100644 index 0000000..097ccd1 --- /dev/null +++ b/car/components/gcov_uart_vfs/gcov_uart_vfs.c @@ -0,0 +1,105 @@ +#include "gcov_uart_vfs.h" +#include "esp_vfs.h" +#include "esp_vfs_ops.h" +#include +#include +#include +#include +#include +#include + +#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); +} diff --git a/car/components/gcov_uart_vfs/gcov_uart_vfs.h b/car/components/gcov_uart_vfs/gcov_uart_vfs.h new file mode 100644 index 0000000..645dd6c --- /dev/null +++ b/car/components/gcov_uart_vfs/gcov_uart_vfs.h @@ -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 diff --git a/car/components/telemetry/test_apps/CMakeLists.txt b/car/components/telemetry/test_apps/CMakeLists.txt index 962d5f2..e8bbd03 100644 --- a/car/components/telemetry/test_apps/CMakeLists.txt +++ b/car/components/telemetry/test_apps/CMakeLists.txt @@ -22,5 +22,7 @@ if(CONFIG_TELEMETRY_TEST_COVERAGE) # --coverage on the link step lets g++ pick the right libgcov multilib # variant; -lgcov alone does not resolve the correct esp32s3 multilib path. - target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage") + # --undefined=__gcov_dump forces the linker to pull the symbol from libgcov; + # without it the weak reference in gcov_uart_vfs.c stays NULL. + target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage" "-Wl,--undefined=__gcov_dump") endif() \ No newline at end of file diff --git a/car/components/telemetry/test_apps/main/CMakeLists.txt b/car/components/telemetry/test_apps/main/CMakeLists.txt index 6ebba4d..abbb325 100644 --- a/car/components/telemetry/test_apps/main/CMakeLists.txt +++ b/car/components/telemetry/test_apps/main/CMakeLists.txt @@ -5,5 +5,5 @@ endif() idf_component_register(SRCS ${srcs} INCLUDE_DIRS "." - PRIV_REQUIRES unity telemetry + PRIV_REQUIRES unity telemetry gcov_uart_vfs WHOLE_ARCHIVE) diff --git a/car/components/telemetry/test_apps/main/Kconfig.projbuild b/car/components/telemetry/test_apps/main/Kconfig.projbuild index f701701..db92ba2 100644 --- a/car/components/telemetry/test_apps/main/Kconfig.projbuild +++ b/car/components/telemetry/test_apps/main/Kconfig.projbuild @@ -20,9 +20,12 @@ config TELEMETRY_TEST_COVERAGE bool "Build telemetry with GCC coverage instrumentation" default n help - Compiles the telemetry component under test with --coverage. app_main - calls exit(0) after the test run so the gcov atexit handler flushes - .gcda files before QEMU exits via semihosting. Opt-in: only enabled - by the dedicated coverage CI job/sdkconfig overlay. + Compiles the telemetry component under test with --coverage. After + the test run, app_main registers a VFS at /gcov that streams gcda + file content as base64 over UART, calls __gcov_dump() to flush + coverage data, then prints GCOV_DUMP_DONE. The coverage pytest + script decodes the UART stream and reconstructs .gcda files on the + host. Opt-in: only enabled by the dedicated coverage CI + job/sdkconfig overlay. endmenu diff --git a/car/components/telemetry/test_apps/main/main.cpp b/car/components/telemetry/test_apps/main/main.cpp index 48da14c..66deea8 100644 --- a/car/components/telemetry/test_apps/main/main.cpp +++ b/car/components/telemetry/test_apps/main/main.cpp @@ -3,7 +3,7 @@ #include "driver/gpio.h" #include "sdkconfig.h" #ifdef CONFIG_TELEMETRY_TEST_COVERAGE -#include +#include "gcov_uart_vfs.h" #endif extern "C" void app_main(void) { @@ -24,7 +24,7 @@ extern "C" void app_main(void) { unity_run_all_tests(); UNITY_END(); #ifdef CONFIG_TELEMETRY_TEST_COVERAGE - exit(0); + gcov_uart_vfs_dump(); #endif } diff --git a/car/components/telemetry/test_apps/pytest_telemetry_qemu_coverage.py b/car/components/telemetry/test_apps/pytest_telemetry_qemu_coverage.py new file mode 100644 index 0000000..a6ffc9d --- /dev/null +++ b/car/components/telemetry/test_apps/pytest_telemetry_qemu_coverage.py @@ -0,0 +1,37 @@ +import base64 +from pathlib import Path + +from pytest_embedded import Dut + + +def test_telemetry_coverage(dut: Dut) -> None: + dut.expect_unity_test_output() + _collect_gcda(dut) + + +def _collect_gcda(dut: Dut) -> None: + base = Path('.') + current_path: str | None = None + current_data = bytearray() + + while True: + match = dut.expect( + r'GCOV_FILE_START:([^\r\n]+)\r?\n|GCOV_B64:([^\r\n]+)\r?\n|GCOV_FILE_END\r?\n|GCOV_DUMP_DONE\r?\n', + timeout=60, + ) + text = match.group(0).decode('ascii').strip() + + if text.startswith('GCOV_FILE_START:'): + current_path = text[len('GCOV_FILE_START:'):] + current_data = bytearray() + elif text.startswith('GCOV_B64:'): + current_data += base64.b64decode(text[len('GCOV_B64:'):]) + elif text == 'GCOV_FILE_END': + if current_path is not None: + dest = base / current_path.lstrip('/') + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(bytes(current_data)) + current_path = None + current_data = bytearray() + elif text == 'GCOV_DUMP_DONE': + return diff --git a/car/components/web_server/test_apps/CMakeLists.txt b/car/components/web_server/test_apps/CMakeLists.txt index 8f65c1b..97a33cf 100644 --- a/car/components/web_server/test_apps/CMakeLists.txt +++ b/car/components/web_server/test_apps/CMakeLists.txt @@ -20,5 +20,7 @@ if(CONFIG_WEB_SERVER_TEST_COVERAGE) # --coverage on the link step lets g++ pick the right libgcov multilib # variant; -lgcov alone does not resolve the correct esp32s3 multilib path. - target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage") + # --undefined=__gcov_dump forces the linker to pull the symbol from libgcov; + # without it the weak reference in gcov_uart_vfs.c stays NULL. + target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage" "-Wl,--undefined=__gcov_dump") endif() diff --git a/car/components/web_server/test_apps/main/CMakeLists.txt b/car/components/web_server/test_apps/main/CMakeLists.txt index f5eb784..60bd1db 100644 --- a/car/components/web_server/test_apps/main/CMakeLists.txt +++ b/car/components/web_server/test_apps/main/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." - PRIV_REQUIRES unity web_server motor tracing wifi telemetry + PRIV_REQUIRES unity web_server motor tracing wifi telemetry gcov_uart_vfs WHOLE_ARCHIVE) diff --git a/car/components/web_server/test_apps/main/Kconfig.projbuild b/car/components/web_server/test_apps/main/Kconfig.projbuild index 6f0bec4..3d5c9e0 100644 --- a/car/components/web_server/test_apps/main/Kconfig.projbuild +++ b/car/components/web_server/test_apps/main/Kconfig.projbuild @@ -20,9 +20,12 @@ config WEB_SERVER_TEST_COVERAGE bool "Build web_server with GCC coverage instrumentation" default n help - Compiles the web_server component under test with --coverage. app_main - calls exit(0) after the test run so the gcov atexit handler flushes - .gcda files before QEMU exits via semihosting. Opt-in: only enabled - by the dedicated coverage CI job/sdkconfig overlay. + Compiles the web_server component under test with --coverage. After + the test run, app_main registers a VFS at /gcov that streams gcda + file content as base64 over UART, calls __gcov_dump() to flush + coverage data, then prints GCOV_DUMP_DONE. The coverage pytest + script decodes the UART stream and reconstructs .gcda files on the + host. Opt-in: only enabled by the dedicated coverage CI + job/sdkconfig overlay. endmenu diff --git a/car/components/web_server/test_apps/main/main.cpp b/car/components/web_server/test_apps/main/main.cpp index aea53fe..d703027 100644 --- a/car/components/web_server/test_apps/main/main.cpp +++ b/car/components/web_server/test_apps/main/main.cpp @@ -8,7 +8,7 @@ #include "unity.h" #include "sdkconfig.h" #ifdef CONFIG_WEB_SERVER_TEST_COVERAGE -#include +#include "gcov_uart_vfs.h" #endif extern "C" void app_main(void) { @@ -27,7 +27,7 @@ extern "C" void app_main(void) { unity_run_all_tests(); UNITY_END(); #ifdef CONFIG_WEB_SERVER_TEST_COVERAGE - exit(0); + gcov_uart_vfs_dump(); #endif } diff --git a/car/components/web_server/test_apps/pytest_web_server_qemu_coverage.py b/car/components/web_server/test_apps/pytest_web_server_qemu_coverage.py new file mode 100644 index 0000000..d97651b --- /dev/null +++ b/car/components/web_server/test_apps/pytest_web_server_qemu_coverage.py @@ -0,0 +1,37 @@ +import base64 +from pathlib import Path + +from pytest_embedded import Dut + + +def test_web_server_coverage(dut: Dut) -> None: + dut.expect_unity_test_output() + _collect_gcda(dut) + + +def _collect_gcda(dut: Dut) -> None: + base = Path('.') + current_path: str | None = None + current_data = bytearray() + + while True: + match = dut.expect( + r'GCOV_FILE_START:([^\r\n]+)\r?\n|GCOV_B64:([^\r\n]+)\r?\n|GCOV_FILE_END\r?\n|GCOV_DUMP_DONE\r?\n', + timeout=60, + ) + text = match.group(0).decode('ascii').strip() + + if text.startswith('GCOV_FILE_START:'): + current_path = text[len('GCOV_FILE_START:'):] + current_data = bytearray() + elif text.startswith('GCOV_B64:'): + current_data += base64.b64decode(text[len('GCOV_B64:'):]) + elif text == 'GCOV_FILE_END': + if current_path is not None: + dest = base / current_path.lstrip('/') + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(bytes(current_data)) + current_path = None + current_data = bytearray() + elif text == 'GCOV_DUMP_DONE': + return diff --git a/car/scripts/run_coverage.sh b/car/scripts/run_coverage.sh index 75e20ae..63fc66a 100755 --- a/car/scripts/run_coverage.sh +++ b/car/scripts/run_coverage.sh @@ -8,5 +8,5 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.qemu;sdkconfig.defaults.coverage" \ "$SCRIPT_DIR/run_build.sh" "$COMPONENT_DIR" -"$SCRIPT_DIR/run_qemu_tests.sh" "$COMPONENT_DIR" "$PYTEST_PATTERN" --qemu-extra-args="-semihosting" +"$SCRIPT_DIR/run_qemu_tests.sh" "$COMPONENT_DIR" "$PYTEST_PATTERN" "$SCRIPT_DIR/run_coverage_report.sh" "$COMPONENT_DIR" diff --git a/docker-compose.headless.yml b/docker-compose.headless.yml index ad1ec89..7822379 100644 --- a/docker-compose.headless.yml +++ b/docker-compose.headless.yml @@ -1,8 +1,8 @@ services: cpp: # Uncomment to pass through ESP32 device. - devices: - - /dev/ttyACM0:/dev/ttyACM0 + # devices: + # - /dev/ttyACM0:/dev/ttyACM0 command: script -q -c "/home/dustmite/run_cpp.sh" /dev/null python: From e74407b266939b9fbd9117005b702f03cecb662c Mon Sep 17 00:00:00 2001 From: Lukasz Towarek Date: Sat, 27 Jun 2026 12:24:45 +0000 Subject: [PATCH 5/8] Use codecov --- .github/workflows/cpp-car.yml | 13 ++++++++----- .github/workflows/js-web.yml | 13 +++++++------ .github/workflows/python-controller.yml | 15 +++++++-------- CONTRIBUTING.md | 13 ++++++------- car/scripts/run_coverage_report.sh | 3 --- codecov.yml | 19 +++++++++++++++++++ controller/pyproject.toml | 2 +- web/vite.config.js | 2 +- 8 files changed, 49 insertions(+), 31 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/cpp-car.yml b/.github/workflows/cpp-car.yml index 5f096cf..cf9b850 100644 --- a/.github/workflows/cpp-car.yml +++ b/.github/workflows/cpp-car.yml @@ -288,12 +288,15 @@ jobs: - name: Generate coverage report run: ./scripts/run_coverage_report.sh ${{ matrix.component_path }} - - name: Upload coverage report - uses: actions/upload-artifact@v4 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 with: - name: coverage-cpp-${{ matrix.name }} - path: car/${{ matrix.component_path }}/coverage/ - if-no-files-found: warn + 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] diff --git a/.github/workflows/js-web.yml b/.github/workflows/js-web.yml index 57f6549..f786e36 100644 --- a/.github/workflows/js-web.yml +++ b/.github/workflows/js-web.yml @@ -69,13 +69,14 @@ jobs: HOME: /root run: ./scripts/run_tests.sh - # Non-blocking: coverage artifact is informational only. - - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 with: - name: coverage-js - path: web/coverage/ - if-no-files-found: warn + token: ${{ secrets.CODECOV_TOKEN }} + files: web/coverage/cobertura-coverage.xml + flags: js + fail_ci_if_error: false + ci-status-js-web: needs: [build-docker, run-checks] diff --git a/.github/workflows/python-controller.yml b/.github/workflows/python-controller.yml index 67c30ac..2110daf 100644 --- a/.github/workflows/python-controller.yml +++ b/.github/workflows/python-controller.yml @@ -76,15 +76,14 @@ jobs: - name: Run tests run: ./scripts/run_tests.sh - # Non-blocking: coverage artifact is informational only. - - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 with: - name: coverage-python - path: | - controller/coverage.xml - controller/coverage/ - if-no-files-found: warn + token: ${{ secrets.CODECOV_TOKEN }} + files: controller/coverage.xml + flags: python + fail_ci_if_error: false + ci-status-python-controller: needs: [build-docker, run-checks] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ab4d0a..09f5274 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -486,14 +486,13 @@ app's `main/Kconfig.projbuild`, a matching `sdkconfig.defaults.coverage` overlay `y`, and guarding the coverage flags in the test app's `CMakeLists.txt`: ```cmake -set(main_requires ... ) -if(CONFIG__TEST_COVERAGE) - list(APPEND main_requires gcov_uart_vfs) -endif() - -idf_component_register(SRCS ${srcs} ... PRIV_REQUIRES ${main_requires} ...) +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. @@ -516,7 +515,7 @@ 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 an HTML report as a CI artifact. The `coverage` CI job is non-blocking (not part of +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): diff --git a/car/scripts/run_coverage_report.sh b/car/scripts/run_coverage_report.sh index 23c47a9..d162a33 100755 --- a/car/scripts/run_coverage_report.sh +++ b/car/scripts/run_coverage_report.sh @@ -13,7 +13,4 @@ lcov \ --output-file coverage.info \ --ignore-errors mismatch \ --quiet -genhtml coverage.info \ - --output-directory coverage \ - --quiet popd > /dev/null diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..77d9926 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,19 @@ +coverage: + status: + project: + default: + target: auto + threshold: 0% + patch: + default: + target: auto + threshold: 0% + +flag_management: + default_rules: + carryforward: true + +comment: + layout: "flags, diff" + behavior: default + require_changes: true diff --git a/controller/pyproject.toml b/controller/pyproject.toml index 1da5977..20f4c2c 100644 --- a/controller/pyproject.toml +++ b/controller/pyproject.toml @@ -22,7 +22,7 @@ legacy_tox_ini = """ [testenv] deps = -r requirements/dev.txt - commands = pytest tests/ --cov=src --cov-report=xml:coverage.xml --cov-report=html:coverage/ {posargs} + commands = pytest tests/ --cov=src --cov-report=xml:coverage.xml {posargs} """ [tool.mypy] diff --git a/web/vite.config.js b/web/vite.config.js index fbf812d..c33d353 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -23,7 +23,7 @@ export default defineConfig({ include: ["tests/unit/**/*.test.js", "tests/integration/**/*.test.js"], coverage: { provider: "v8", - reporter: ["lcov", "html"], + reporter: ["cobertura"], include: ["src/**"], reportsDirectory: "./coverage", }, From 4469f64bfb3d83a528f323978ed2d3d3613cd0fb Mon Sep 17 00:00:00 2001 From: Lukasz Towarek Date: Sat, 27 Jun 2026 16:40:30 +0000 Subject: [PATCH 6/8] Push test results --- .github/workflows/cpp-car.yml | 14 +++++-- .github/workflows/js-web.yml | 9 +++++ .github/workflows/python-controller.yml | 9 +++++ .../telemetry/test_apps/CMakeLists.txt | 2 +- .../pytest_telemetry_qemu_coverage.py | 34 +---------------- .../pytest_web_server_qemu_coverage.py | 34 +---------------- car/conftest.py | 1 + car/pytest.ini | 1 + car/scripts/run_qemu_tests.sh | 2 +- car/test_apps/conftest.py | 3 +- car/test_apps/helpers.py | 6 --- car/test_helpers/helpers.py | 37 +++++++++++++++++++ codecov.yml | 3 ++ controller/pyproject.toml | 2 +- web/vite.config.js | 2 + 15 files changed, 80 insertions(+), 79 deletions(-) create mode 100644 car/conftest.py delete mode 100644 car/test_apps/helpers.py create mode 100644 car/test_helpers/helpers.py diff --git a/.github/workflows/cpp-car.yml b/.github/workflows/cpp-car.yml index cf9b850..641a70f 100644 --- a/.github/workflows/cpp-car.yml +++ b/.github/workflows/cpp-car.yml @@ -102,10 +102,16 @@ 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/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ matrix.path }}/results.xml + flags: cpp + fail_ci_if_error: false clang-format: needs: build-docker diff --git a/.github/workflows/js-web.yml b/.github/workflows/js-web.yml index f786e36..e28d604 100644 --- a/.github/workflows/js-web.yml +++ b/.github/workflows/js-web.yml @@ -77,6 +77,15 @@ jobs: flags: js fail_ci_if_error: false + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: web/test-report.junit.xml + flags: js + fail_ci_if_error: false + ci-status-js-web: needs: [build-docker, run-checks] diff --git a/.github/workflows/python-controller.yml b/.github/workflows/python-controller.yml index 2110daf..b0bc0d9 100644 --- a/.github/workflows/python-controller.yml +++ b/.github/workflows/python-controller.yml @@ -84,6 +84,15 @@ jobs: flags: python fail_ci_if_error: false + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: controller/results.xml + flags: python + fail_ci_if_error: false + ci-status-python-controller: needs: [build-docker, run-checks] diff --git a/car/components/telemetry/test_apps/CMakeLists.txt b/car/components/telemetry/test_apps/CMakeLists.txt index e8bbd03..ac3f3b8 100644 --- a/car/components/telemetry/test_apps/CMakeLists.txt +++ b/car/components/telemetry/test_apps/CMakeLists.txt @@ -25,4 +25,4 @@ if(CONFIG_TELEMETRY_TEST_COVERAGE) # --undefined=__gcov_dump forces the linker to pull the symbol from libgcov; # without it the weak reference in gcov_uart_vfs.c stays NULL. target_link_options(${CMAKE_PROJECT_NAME}.elf PRIVATE "--coverage" "-Wl,--undefined=__gcov_dump") -endif() \ No newline at end of file +endif() diff --git a/car/components/telemetry/test_apps/pytest_telemetry_qemu_coverage.py b/car/components/telemetry/test_apps/pytest_telemetry_qemu_coverage.py index a6ffc9d..474f913 100644 --- a/car/components/telemetry/test_apps/pytest_telemetry_qemu_coverage.py +++ b/car/components/telemetry/test_apps/pytest_telemetry_qemu_coverage.py @@ -1,37 +1,7 @@ -import base64 -from pathlib import Path - +from helpers import collect_gcda from pytest_embedded import Dut def test_telemetry_coverage(dut: Dut) -> None: dut.expect_unity_test_output() - _collect_gcda(dut) - - -def _collect_gcda(dut: Dut) -> None: - base = Path('.') - current_path: str | None = None - current_data = bytearray() - - while True: - match = dut.expect( - r'GCOV_FILE_START:([^\r\n]+)\r?\n|GCOV_B64:([^\r\n]+)\r?\n|GCOV_FILE_END\r?\n|GCOV_DUMP_DONE\r?\n', - timeout=60, - ) - text = match.group(0).decode('ascii').strip() - - if text.startswith('GCOV_FILE_START:'): - current_path = text[len('GCOV_FILE_START:'):] - current_data = bytearray() - elif text.startswith('GCOV_B64:'): - current_data += base64.b64decode(text[len('GCOV_B64:'):]) - elif text == 'GCOV_FILE_END': - if current_path is not None: - dest = base / current_path.lstrip('/') - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(bytes(current_data)) - current_path = None - current_data = bytearray() - elif text == 'GCOV_DUMP_DONE': - return + collect_gcda(dut) diff --git a/car/components/web_server/test_apps/pytest_web_server_qemu_coverage.py b/car/components/web_server/test_apps/pytest_web_server_qemu_coverage.py index d97651b..b6c1d91 100644 --- a/car/components/web_server/test_apps/pytest_web_server_qemu_coverage.py +++ b/car/components/web_server/test_apps/pytest_web_server_qemu_coverage.py @@ -1,37 +1,7 @@ -import base64 -from pathlib import Path - +from helpers import collect_gcda from pytest_embedded import Dut def test_web_server_coverage(dut: Dut) -> None: dut.expect_unity_test_output() - _collect_gcda(dut) - - -def _collect_gcda(dut: Dut) -> None: - base = Path('.') - current_path: str | None = None - current_data = bytearray() - - while True: - match = dut.expect( - r'GCOV_FILE_START:([^\r\n]+)\r?\n|GCOV_B64:([^\r\n]+)\r?\n|GCOV_FILE_END\r?\n|GCOV_DUMP_DONE\r?\n', - timeout=60, - ) - text = match.group(0).decode('ascii').strip() - - if text.startswith('GCOV_FILE_START:'): - current_path = text[len('GCOV_FILE_START:'):] - current_data = bytearray() - elif text.startswith('GCOV_B64:'): - current_data += base64.b64decode(text[len('GCOV_B64:'):]) - elif text == 'GCOV_FILE_END': - if current_path is not None: - dest = base / current_path.lstrip('/') - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(bytes(current_data)) - current_path = None - current_data = bytearray() - elif text == 'GCOV_DUMP_DONE': - return + collect_gcda(dut) diff --git a/car/conftest.py b/car/conftest.py new file mode 100644 index 0000000..81478cf --- /dev/null +++ b/car/conftest.py @@ -0,0 +1 @@ +# pytest adds this directory to sys.path, making car-level helpers importable. diff --git a/car/pytest.ini b/car/pytest.ini index eea2c18..9169891 100644 --- a/car/pytest.ini +++ b/car/pytest.ini @@ -1 +1,2 @@ [pytest] +pythonpath = test_helpers diff --git a/car/scripts/run_qemu_tests.sh b/car/scripts/run_qemu_tests.sh index 8543c35..d5ae4d3 100755 --- a/car/scripts/run_qemu_tests.sh +++ b/car/scripts/run_qemu_tests.sh @@ -7,5 +7,5 @@ PYTEST_PATTERN="${2?Usage: run_qemu_tests.sh [p source "$IDF_PATH/export.sh" pushd "$COMPONENT_DIR" > /dev/null -python -m pytest "$PYTEST_PATTERN" --embedded-services idf,qemu "${@:3}" -v +python -m pytest "$PYTEST_PATTERN" --embedded-services idf,qemu --junitxml=results.xml "${@:3}" -v popd > /dev/null diff --git a/car/test_apps/conftest.py b/car/test_apps/conftest.py index ddbf1c0..ce6988c 100644 --- a/car/test_apps/conftest.py +++ b/car/test_apps/conftest.py @@ -1,2 +1 @@ -# Shared fixtures and helpers for integration and E2E tests. -# pytest adds this directory to sys.path, making helpers.py importable. +# Shared fixtures for integration and E2E tests. diff --git a/car/test_apps/helpers.py b/car/test_apps/helpers.py deleted file mode 100644 index 8058aae..0000000 --- a/car/test_apps/helpers.py +++ /dev/null @@ -1,6 +0,0 @@ -from pytest_embedded import Dut - - -def get_dut_ip(dut: Dut) -> str: - match = dut.expect(r'sta ip: (\d+\.\d+\.\d+\.\d+)', timeout=30) - return match.group(1).decode() diff --git a/car/test_helpers/helpers.py b/car/test_helpers/helpers.py new file mode 100644 index 0000000..8aba869 --- /dev/null +++ b/car/test_helpers/helpers.py @@ -0,0 +1,37 @@ +import base64 +from pathlib import Path + +from pytest_embedded import Dut + + +def get_dut_ip(dut: Dut) -> str: + match = dut.expect(r'sta ip: (\d+\.\d+\.\d+\.\d+)', timeout=30) + return match.group(1).decode() + + +def collect_gcda(dut: Dut) -> None: + base = Path('.') + current_path: str | None = None + current_data = bytearray() + + while True: + match = dut.expect( + r'GCOV_FILE_START:([^\r\n]+)\r?\n|GCOV_B64:([^\r\n]+)\r?\n|GCOV_FILE_END\r?\n|GCOV_DUMP_DONE\r?\n', + timeout=60, + ) + text = match.group(0).decode('ascii').strip() + + if text.startswith('GCOV_FILE_START:'): + current_path = text[len('GCOV_FILE_START:'):] + current_data = bytearray() + elif text.startswith('GCOV_B64:'): + current_data += base64.b64decode(text[len('GCOV_B64:'):]) + elif text == 'GCOV_FILE_END': + if current_path is not None: + dest = base / current_path.lstrip('/') + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(bytes(current_data)) + current_path = None + current_data = bytearray() + elif text == 'GCOV_DUMP_DONE': + return diff --git a/codecov.yml b/codecov.yml index 77d9926..92b7234 100644 --- a/codecov.yml +++ b/codecov.yml @@ -17,3 +17,6 @@ comment: layout: "flags, diff" behavior: default require_changes: true + +fixes: + - "controller/::controller/src/controller/" diff --git a/controller/pyproject.toml b/controller/pyproject.toml index 20f4c2c..488c6d2 100644 --- a/controller/pyproject.toml +++ b/controller/pyproject.toml @@ -22,7 +22,7 @@ legacy_tox_ini = """ [testenv] deps = -r requirements/dev.txt - commands = pytest tests/ --cov=src --cov-report=xml:coverage.xml {posargs} + commands = pytest tests/ --cov=src --cov-report=xml:coverage.xml --junitxml=results.xml -o junit_family=legacy {posargs} """ [tool.mypy] diff --git a/web/vite.config.js b/web/vite.config.js index c33d353..b36747a 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -20,6 +20,8 @@ export default defineConfig({ }, test: { environment: "jsdom", + reporters: ['default', 'junit'], + outputFile: { junit: './test-report.junit.xml' }, include: ["tests/unit/**/*.test.js", "tests/integration/**/*.test.js"], coverage: { provider: "v8", From 4cf02be5025795f4b76668da18a68923d4b71ff0 Mon Sep 17 00:00:00 2001 From: Lukasz Towarek Date: Sat, 27 Jun 2026 17:17:51 +0000 Subject: [PATCH 7/8] Fix codecov --- .github/workflows/cpp-car.yml | 3 ++- .github/workflows/js-web.yml | 3 ++- .github/workflows/python-controller.yml | 3 ++- codecov.yml | 2 +- web/vite.config.js | 4 ++-- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cpp-car.yml b/.github/workflows/cpp-car.yml index 641a70f..b6c71a6 100644 --- a/.github/workflows/cpp-car.yml +++ b/.github/workflows/cpp-car.yml @@ -106,12 +106,13 @@ jobs: - name: Upload test results to Codecov if: ${{ !cancelled() && matrix.qemu }} - uses: codecov/test-results-action@v1 + 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 diff --git a/.github/workflows/js-web.yml b/.github/workflows/js-web.yml index e28d604..e3d2a9e 100644 --- a/.github/workflows/js-web.yml +++ b/.github/workflows/js-web.yml @@ -79,12 +79,13 @@ jobs: - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + 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: diff --git a/.github/workflows/python-controller.yml b/.github/workflows/python-controller.yml index b0bc0d9..03e6ce1 100644 --- a/.github/workflows/python-controller.yml +++ b/.github/workflows/python-controller.yml @@ -86,12 +86,13 @@ jobs: - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + 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: diff --git a/codecov.yml b/codecov.yml index 92b7234..9937331 100644 --- a/codecov.yml +++ b/codecov.yml @@ -19,4 +19,4 @@ comment: require_changes: true fixes: - - "controller/::controller/src/controller/" + - "/__w/dust-mite/dust-mite/::" diff --git a/web/vite.config.js b/web/vite.config.js index b36747a..53131f2 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -20,8 +20,8 @@ export default defineConfig({ }, test: { environment: "jsdom", - reporters: ['default', 'junit'], - outputFile: { junit: './test-report.junit.xml' }, + reporters: ["default", "junit"], + outputFile: { junit: "./test-report.junit.xml" }, include: ["tests/unit/**/*.test.js", "tests/integration/**/*.test.js"], coverage: { provider: "v8", From 46124b9419e1a3d378588dddf9895b607e4204e8 Mon Sep 17 00:00:00 2001 From: Lukasz Towarek Date: Sat, 27 Jun 2026 17:28:19 +0000 Subject: [PATCH 8/8] Fix tox --- controller/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/controller/pyproject.toml b/controller/pyproject.toml index 488c6d2..4d6684e 100644 --- a/controller/pyproject.toml +++ b/controller/pyproject.toml @@ -21,6 +21,7 @@ legacy_tox_ini = """ py312 [testenv] + package = editable deps = -r requirements/dev.txt commands = pytest tests/ --cov=src --cov-report=xml:coverage.xml --junitxml=results.xml -o junit_family=legacy {posargs} """