Merge branch 'feat/apply-common-scripts-6.1' into 'release/v6.1'

ci: apply common-scripts CI refactor (v6.1)

See merge request espressif/esp-idf!50988
This commit is contained in:
Fu Hanxi
2026-08-27 10:39:58 +02:00
75 changed files with 275 additions and 5091 deletions
+9 -7
View File
@@ -4,10 +4,13 @@ include:
- project: "ci/actions/common"
file:
- "templates/stable/default-workflow.yml"
- "templates/idf/deploy-github.yml"
- "templates/idf/v2/pre_check.yml"
- "templates/idf/pre-commit.yml"
- "templates/idf/build.yml"
- "templates/idf/v2/deploy-github.yml"
- "templates/idf/deploy-docs.yml"
- "templates/idf/integration-test.yml"
- "templates/idf/pre_check.yml"
- "templates/idf/sync-files.yml"
- project: "ci/actions/common"
file: "templates/idf/build-docs.yml"
inputs:
@@ -20,16 +23,15 @@ include:
- DOCLANG: "zh_CN"
DOCTGT: "esp32p4"
- ".gitlab/ci/danger.yml"
- ".gitlab/ci/common.yml"
- ".gitlab/ci/common.yml" # has to be placed after danger.yml, since danger.yml defined stages.
- ".gitlab/ci/rules.yml"
# by stages
- ".gitlab/ci/manual_gate.yml"
- ".gitlab/ci/upload_cache.yml"
- ".gitlab/ci/static-code-analysis.yml"
- ".gitlab/ci/pre_commit.yml"
- ".gitlab/ci/pre_check.yml"
- ".gitlab/ci/static-code-analysis.yml"
- ".gitlab/ci/build.yml"
- ".gitlab/ci/host-test.yml"
- ".gitlab/ci/pre_deploy.yml"
- ".gitlab/ci/test-win.yml"
- ".gitlab/ci/deploy.yml"
- ".gitlab/ci/post_deploy.yml"
- ".gitlab/ci/test-win.yml"
+7 -83
View File
@@ -9,7 +9,6 @@
IDF_CCACHE_ENABLE: "1"
dependencies: # set dependencies to null to avoid missing artifacts issue
########################################
# Clang Build Apps Without Tests Cases #
########################################
@@ -40,10 +39,8 @@
-p tools/test_apps/system/clang_build_test
components/esp_security/test_apps/fault_assert_opt_check
-t $IDF_TARGET
--parallel-count ${CI_NODE_TOTAL:-1}
--parallel-index ${CI_NODE_INDEX:-1}
--modified-components ${MR_MODIFIED_COMPONENTS}
--modified-files ${MR_MODIFIED_FILES}
--modified-components \"${MR_MODIFIED_COMPONENTS}\"
--modified-files \"${MR_MODIFIED_FILES}\"
$TEST_BUILD_OPTS_EXTRA
build_clang_test_apps_xtensa:
@@ -68,65 +65,9 @@ build_clang_test_apps_riscv:
####################
# Dynamic Pipeline #
####################
pre_check_complete:
stage: build
image: $ESP_ENV_IMAGE
tags: [fast_run, shiny]
variables:
GIT_STRATEGY: none
cache: []
before_script: []
dependencies: []
script:
- echo "pre_check stage completed"
generate_build_child_pipeline:
extends:
- .build_template
tags: [fast_run, shiny]
dependencies: # set dependencies to null to avoid missing artifacts issue
needs:
- pipeline_variables
- job: pre_check_complete
artifacts: false
- job: baseline_manifest_sha
optional: true
artifacts:
paths:
- build_child_pipeline.yml
- test_related_apps.txt
- non_test_related_apps.txt
expire_in: 1 week
when: always
script:
- run_cmd idf-ci --debug gitlab build-child-pipeline
-p components
-p examples
-p tools/test_apps
--modified-files $MR_MODIFIED_FILES
- shuf test_related_apps.txt -o test_related_apps.txt
- shuf non_test_related_apps.txt -o non_test_related_apps.txt
build_child_pipeline:
stage: build
needs:
- job: fast_build_check
optional: true
artifacts: false
- pipeline_variables
- generate_build_child_pipeline
variables:
MR_MODIFIED_COMPONENTS: $MR_MODIFIED_COMPONENTS
MR_MODIFIED_FILES: $MR_MODIFIED_FILES
PARENT_PIPELINE_ID: $CI_PIPELINE_ID
# https://gitlab.com/gitlab-org/gitlab/-/issues/214340
inherit:
variables: false
trigger:
include:
- artifact: build_child_pipeline.yml
job: generate_build_child_pipeline
strategy: depend
# !!!
# configurations moved to ci/actions/common/idf/
# !!!
# Generates a separate child-pipeline YAML for the cmakev2 build system.
# The build_dir stays identical to cmakev1 (build_<target>_<config>); v1/v2
@@ -161,11 +102,11 @@ generate_build_child_pipeline_buildv2:
expire_in: 1 week
when: always
script:
- idf-ci --debug gitlab build-child-pipeline
- run_cmd idf-ci --debug gitlab build-child-pipeline
-p components
-p examples
-p tools/test_apps
--modified-files "$MR_MODIFIED_FILES"
--modified-files \"${MR_MODIFIED_FILES}\"
buildv2_child_pipeline.yml > idfci_buildv2_gen.log 2>&1
- tail -20 idfci_buildv2_gen.log
- shuf test_related_apps.txt -o test_related_apps.txt
@@ -187,8 +128,6 @@ build_child_pipeline_buildv2:
- pipeline_variables
- generate_build_child_pipeline_buildv2
variables:
MR_MODIFIED_COMPONENTS: $MR_MODIFIED_COMPONENTS
MR_MODIFIED_FILES: $MR_MODIFIED_FILES
PARENT_PIPELINE_ID: $CI_PIPELINE_ID
inherit:
variables: false
@@ -198,18 +137,3 @@ build_child_pipeline_buildv2:
job: generate_build_child_pipeline_buildv2
strategy: depend
allow_failure: true
generate_prebuild_report:
extends:
- .build_template
tags: [fast_run, shiny]
needs:
- pipeline_variables
artifacts:
paths:
- prebuild_report.html
expire_in: 1 week
when: always
script:
- run_cmd idf-ci build collect --format html -o prebuild_report.html
- echo "Report generated at https://${CI_PAGES_HOSTNAME}:${CI_SERVER_PORT}/-/esp-idf/-/jobs/${CI_JOB_ID}/artifacts/prebuild_report.html"
+17 -235
View File
@@ -15,6 +15,14 @@ stages:
- deploy
- post_deploy
include:
- project: "ci/actions/common"
file: "templates/idf/common-scripts.yml"
inputs:
ci_python_constraint_file: "espidf.constraints.v6.1.txt"
macos_image: "registry.hub.docker.com/espressif/macos-idf:sequoia-v6.1"
windows_image: "windows_11_idf_v6_1"
variables:
# System environment
@@ -59,33 +67,30 @@ variables:
ESP_ENV_IMAGE: "${CI_REGISTRY}/ci/images/idf-v6.1:1"
ESP_IDF_DOC_ENV_IMAGE: "${CI_REGISTRY}/ci/images/idf-v6.1-docs:1"
TARGET_TEST_ENV_IMAGE: "${CI_REGISTRY}/ci/images/idf-v6.1-target-test:1"
SONARQUBE_SCANNER_IMAGE: "${CI_DOCKER_REGISTRY}/sonarqube-scanner:5"
# cache python dependencies
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
PRE_COMMIT_ENV_IMAGE: "${CI_REGISTRY}/ci/images/pre-commit-idf-v6.1:1"
# Set this variable to the branch of idf-constraints repo in order to test a custom Python constraint file. The
# branch name must be without the remote part ("origin/"). Keep the variable empty in order to use the constraint
# file from https://dl.espressif.com/dl/esp-idf.
CI_PYTHON_CONSTRAINT_BRANCH: ""
# CI_PYTHON_CONSTRAINT_BRANCH could be defined as component input `ci_python_constraint_branch` in common-scripts above
# Update the filename for a specific ESP-IDF release. It is used only with CI_PYTHON_CONSTRAINT_BRANCH.
CI_PYTHON_CONSTRAINT_FILE: "espidf.constraints.v6.1.txt"
# CI_PYTHON_CONSTRAINT_FILE is defined as component input `ci_python_constraint_file` in common-scripts above
# Set this variable to repository name of a Python tool you wish to install and test in the context of ESP-IDF CI.
# Keep the variable empty when not used.
CI_PYTHON_TOOL_REPO: ""
# CI_PYTHON_TOOL_REPO could be defined as component input `ci_python_tool_repo` in common-scripts above
# Set this variable to the branch of a Python tool repo specified in CI_PYTHON_TOOL_REPO. The
# branch name must be without the remote part ("origin/"). Keep the variable empty when not used.
# This is used only if CI_PYTHON_TOOL_REPO is not empty.
CI_PYTHON_TOOL_BRANCH: ""
# CI_PYTHON_TOOL_BRANCH could be defined as component input `ci_python_tool_branch` in common-scripts above
# Set this variable to Clang toolchain distro URL to be used.
# NOTE: We have separate toolchains for Xtensa and RISCV, therefore jobs for one arch will fail.
# This is OK as far as we use CI_CLANG_DISTRO_URL for pre-release tests purposes only.
# Keep the variable empty when not used.
CI_CLANG_DISTRO_URL: ""
# CI_CLANG_DISTRO_URL could be defined as component input `ci_clang_distro_url` in common-scripts above
# Set this variable to specify the file name for the known failure cases.
KNOWN_FAILURE_CASES_FILE_NAME: "6.1.txt"
@@ -107,235 +112,19 @@ variables:
################################################
# `before_script` and `after_script` Templates #
################################################
.common_before_scripts: &common-before_scripts |
source tools/ci/utils.sh
is_based_on_commits $REQUIRED_ANCESTOR_COMMITS
if [[ -n "$IDF_DONT_USE_MIRRORS" ]]; then
export IDF_MIRROR_PREFIX_MAP=
fi
if echo "$CI_MERGE_REQUEST_LABELS" | egrep "(^|,)include_nightly_run(,|$)"; then
export INCLUDE_NIGHTLY_RUN="1"
export NIGHTLY_RUN="1"
fi
# configure cmake related flags
source tools/ci/configure_ci_environment.sh
if [[ "$CI_CCACHE_STATS" == 1 ]] && command -v ccache >/dev/null 2>&1 && [[ -n "$CCACHE_STATSLOG" ]]; then
mkdir -p "$(dirname "$CCACHE_STATSLOG")"
rm -f "$CCACHE_STATSLOG"
fi
# add extra python packages
export PYTHONPATH="$IDF_PATH/tools:$IDF_PATH/tools/ci:$IDF_PATH/tools/esp_app_trace:$IDF_PATH/components/partition_table:$IDF_PATH/tools/ci/python_packages:$PYTHONPATH"
.setup_tools_and_idf_python_venv: &setup_tools_and_idf_python_venv |
# must use after setup_tools_except_target_test
# otherwise the export.sh won't work properly
# download constraint file for dev
if [[ -n "$CI_PYTHON_CONSTRAINT_BRANCH" ]]; then
wget -O /tmp/constraint.txt --header="Authorization:Bearer ${ESPCI_TOKEN}" "${GITLAB_HTTP_SERVER}/api/v4/projects/2581/repository/files/${CI_PYTHON_CONSTRAINT_FILE}/raw?ref=${CI_PYTHON_CONSTRAINT_BRANCH}"
mkdir -p ~/.espressif
mv /tmp/constraint.txt ~/.espressif/${CI_PYTHON_CONSTRAINT_FILE}
fi
# Mirror
if [[ -n "$IDF_DONT_USE_MIRRORS" ]]; then
export IDF_MIRROR_PREFIX_MAP=
fi
# Optimize pip install
if echo "${CI_RUNNER_TAGS}" | grep "shiny"; then
export PIP_INDEX_URL="${PIP_INDEX_URL_SHINY}"
fi
if [[ "$(uname -m)" == "x86_64" ]] || [[ "$(uname -m)" == "aarch64" ]]; then
export IDF_PIP_WHEELS_URL=""
fi
# install.sh
if [[ "${CI_JOB_STAGE}" != "target_test" ]]; then
section_start "running_install_sh" "Running install.sh"
if [[ "${CI_JOB_STAGE}" == "build_doc" || "${CI_JOB_GROUP_NAME}" == *docs* ]]; then
run_cmd bash install.sh --enable-ci --enable-docs
else
run_cmd bash install.sh --enable-ci
fi
section_end "running_install_sh"
else
section_start "install_python_env" "Install Python environment, skip required tools check"
run_cmd python tools/idf_tools.py install-python-env --features ci,test-specific
export IDF_SKIP_TOOLS_CHECK=1
section_end "install_python_env"
fi
section_start "source_export" "Source export.sh"
source ./export.sh
section_end "source_export"
# Eager upgrade of CI dependencies
# Done after sourcing export.sh so that we could easily invoke the right pip
section_start "upgrade_ci_dependencies" "Upgrading CI dependencies"
pip install --upgrade --upgrade-strategy=eager -r $IDF_PATH/tools/requirements/requirements.ci.txt -c ~/.espressif/${CI_PYTHON_CONSTRAINT_FILE}
# we need the latest DEV release for esptool to be installed in every job without bumping the minimal requirement in
# the constraint file. CI_ESPTOOL_EXTRA_INDEX_URL (set in GitLab project variables) optionally points to an internal
# package registry so that internal dev builds are preferred when the variable is set; it is a no-op when unset.
# Use an explicit if/else: the `${VAR:+--flag "$VAR"}` form collapses to a single token under zsh (macOS runners).
if [[ -n "$CI_ESPTOOL_EXTRA_INDEX_URL" ]]; then
pip install --upgrade --pre --extra-index-url "$CI_ESPTOOL_EXTRA_INDEX_URL" esptool -c ~/.espressif/${CI_PYTHON_CONSTRAINT_FILE}
else
pip install --upgrade --pre esptool -c ~/.espressif/${CI_PYTHON_CONSTRAINT_FILE}
fi
section_end "upgrade_ci_dependencies"
REEXPORT_NEEDED=0
if [[ ! -z "$INSTALL_EXTRA_TOOLS" ]]; then
section_start "installing_optional_tools" "Install optional tools ${INSTALL_EXTRA_TOOLS}"
run_cmd $IDF_PATH/tools/idf_tools.py --non-interactive install $INSTALL_EXTRA_TOOLS
section_end "installing_optional_tools"
REEXPORT_NEEDED=1
fi
# Install esp-clang if necessary (esp-clang is separately installed)
if [[ "$IDF_TOOLCHAIN" == "clang" && -z "$CI_CLANG_DISTRO_URL" ]]; then
$IDF_PATH/tools/idf_tools.py --non-interactive install esp-clang
REEXPORT_NEEDED=1
fi
if [[ $REEXPORT_NEEDED -eq 1 ]]; then
section_start "re_source_export" "Re-source export.sh"
source ./export.sh
section_end "re_source_export"
fi
# Custom clang toolchain
if [[ "$IDF_TOOLCHAIN" == "clang" && ! -z "$CI_CLANG_DISTRO_URL" ]]; then
echo "Using custom clang from ${CI_CLANG_DISTRO_URL}"
wget $CI_CLANG_DISTRO_URL
ARCH_NAME=$(basename $CI_CLANG_DISTRO_URL)
tar -x -f $ARCH_NAME
export PATH=$PWD/esp-clang/bin:$PATH
fi
# Custom OpenOCD
if [[ "$CI_JOB_STAGE" == "target_test" ]]; then
machine="$(uname -m)"
if [[ "$machine" == "armv7l" ]] ; then
OOCD_DISTRO_URL="$OOCD_DISTRO_URL_ARMHF"
elif [[ "$machine" == "aarch64" ]] ; then
OOCD_DISTRO_URL="$OOCD_DISTRO_URL_ARM64"
fi
if [[ ! -z "$OOCD_DISTRO_URL" ]]; then
echo "Using custom OpenOCD from ${OOCD_DISTRO_URL}"
wget $OOCD_DISTRO_URL
ARCH_NAME=$(basename $OOCD_DISTRO_URL)
tar -x -f $ARCH_NAME
export OPENOCD_SCRIPTS=$PWD/openocd-esp32/share/openocd/scripts
export PATH=$PWD/openocd-esp32/bin:$PATH
fi
fi
if [[ -n "$CI_PYTHON_TOOL_REPO" ]]; then
git clone --quiet --depth=1 -b ${CI_PYTHON_TOOL_BRANCH} https://gitlab-ci-token:${ESPCI_TOKEN}@${GITLAB_HTTPS_HOST}/espressif/${CI_PYTHON_TOOL_REPO}.git
pip install ./${CI_PYTHON_TOOL_REPO}
rm -rf ${CI_PYTHON_TOOL_REPO}
fi
info "setup tools and python venv done"
.show_ccache_statistics: &show_ccache_statistics |
# Show ccache statistics if enabled globally
section_start "ccache_show_stats" "Show ccache statistics"
if [[ "$CI_CCACHE_STATS" == 1 ]] && command -v ccache >/dev/null 2>&1; then
if ccache --help 2>/dev/null | grep -q -- '--show-log-stats'; then
if [[ -n "$CCACHE_STATSLOG" && -f "$CCACHE_STATSLOG" ]]; then
ccache --show-log-stats -vv
else
echo "INFO: No per-job ccache statistics were recorded"
fi
else
ccache --show-stats -vv
fi
fi || true
section_end "ccache_show_stats"
.upload_failed_job_log_artifacts: &upload_failed_job_log_artifacts |
if [ $CI_JOB_STATUS = "failed" ]; then
run_cmd idf-ci gitlab upload-artifacts --type log
fi
.before_script:minimal:
before_script:
- *common-before_scripts
- !reference [.common_before_scripts, minimal]
.after_script:build:
after_script:
- source tools/ci/utils.sh
- source tools/ci/configure_ci_environment.sh
- *show_ccache_statistics
- !reference [.common_after_scripts, ccache]
- run_cmd idf-ci gitlab upload-artifacts --type junit
- |
if [ $CI_JOB_STATUS = "failed" ]; then
run_cmd idf-ci gitlab upload-artifacts --type log
fi
.brew-macos-settings:
variables:
GIT_STRATEGY: fetch
IDF_CCACHE_ENABLE: "0"
CCACHE_DIR: "/var/tmp/cache/idf_ccache"
tags:
- macos-tart
image: macos-sequoia-idf-v6.1
after_script: [] # ccache now is disabled for macos brew runners
timeout: 30m
.brew-windows-settings:
image: windows_11_idf_v6_1
tags: [windows-vm, brew]
variables:
GIT_STRATEGY: fetch
LC_ALL: C.UTF-8
# git clean at the final cleanup stage stuck on Windows VM
# a new VM for each job anyway, so we can skip cleaning the workspace
GIT_CLEAN_FLAGS: none
# IDF_CCACHE_ENABLE: "1"
# CCACHE_DIR: "C:/cache/idf_ccache"
before_script:
- if ($env:IDF_DONT_USE_MIRRORS) { $env:IDF_MIRROR_PREFIX_MAP = '' }
- ./install.ps1 --enable-ci
- ./export.ps1
# Keep the esptool upgrade in line with how it is done in setup_tools_and_idf_python_venv
- $extraIndex = if ($env:CI_ESPTOOL_EXTRA_INDEX_URL) { @('--extra-index-url', $env:CI_ESPTOOL_EXTRA_INDEX_URL) } else { @() }
- pip install --upgrade --pre @extraIndex esptool -c "$env:USERPROFILE\.espressif\$env:CI_PYTHON_CONSTRAINT_FILE"
- $env:PYTHONPATH = "$env:PYTHONPATH;$env:IDF_PATH\tools;$env:IDF_PATH\tools\esp_app_trace;$env:IDF_PATH\components\partition_table;$env:IDF_PATH\tools\ci\python_packages"
- python "${SUBMODULE_FETCH_TOOL}" -s "${SUBMODULES_TO_FETCH}"
# Sync VM clock. net start/stop exit 2 when the service is already in that state; do not fail CI.
- |
function Invoke-NetService($Action, $Name) {
$output = net $Action $Name 2>&1
if ($LASTEXITCODE -eq 2) {
Write-Host "net $Action $Name already in requested state: $output"
$global:LASTEXITCODE = 0
return
}
if ($LASTEXITCODE -ne 0) {
Write-Host $output
exit $LASTEXITCODE
}
}
Invoke-NetService start w32time
w32tm /config /manualpeerlist:"time.google.com,0x9 pool.ntp.org,0x9" /syncfromflags:manual /reliable:yes /update
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Invoke-NetService stop w32time
Invoke-NetService start w32time
w32tm /resync
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
after_script: []
#############
# `default` #
@@ -344,12 +133,6 @@ default:
cache:
# pull only for most of the use cases since it's cache dir.
# Only set "push" policy for "upload_cache" stage jobs
- key: pip-cache-${LATEST_GIT_TAG}
fallback_keys:
- pip-cache
paths:
- .cache/pip
policy: pull
- key: submodule-cache-${LATEST_GIT_TAG}
fallback_keys:
- submodule-cache
@@ -357,8 +140,7 @@ default:
- .cache/submodule_archives
policy: pull
before_script:
- *common-before_scripts
- *setup_tools_and_idf_python_venv
- !reference [.common_before_scripts, with_tools]
- add_gitlab_ssh_keys
- fetch_submodules
- export EXTRA_CFLAGS=${PEDANTIC_CFLAGS}
-19
View File
@@ -1,19 +0,0 @@
# this file support two keywords:
# - extra_default_build_targets:
# besides of the SUPPORTED_TARGETS in IDF,
# enable build for the specified targets by default as well.
# !!! DEPRECATED: use `additional_build_targets` in .idf_build_apps.toml instead
#
# - bypass_check_test_targets:
# suppress the check_build_test_rules check-test-script warnings for the specified targets
#
# This file should ONLY be used during bringup. Should be reset to empty after the bringup process
bypass_check_test_targets:
- esp32h21
#
# These lines would
# - enable the README.md check for esp32c6. Don't forget to add the build jobs in .gitlab/ci/build.yml
# - disable the test script check with the manifest file.
#
+6 -9
View File
@@ -7,9 +7,6 @@
# run host_test jobs immediately, only after upload cache
needs:
- pipeline_variables
- job: upload-pip-cache
optional: true
artifacts: false
- job: upload-submodules-cache
optional: true
artifacts: false
@@ -374,7 +371,7 @@ build_docker:
--build-system cmake
--target $IDF_TARGET
-m qemu
--modified-files ${MR_MODIFIED_FILES}
--modified-files \"${MR_MODIFIED_FILES}\"
- run_cmd idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME}
- run_cmd pytest
--target $IDF_TARGET
@@ -412,7 +409,7 @@ test_pytest_qemu:
-p components -p examples -p tools/test_apps
--target linux
--only-test-related
--modified-files ${MR_MODIFIED_FILES}
--modified-files \"${MR_MODIFIED_FILES}\"
- run_cmd idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME}
- run_cmd pytest
--target linux
@@ -427,7 +424,7 @@ test_pytest_linux:
test_pytest_macos:
extends:
- .host_test_template
- .brew-macos-settings
- .macos-settings
artifacts:
paths:
- XUNIT_RESULT.xml
@@ -442,7 +439,7 @@ test_pytest_macos:
--target linux
--only-test-related
-m macos
--modified-files ${MR_MODIFIED_FILES}
--modified-files \"${MR_MODIFIED_FILES}\"
- run_cmd idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME}
- run_cmd pytest
--target linux
@@ -561,7 +558,7 @@ test_pytest_linux_buildv2:
pytest_build_system_macos:
extends:
- .test_build_system_template
- .brew-macos-settings
- .macos-settings
- .rules:build:macos
parallel: 3
@@ -571,7 +568,7 @@ pytest_build_system_minimal_cmake:
pytest_build_system_macos_minimal_cmake:
extends:
- .test_build_system_minimal_cmake_template
- .brew-macos-settings
- .macos-settings
- .rules:build:macos
variables:
INSTALL_EXTRA_TOOLS: ninja cmake@3.22.1
-16
View File
@@ -2,22 +2,6 @@
stage: post_deploy
image: $ESP_ENV_IMAGE
generate_failed_jobs_report:
extends:
- .post_deploy_template
tags: [build, shiny]
when: always
dependencies: # Do not download artifacts from the previous stages
needs:
- pipeline_variables
artifacts:
expire_in: 2 week
when: always
paths:
- job_report.html
script:
- python tools/ci/dynamic_pipelines/scripts/generate_report.py --report-type job
sync_support_status:
extends:
- .post_deploy_template
+2 -48
View File
@@ -1,9 +1,3 @@
.pre_check_template:
stage: pre_check
image: $ESP_ENV_IMAGE
tags: [fast_run, shiny]
dependencies: # set dependencies to null to avoid missing artifacts issue
####################
# fast_build_check #
####################
@@ -157,51 +151,11 @@ check_test_scripts_build_test_rules:
# requires basic pytest dependencies
- python tools/ci/check_build_test_rules.py check-test-scripts examples/ tools/test_apps components
snapshot_known_failure_cases:
check_gitlab_yaml:
extends:
- .pre_check_template
script:
- run_cmd idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME}
- mkdir -p artifacts
- mv ${KNOWN_FAILURE_CASES_FILE_NAME} artifacts/${KNOWN_FAILURE_CASES_FILE_NAME}.freeze
artifacts:
paths:
- artifacts/${KNOWN_FAILURE_CASES_FILE_NAME}.freeze
expire_in: 1 week
when: always
baseline_manifest_sha:
extends:
- .pre_check_template
- .rules:dev-push
script:
- |
# merged results pipelines, by default
# diff between target-branch-head and merged-result-head
if [ -n "$CI_MERGE_REQUEST_TARGET_BRANCH_SHA" ]; then
git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_SHA --depth=1
git checkout FETCH_HEAD
idf-build-apps dump-manifest-sha \
--manifest-files $(find . -name ".build-test-rules.yml" | xargs) \
--output .manifest_sha
# merge request pipelines, when the mr got conflicts
# diff between diff-base-sha and merge-request-head
elif [ -n "$CI_MERGE_REQUEST_DIFF_BASE_SHA" ]; then
git fetch origin $CI_MERGE_REQUEST_DIFF_BASE_SHA --depth=1
git checkout FETCH_HEAD
idf-build-apps dump-manifest-sha \
--manifest-files $(find . -name ".build-test-rules.yml" | xargs) \
--output .manifest_sha
# other pipelines, like the protected branches pipelines
# not triggered in this job
fi
artifacts:
paths:
- .manifest_sha
expire_in: 1 week
when: always
- python tools/ci/gitlab_yaml_linter.py
retry_failed_jobs:
extends:
-31
View File
@@ -1,34 +1,3 @@
.check_pre_commit_template:
extends:
- .before_script:minimal
stage: pre_check
image: "${CI_DOCKER_REGISTRY}/esp-idf-pre-commit:2"
tags: [pre-commit]
variables:
# Both shiny and brew runners can pick this job
GIT_STRATEGY: fetch
GIT_DEPTH: 1
SUBMODULES_TO_FETCH: "all"
needs:
- pipeline_variables
script:
- fetch_submodules
- pre-commit run --files $MODIFIED_FILES
- pre-commit run --hook-stage post-commit validate-sbom-manifest
check_pre_commit:
extends:
- .check_pre_commit_template
rules:
- if: '($CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_BRANCH =~ /^release\/v/) && $CI_PIPELINE_SOURCE == "push"'
when: never
- when: on_success
cache:
- key: submodule-cache-${LATEST_GIT_TAG}
paths:
- .cache/submodule_archives
policy: pull
check_powershell:
extends:
- .before_script:minimal
-25
View File
@@ -1,25 +0,0 @@
.pre_deploy_template:
stage: pre_deploy
image: $ESP_ENV_IMAGE
check_submodule_sync:
extends:
- .pre_deploy_template
- .rules:test:submodule
tags: [ brew, github_sync ]
retry: 2
variables:
GIT_STRATEGY: fetch # use brew local mirror first
SUBMODULES_TO_FETCH: "none"
PUBLIC_IDF_URL: "https://github.com/espressif/esp-idf.git"
script:
- git submodule deinit --force .
- rm -rf .git/modules # remove all the cached metadata
# setting the default remote URL to the public one, to resolve relative location URLs
- git config remote.origin.url ${PUBLIC_IDF_URL}
# check if all submodules are correctly synced to public repository
- git submodule init
- git config --get-regexp '^submodule\..*\.url$' || true
- git submodule update --recursive
- echo "IDF was cloned from ${PUBLIC_IDF_URL} completely"
-25
View File
@@ -13,16 +13,6 @@
- "components/**/Kconfig"
- "components/**/CMakeLists.txt"
.patterns-python-cache: &patterns-python-cache
- "tools/requirements.json"
- "tools/requirements/requirements.*.txt"
.patterns-python-files: &patterns-python-files
- ".gitlab/ci/static-code-analysis.yml"
- "**/*.py"
.patterns-sonarqube-files: &patterns-sonarqube-files
- "tools/ci/sonar_exclude_list.txt"
.patterns-build_components: &patterns-build_components
# components files except "test*/" "host*/" folders
@@ -187,16 +177,10 @@
.if-protected-check: &if-protected-check
if: '($CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_BRANCH =~ /^release\/v/ || $CI_COMMIT_TAG =~ /^v\d+\.\d+(\.\d+)?($|-)/) || $CI_COMMIT_TAG =~ /^qa-test/'
.if-protected-deploy: &if-protected-deploy
if: '($CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_BRANCH =~ /^release\/v/ || $CI_COMMIT_TAG =~ /^v\d+\.\d+(\.\d+)?($|-)/) && ($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "api")'
.if-protected-branch-push: &if-protected-branch-push
# rules:changes always evaluates to true for new branch pipelines or when there is no Git push event
if: '($CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_BRANCH =~ /^release\/v/) && $CI_PIPELINE_SOURCE == "push"'
.if-qa-test-tag: &if-qa-test-tag
if: '$CI_COMMIT_TAG =~ /^qa-test/'
.if-dev-push: &if-dev-push
if: '$CI_COMMIT_REF_NAME != "master" && $CI_COMMIT_BRANCH !~ /^release\/v/ && $CI_COMMIT_TAG !~ /^v\d+\.\d+(\.\d+)?($|-)/ && $CI_COMMIT_TAG !~ /^qa-test/ && ($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event")'
@@ -235,15 +219,6 @@
rules:
- <<: *if-dev-push
.rules:upload-python-cache:
rules:
- <<: *if-release-tag
- <<: *if-schedule-nightly
- <<: *if-protected-branch-push
changes: *patterns-python-cache
- <<: *if-label-upload_cache
when: manual
.rules:upload-submodule-cache:
rules:
- <<: *if-release-tag
-81
View File
@@ -27,84 +27,3 @@ gcc_static_analyzer:
- echo "CONFIG_COMPILER_STATIC_ANALYZER=y" >> ${ANALYZING_APP}/sdkconfig.defaults
- sed -i 's/.*MINIMAL_BUILD.*//g' ${ANALYZING_APP}/CMakeLists.txt
- idf-build-apps build -p ${ANALYZING_APP}
#
## build stage
## Sonarqube related jobs put here for this reason:
## Here we have two jobs. code_quality_check and code_quality_report.
##
## code_quality_check will analyze the code changes between your MR and
## code repo stored in sonarqube server. The analysis result is only shown in
## the comments under this MR and won't be transferred to the server.
##
## code_quality_report will analyze and transfer both of the newly added code
## and the analysis result to the server.
##
## Put in the front to ensure that the newly merged code can be stored in
## sonarqube server ASAP, in order to avoid reporting unrelated code issues
#.sonar_scan_template:
# stage: build
# extends: .pre_check_template
# # full clone since this image does not support fetch --shallow-since-cutoff
# # shiny runners are used for full clone
# tags: [build, shiny]
# image: $SONARQUBE_SCANNER_IMAGE
# before_script:
# - source tools/ci/utils.sh
# - export PYTHONPATH="$CI_PROJECT_DIR/tools:$CI_PROJECT_DIR/tools/ci/python_packages:$PYTHONPATH"
# - fetch_submodules
# # Exclude the submodules, all paths ends with /**
# - submodules=$(get_all_submodules)
# # get all exclude paths specified in tools/ci/sonar_exclude_list.txt | ignore lines start with # | xargs | replace all <space> to <comma>
# - custom_excludes=$(cat $CI_PROJECT_DIR/tools/ci/sonar_exclude_list.txt | grep -v '^#' | xargs | sed -e 's/ /,/g')
# # Exclude the report dir as well
# - export EXCLUSIONS="$custom_excludes,$submodules"
# - export SONAR_SCANNER_OPTS="-Xmx2048m"
# variables:
# GIT_DEPTH: 0
# REPORT_PATTERN: clang_tidy_reports/**/*.txt
# artifacts:
# paths:
# - $REPORT_PATTERN
# expire_in: 1 week
# when: always
# dependencies: # Here is not a hard dependency relationship, could be skipped when only python files changed. so we do not use "needs" here.
# - clang_tidy_check
#
#code_quality_check:
# extends:
# - .sonar_scan_template
# - .rules:patterns:static-code-analysis-preview
# allow_failure: true # it's using exit code to indicate the code analysis result,
# # we don't want to block ci when critical issues founded
# script:
# - export CI_MERGE_REQUEST_COMMITS=$(python ${CI_PROJECT_DIR}/tools/ci/ci_get_mr_info.py commits --src-branch ${CI_COMMIT_REF_NAME} | tr '\n' ',')
# # test if this branch have merge request, if not, exit 0
# - test -n "$CI_MERGE_REQUEST_IID" || exit 0
# - test -n "$CI_MERGE_REQUEST_COMMITS" || exit 0
# - sonar-scanner
# -Dsonar.analysis.mode=preview
# -Dsonar.branch.name=$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
# -Dsonar.cxx.clangtidy.reportPath=$REPORT_PATTERN
# -Dsonar.exclusions=$EXCLUSIONS
# -Dsonar.gitlab.ci_merge_request_iid=$CI_MERGE_REQUEST_IID
# -Dsonar.gitlab.commit_sha=$CI_MERGE_REQUEST_COMMITS
# -Dsonar.gitlab.merge_request_discussion=true
# -Dsonar.gitlab.ref_name=$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
# -Dsonar.host.url=$SONAR_HOST_URL
# -Dsonar.login=$SONAR_LOGIN
#
#code_quality_report:
# extends:
# - .sonar_scan_template
# - .rules:protected:check
# allow_failure: true # it's using exit code to indicate the code analysis result,
# # we don't want to block ci when critical issues founded
# script:
# - sonar-scanner
# -Dsonar.branch.name=$CI_COMMIT_REF_NAME
# -Dsonar.cxx.clangtidy.reportPath=$REPORT_PATTERN
# -Dsonar.exclusions=$EXCLUSIONS
# -Dsonar.gitlab.commit_sha=$PIPELINE_COMMIT_SHA
# -Dsonar.gitlab.ref_name=$CI_COMMIT_REF_NAME
# -Dsonar.host.url=$SONAR_HOST_URL
# -Dsonar.login=$SONAR_LOGIN
+1 -4
View File
@@ -1,14 +1,11 @@
.host_test_win_template:
extends:
- .brew-windows-settings
- .windows-settings
- .rules:test:host_test
stage: host_test
dependencies: # set dependencies to null to avoid missing artifacts issue
# run host_test jobs immediately, only after upload cache
needs:
- job: upload-pip-cache
optional: true
artifacts: false
- job: upload-submodules-cache
optional: true
artifacts: false
-20
View File
@@ -6,26 +6,6 @@
stage: upload_cache
image: $ESP_ENV_IMAGE
upload-pip-cache:
extends:
- .upload_cache_template
- .before_script:minimal
- .rules:upload-python-cache
tags:
- $GEO
- cache
cache:
key: pip-cache-${LATEST_GIT_TAG}
paths:
- .cache/pip
policy: push
script:
- rm -rf .cache/pip # clear old packages
- bash install.sh --enable-ci --enable-test-specific
parallel:
matrix:
- GEO: [ 'shiny', 'brew' ]
upload-submodules-cache:
extends:
- .upload_cache_template
+1 -2
View File
@@ -22,8 +22,7 @@ IDF_CI_BUILD = "1"
[gitlab.build_pipeline]
workflow_name = "build_child_pipeline"
presigned_json_job_name = 'generate_pytest_build_report'
runs_per_job=30
runs_per_job = 30
job_tags = ['build', 'shiny']
job_template_name = '.dynamic_build_template'
+1 -9
View File
@@ -145,7 +145,7 @@ repos:
require_serial: true
additional_dependencies:
- PyYAML == 5.3.1
- idf-build-apps~=2.13
- idf-build-apps~=3.0
- id: sort-yaml-files
name: sort yaml files
entry: tools/ci/sort_yaml.py
@@ -166,14 +166,6 @@ repos:
language: python
always_run: true
require_serial: true
- id: gitlab-yaml-linter
name: Check gitlab yaml files
entry: tools/ci/gitlab_yaml_linter.py
language: python
files: '\.gitlab-ci\.yml|\.gitlab/ci/.+\.yml|\.gitmodules'
pass_filenames: false
additional_dependencies:
- PyYAML == 5.3.1
- id: check-wifi-remote-api
name: Check wifi-remote API generation
description: Runs generate_and_check.py and fails if any generated files differ from the index.
@@ -21,7 +21,6 @@ def test_efuse_qemu(dut: Dut) -> None:
dut.run_all_single_board_cases()
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_efuse_linux(dut: Dut) -> None:
dut.run_all_single_board_cases(timeout=60)
@@ -11,7 +11,6 @@ def test_esp_app_format(dut: Dut) -> None:
dut.expect_unity_test_output()
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_app_format_linux(dut: Dut) -> None:
dut.expect_unity_test_output()
@@ -6,14 +6,12 @@ from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_section_macros(dut: Dut) -> None:
dut.expect('SUCCESS: All 5 section entries verified.')
dut.expect('SUCCESS: All 3 pointer section entries verified.')
@pytest.mark.host_test
@pytest.mark.macos
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_section_macros_macos(dut: Dut) -> None:
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_event_linux(dut: Dut) -> None:
dut.expect_exact('All tests passed', timeout=5)
@@ -27,22 +27,20 @@ def test_esp_event_ext_ram(dut: Dut) -> None:
@pytest.mark.qemu
@pytest.mark.xfail('config.getvalue("target") == "esp32c3"', reason='Unstable on QEMU, needs investigation')
@pytest.mark.parametrize(
'config',
@idf_parametrize(
'config,target,markers',
[
'defaults',
('defaults', 'esp32'),
('defaults', 'esp32c3', (pytest.mark.xfail(reason='Unstable on QEMU, needs investigation'),)),
],
indirect=True,
indirect=['config', 'target'],
)
@idf_parametrize('target', ['esp32', 'esp32c3'], indirect=['target'])
def test_esp_event_qemu(dut: Dut) -> None:
for case in dut.test_menu:
if 'qemu-ignore' not in case.groups and not case.is_ignored and case.type == 'normal':
dut._run_normal_case(case)
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
@pytest.mark.parametrize(
'config',
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_rv_decode(dut: Dut) -> None:
dut.expect_exact('All tests passed', timeout=180)
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_hw_support_linux(dut: Dut) -> None:
dut.run_all_single_board_cases(group='!ignore', timeout=120)
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_partition_linux(dut: Dut) -> None:
dut.expect_unity_test_output(timeout=5)
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_partition_bdl(dut: Dut) -> None:
dut.expect_unity_test_output(timeout=5)
@@ -45,7 +45,6 @@ def test_esp_ringbuf_qemu(dut: Dut) -> None:
dut._run_normal_case(case)
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_ringbuf_posix_simulator(dut: Dut) -> None:
dut.run_all_single_board_cases(group='linux')
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_rom_linux(dut: Dut) -> None:
dut.expect_exact('All tests passed', timeout=5)
@@ -104,7 +104,6 @@ def test_esp_system_stdio_correct_open_and_close(dut: Dut) -> None:
dut.expect('STDIO_TEST:CLOSE_OK')
@pytest.mark.host_test
@pytest.mark.qemu
@pytest.mark.parametrize(
'config, embedded_services',
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_system_linux(dut: Dut) -> None:
dut.run_all_single_board_cases(timeout=60)
@@ -5,13 +5,11 @@ from pytest_embedded_idf.dut import IdfDut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_sys_init_fn_linux(dut: IdfDut) -> None:
dut.run_all_single_board_cases(timeout=60)
@pytest.mark.host_test
@pytest.mark.macos
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_sys_init_fn_macos(dut: IdfDut) -> None:
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_fatfs_bdl_linux(dut: Dut) -> None:
dut.expect_exact('All tests passed', timeout=120)
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_fatfs_linux(dut: Dut) -> None:
dut.expect_exact('All tests passed', timeout=120)
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_hal_utils(dut: Dut) -> None:
dut.run_all_single_board_cases()
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_heap_linux(dut: Dut) -> None:
dut.run_all_single_board_cases(timeout=60)
@@ -5,7 +5,6 @@ from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@pytest.mark.parametrize(
'config',
[
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: CC0-1.0
import pytest
from pytest_embedded_idf.dut import IdfDut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_linux_component(dut: IdfDut) -> None:
dut.expect_exact('Press ENTER to see the list of tests.')
@@ -5,7 +5,6 @@ from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@pytest.mark.parametrize(
'config',
[
@@ -5,7 +5,6 @@ from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@pytest.mark.parametrize(
'config',
[
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_nvs_page_linux(dut: Dut) -> None:
dut.expect_unity_test_output(timeout=120)
@@ -54,7 +54,6 @@ def test_pthread_qemu(dut: Dut) -> None:
dut._run_normal_case(case, timeout=75)
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_pthread_linux(dut: Dut) -> None:
dut.run_all_single_board_cases(timeout=120)
@@ -5,7 +5,6 @@ from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@pytest.mark.parametrize('config', ['erase_check', 'no_erase_check'])
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_spiffs_linux(dut: Dut) -> None:
@@ -20,7 +20,6 @@ def test_unit_test_order(dut: Dut) -> None:
verify_test_order(dut)
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_unit_test_order_linux(dut: Dut) -> None:
verify_test_order(dut)
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_wear_levelling_linux(dut: Dut) -> None:
dut.expect_exact('All tests passed', timeout=180)
@@ -735,15 +735,35 @@ Sometimes, a test can consistently fail for the following reasons:
Now you may mark this test case with marker `xfail <https://docs.pytest.org/en/latest/how-to/skipping.html#xfail-mark-test-functions-as-expected-to-fail>`__ with a user-friendly readable reason.
.. attention::
Avoid using a string condition, e.g., ``@pytest.mark.xfail('config.getvalue("target") == "esp32s2"', ...)``. This condition is evaluated against the session-level CLI ``--target`` option, not against the actual target resolved for a given parametrized test instance, so it can silently mismatch when the same test is parametrized over multiple targets and produce unreliable results.
Instead, attach the ``xfail`` marker directly to the specific parametrized value passed to ``idf_parametrize`` (see :ref:`Same App With Different Running Environments <pytest-same-app-different-running-environments>`), so the marker is tied to that value at collection time.
This code example is taken from :idf_file:`pytest_panic.py <tools/test_apps/system/panic/panic_base/pytest_panic.py>`
.. code-block:: python
@pytest.mark.xfail('config.getvalue("target") == "esp32s2"', reason='raised IllegalInstruction instead')
def test_cache_error(dut: PanicTestDut, config: str, test_func_name: str) -> None:
@pytest.mark.generic
@idf_parametrize(
'config,target,markers',
[
(
'memprot_esp32s2',
'esp32s2',
pytest.mark.xfail(reason='Incorrect panic reason may be observed', run=False),
),
('memprot_esp32c3', 'esp32c3'),
],
indirect=['config', 'target'],
)
def test_cache_error(dut: PanicTestDut, test_func_name: str) -> None:
This marker means that test is a known failure on the ESP32-S2.
If a test is only ever parametrized with a single target, there is no ambiguity, and you can use an unconditional ``@pytest.mark.xfail(reason=..., run=False)`` instead.
Mark Nightly Run Test Cases
---------------------------
@@ -735,15 +735,35 @@ Pytest 使用技巧
可使用 `xfail <https://docs.pytest.org/en/latest/how-to/skipping.html#xfail-mark-test-functions-as-expected-to-fail>`__ marker 来标记此测试用例,并写出原因。
.. attention::
请避免使用字符串条件,例如 ``@pytest.mark.xfail('config.getvalue("target") == "esp32s2"', ...)``。该条件是在运行时根据 session 级别的 CLI ``--target`` 参数求值的,而不是针对某个具体参数化测试实例实际解析出的 target 值,因此当同一测试针对多个 target 参数化时,该条件可能被错误匹配,导致结果不可靠。
推荐做法是通过 ``idf_parametrize`` 传入的具体参数化取值直接附加 ``xfail`` marker,更复杂的示例请参阅 :ref:`在不同运行环境中运行相同的应用程序 <pytest-same-app-different-running-environments>` 小节,这样该 marker 在 collection 阶段就与该取值绑定。
以下代码来自 :idf_file:`pytest_panic.py <tools/test_apps/system/panic/panic_base/pytest_panic.py>`。
.. code-block:: python
@pytest.mark.xfail('config.getvalue("target") == "esp32s2"', reason='raised IllegalInstruction instead')
def test_cache_error(dut: PanicTestDut, config: str, test_func_name: str) -> None:
@pytest.mark.generic
@idf_parametrize(
'config,target,markers',
[
(
'memprot_esp32s2',
'esp32s2',
pytest.mark.xfail(reason='Incorrect panic reason may be observed', run=False),
),
('memprot_esp32c3', 'esp32c3'),
],
indirect=['config', 'target'],
)
def test_cache_error(dut: PanicTestDut, test_func_name: str) -> None:
这一 marker 表示该测试在 ESP32-S2 上是一个已知失败。
如果一个测试始终只针对单个 target 参数化,则不存在歧义,此时可直接使用无条件的 ``@pytest.mark.xfail(reason=..., run=False)``。
标记夜间运行的测试用例
---------------------------
@@ -18,13 +18,11 @@ def test_hello_world(dut: IdfDut, log_minimum_free_heap_size: Callable[..., None
log_minimum_free_heap_size()
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_hello_world_linux(dut: IdfDut) -> None:
dut.expect('Hello world!')
@pytest.mark.host_test
@pytest.mark.macos
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_hello_world_macos(dut: IdfDut) -> None:
@@ -121,7 +121,6 @@ def test_examples_protocol_esp_http_client_dynamic_buffer(dut: Dut) -> None:
dut.expect('Finish http example')
@pytest.mark.host_test
@pytest.mark.parametrize(
'config',
[
@@ -186,7 +186,6 @@ def _run_mtls_test(
# ---------------------------------------------------------------------------
@pytest.mark.host_test
@pytest.mark.qemu
@pytest.mark.parametrize('config', ['default'], indirect=True)
@idf_parametrize('target', ['esp32c3'], indirect=['target'])
@@ -203,7 +202,6 @@ def test_mutual_auth_software_keys(app: QemuApp, dut: QemuDut) -> None:
# ---------------------------------------------------------------------------
@pytest.mark.host_test
@pytest.mark.qemu
@pytest.mark.parametrize('config', ['qemu_ds'], indirect=True)
@idf_parametrize('target', ['esp32c3'], indirect=['target'])
@@ -216,7 +214,6 @@ def test_mutual_auth_ds(app: QemuApp, dut: QemuDut) -> None:
_run_mtls_test(dut, server_port=8070, client_ca=client_ca, expect_success=True)
@pytest.mark.host_test
@pytest.mark.qemu
@pytest.mark.parametrize('config', ['qemu_ds'], indirect=True)
@idf_parametrize('target', ['esp32c3'], indirect=['target'])
@@ -43,7 +43,6 @@ def test_examples_efuse(dut: Dut) -> None:
basic_efuse_example(dut)
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_examples_efuse_linux(dut: Dut) -> None:
basic_efuse_example(dut)
-24
View File
@@ -1,24 +0,0 @@
sonar.projectKey=esp-idf
sonar.projectName=Espressif IoT Development Framework
sonar.projectVersion=4.4
sonar.sources=.
sonar.sourceEncoding=UTF-8
sonar.inclusions=\
**/*.c,**/*.cpp,**/*.h,**/*.hpp,\
**/*.py
####################
# sonar-cxx-plugin #
####################
sonar.cxx.includeDirectories=/usr/include
sonar.cxx.file.suffixes=.cxx,.cpp,.cc,.c,.hxx,.hpp,.hh,.h
#######################
# sonar-gitlab-plugin #
#######################
sonar.gitlab.failure_notification_mode=exit-code
sonar.gitlab.project_id=103
+7 -22
View File
@@ -8,7 +8,6 @@ import sys
from collections import defaultdict
from pathlib import Path
import yaml
from idf_build_apps import App
from idf_build_apps import find_apps
from idf_build_apps.constants import SUPPORTED_TARGETS
@@ -40,6 +39,12 @@ USUAL_TO_FORMAL = {
FORMAL_TO_USUAL = {v: k for k, v in USUAL_TO_FORMAL.items()}
# Targets under bringup: suppress check-test-scripts warnings for these.
# Reset to [] once bringup is complete.
BYPASS_CHECK_TEST_TARGETS: list[str] = [
'esp32h21',
]
def print_diff_table(
list1: list[str], list2: list[str], title1: str, title2: str, excluded: list[str] | None = None
@@ -299,21 +304,9 @@ if __name__ == '__main__':
readme_parser = action.add_parser('check-readmes')
readme_parser.add_argument('paths', nargs='+', help='check under paths')
readme_parser.add_argument(
'-c',
'--config',
default=os.path.join(IDF_PATH, '.gitlab', 'ci', 'default-build-test-rules.yml'),
help='config file',
)
test_parser = action.add_parser('check-test-scripts')
test_parser.add_argument('paths', nargs='+', help='check under paths')
test_parser.add_argument(
'-c',
'--config',
default=os.path.join(IDF_PATH, '.gitlab', 'ci', 'default-build-test-rules.yml'),
help='config file',
)
arg = parser.parse_args()
check_dirs = set()
@@ -344,14 +337,6 @@ if __name__ == '__main__':
os.path.join(IDF_PATH, 'tools', 'cmakev2', 'test'),
]
_bypass_targets: list[str] = []
if arg.config:
with open(arg.config) as fr:
configs = yaml.safe_load(fr)
if configs:
_bypass_targets = configs.get('bypass_check_test_targets') or []
os.environ.update(
{
'IDF_PATH': IDF_PATH,
@@ -369,5 +354,5 @@ if __name__ == '__main__':
check_test_scripts(
list(check_dirs),
_exclude_dirs,
bypass_targets=_bypass_targets,
bypass_targets=BYPASS_CHECK_TEST_TARGETS,
)
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env python
#
# internal use only for CI
# get latest MR information by source branch
#
# SPDX-FileCopyrightText: 2020-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
import argparse
import logging
import os
import subprocess
import typing as t
from pathlib import Path
from gitlab_api import Gitlab
from idf_ci_utils import IDF_PATH
if t.TYPE_CHECKING:
from gitlab.v4.objects import ProjectCommit
from gitlab.v4.objects import ProjectMergeRequest
def _get_mr_obj(source_branch: str) -> t.Optional['ProjectMergeRequest']:
gl = Gitlab(os.getenv('CI_PROJECT_ID', 'espressif/esp-idf'))
if not gl.project:
return None
mrs = gl.project.mergerequests.list(state='opened', source_branch=source_branch)
if mrs:
return mrs[0] # one source branch can only have one opened MR at one moment
else:
return None
def get_mr_iid(source_branch: str) -> str:
mr = _get_mr_obj(source_branch)
if not mr:
return ''
else:
return str(mr.iid)
def get_mr_changed_files(source_branch: str) -> t.List[str]:
mr = _get_mr_obj(source_branch)
if not mr:
return []
git_output = subprocess.check_output(
['git', 'diff', '--name-only', '--diff-filter=d', f'origin/{mr.target_branch}...origin/{source_branch}']
).decode('utf8')
return [line.strip() for line in git_output.splitlines() if line.strip()]
def get_mr_commits(source_branch: str) -> t.List['ProjectCommit']:
mr = _get_mr_obj(source_branch)
if not mr:
return []
return list(mr.commits())
_COMPONENT_NAME_DIR_RECORDS = {}
def get_modified_component(filepath: str) -> t.Optional[str]:
"""Return the component name if the file is in a component directory, otherwise None."""
try:
f_path = Path(filepath).resolve().relative_to(IDF_PATH)
except ValueError: # not in IDF_PATH
return None
# skip md files, etc.
if f_path.suffix in ['.md', '.yml']:
return None
# skip test_apps files
if 'test_apps' in f_path.parts:
return None
component_parent_dirs = [f_path.parts[0]]
for part in f_path.parts[1:]:
if component_parent_dirs[-1] == 'components' or component_parent_dirs[-1].endswith('common_components'):
if part not in _COMPONENT_NAME_DIR_RECORDS:
logging.debug('Found component "%s" in path "%s"' % (part, component_parent_dirs))
_COMPONENT_NAME_DIR_RECORDS[part] = component_parent_dirs
elif _COMPONENT_NAME_DIR_RECORDS.get(part) != component_parent_dirs:
logging.debug(
'WARNING!!! Found component "%s" in path "%s" and "%s"'
% (part, component_parent_dirs, _COMPONENT_NAME_DIR_RECORDS.get(part))
)
return part
component_parent_dirs.append(part)
return None
def get_mr_components(
source_branch: t.Optional[str] = None, modified_files: t.Optional[t.List[str]] = None
) -> t.List[str]:
components: t.Set[str] = set()
if modified_files is None:
if not source_branch:
raise RuntimeError('--src-branch is required if --modified-files is not provided')
modified_files = get_mr_changed_files(source_branch)
for f in modified_files:
modified_component = get_modified_component(f)
if modified_component:
components.add(modified_component)
return list(components)
def _print_list(_list: t.List[str], separator: str = '\n') -> None:
print(separator.join(_list))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Get the latest merge request info by pipeline')
actions = parser.add_subparsers(dest='action', help='info type', required=True)
common_args = argparse.ArgumentParser(add_help=False)
common_args.add_argument('--src-branch', help='source branch')
common_args.add_argument(
'--modified-files',
nargs='+',
help='space-separated list specifies the modified files. will be detected by --src-branch if not provided',
)
actions.add_parser('id', parents=[common_args])
actions.add_parser('commits', parents=[common_args])
actions.add_parser('components', parents=[common_args])
target = actions.add_parser('target_in_tags')
target.add_argument('tags', help='comma separated tags, e.g., esp32,generic')
args = parser.parse_args()
if args.action == 'id':
if not args.src_branch:
raise RuntimeError('--src-branch is required')
print(get_mr_iid(args.src_branch))
elif args.action == 'commits':
if not args.src_branch:
raise RuntimeError('--src-branch is required')
_print_list([commit.id for commit in get_mr_commits(args.src_branch)])
elif args.action == 'components':
_print_list(get_mr_components(args.src_branch, args.modified_files))
else:
raise NotImplementedError('not possible to get here')
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env python3
#
# CI script to deploy docs to a webserver. Not useful outside of CI environment
#
#
# SPDX-FileCopyrightText: 2020-2022 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
import glob
import os
import os.path
import re
import stat
import subprocess
import sys
import tarfile
from typing import Any, List, Tuple
import packaging.version
def env(variable: str, default: str=None) -> str:
""" Shortcut to return the expanded version of an environment variable """
return os.path.expandvars(os.environ.get(variable, default) if default else os.environ[variable])
# import sanitize_version from the docs directory, shared with here
sys.path.append(os.path.join(env('IDF_PATH'), 'docs'))
from sanitize_version import sanitize_version # noqa
def main() -> None:
# if you get KeyErrors on the following lines, it's probably because you're not running in Gitlab CI
git_ver = env('GIT_VER') # output of git describe --always
ci_ver = env('CI_COMMIT_REF_NAME', git_ver) # branch or tag we're building for (used for 'release' & URL)
version = sanitize_version(ci_ver)
print('Git version: {}'.format(git_ver))
print('CI Version: {}'.format(ci_ver))
print('Deployment version: {}'.format(version))
if not version:
raise RuntimeError('A version is needed to deploy')
build_dir = env('DOCS_BUILD_DIR') # top-level local build dir, where docs have already been built
if not build_dir:
raise RuntimeError('Valid DOCS_BUILD_DIR is needed to deploy')
url_base = env('DOCS_DEPLOY_URL_BASE') # base for HTTP URLs, used to print the URL to the log after deploying
docs_server = env('DOCS_DEPLOY_SERVER') # ssh server to deploy to
docs_user = env('DOCS_DEPLOY_SERVER_USER')
docs_path = env('DOCS_DEPLOY_PATH') # filesystem path on DOCS_SERVER
if not docs_server:
raise RuntimeError('Valid DOCS_DEPLOY_SERVER is needed to deploy')
if not docs_user:
raise RuntimeError('Valid DOCS_DEPLOY_SERVER_USER is needed to deploy')
docs_server = '{}@{}'.format(docs_user, docs_server)
if not docs_path:
raise RuntimeError('Valid DOCS_DEPLOY_PATH is needed to deploy')
print('DOCS_DEPLOY_SERVER {} DOCS_DEPLOY_PATH {}'.format(docs_server, docs_path))
tarball_path, version_urls = build_doc_tarball(version, git_ver, build_dir)
deploy(version, tarball_path, docs_path, docs_server)
print('Docs URLs:')
doc_deploy_type = os.getenv('TYPE')
for vurl in version_urls:
language, _, target = vurl.split('/')
tag = '{}_{}'.format(language, target)
url = '{}/{}/index.html'.format(url_base, vurl) # (index.html needed for the preview server)
url = re.sub(r'([^:])//', r'\1/', url) # get rid of any // that isn't in the https:// part
print('[document {}][{}] {}'.format(doc_deploy_type, tag, url))
# note: it would be neater to use symlinks for stable, but because of the directory order
# (language first) it's kind of a pain to do on a remote server, so we just repeat the
# process but call the version 'stable' this time
if is_stable_version(version):
print('Deploying again as stable version...')
tarball_path, version_urls = build_doc_tarball('stable', git_ver, build_dir)
deploy('stable', tarball_path, docs_path, docs_server)
def deploy(version: str, tarball_path: str, docs_path: str, docs_server: str) -> None:
def run_ssh(commands: List) -> None:
""" Log into docs_server and run a sequence of commands using ssh """
print('Running ssh: {}'.format(commands))
subprocess.run(['ssh', '-o', 'BatchMode=yes', docs_server, '-x', ' && '.join(commands)], check=True)
# copy the version tarball to the server
run_ssh(['mkdir -p {}'.format(docs_path)])
print('Running scp {} to {}'.format(tarball_path, '{}:{}'.format(docs_server, docs_path)))
subprocess.run(['scp', '-B', tarball_path, '{}:{}'.format(docs_server, docs_path)], check=True)
tarball_name = os.path.basename(tarball_path)
run_ssh(['cd {}'.format(docs_path),
'rm -rf ./*/{}'.format(version), # remove any pre-existing docs matching this version
'tar -zxvf {}'.format(tarball_name), # untar the archive with the new docs
'rm {}'.format(tarball_name)])
# Note: deleting and then extracting the archive is a bit awkward for updating stable/latest/etc
# as the version will be invalid for a window of time. Better to do it atomically, but this is
# another thing made much more complex by the directory structure putting language before version...
def build_doc_tarball(version: str, git_ver: str, build_dir: str) -> Tuple[str, List]:
""" Make a tar.gz archive of the docs, in the directory structure used to deploy as
the given version """
version_paths = []
tarball_path = '{}/{}.tar.gz'.format(build_dir, version)
# find all the 'html/' directories under build_dir
html_dirs = glob.glob('{}/**/html/'.format(build_dir), recursive=True)
print('Found %d html directories' % len(html_dirs))
pdfs = glob.glob('{}/**/latex/build/*.pdf'.format(build_dir), recursive=True)
print('Found %d PDFs in latex directories' % len(pdfs))
# add symlink for stable and latest and adds them to PDF blob
symlinks = create_and_add_symlinks(version, git_ver, pdfs)
def not_sources_dir(ti: Any) -> Any:
print(type(ti))
""" Filter the _sources directories out of the tarballs """
if ti.name.endswith('/_sources'):
return None
ti.mode |= stat.S_IWGRP # make everything group-writeable
return ti
try:
os.remove(tarball_path)
except OSError:
pass
with tarfile.open(tarball_path, 'w:gz') as tarball:
for html_dir in html_dirs:
# html_dir has the form '<ignored>/<language>/<target>/html/'
target_dirname = os.path.dirname(os.path.dirname(html_dir))
target = os.path.basename(target_dirname)
language = os.path.basename(os.path.dirname(target_dirname))
# when deploying, we want the top-level directory layout 'language/version/target'
archive_path = '{}/{}/{}'.format(language, version, target)
print("Archiving '{}' as '{}'...".format(html_dir, archive_path))
tarball.add(html_dir, archive_path, filter=not_sources_dir)
version_paths.append(archive_path)
for pdf_path in pdfs:
# pdf_path has the form '<ignored>/<language>/<target>/latex/build'
latex_dirname = os.path.dirname(pdf_path)
pdf_filename = os.path.basename(pdf_path)
target_dirname = os.path.dirname(os.path.dirname(latex_dirname))
target = os.path.basename(target_dirname)
language = os.path.basename(os.path.dirname(target_dirname))
# when deploying, we want the layout 'language/version/target/pdf'
archive_path = '{}/{}/{}/{}'.format(language, version, target, pdf_filename)
print("Archiving '{}' as '{}'...".format(pdf_path, archive_path))
tarball.add(pdf_path, archive_path)
for symlink in symlinks:
os.unlink(symlink)
return (os.path.abspath(tarball_path), version_paths)
def create_and_add_symlinks(version: str, git_ver: str, pdfs: List) -> List:
""" Create symbolic links for PDFs for 'latest' and 'stable' releases """
symlinks = []
if 'stable' in version or 'latest' in version:
for pdf_path in pdfs:
symlink_path = pdf_path.replace(git_ver, version)
os.symlink(pdf_path, symlink_path)
symlinks.append(symlink_path)
pdfs.extend(symlinks)
print('Found %d PDFs in latex directories after adding symlink' % len(pdfs))
return symlinks
def is_stable_version(version: str) -> bool:
""" Heuristic for whether this is the latest stable release """
if not version.startswith('v'):
return False # branch name
if '-' in version:
return False # prerelease tag
git_out = subprocess.check_output(['git', 'tag', '-l']).decode('utf-8')
versions = [v.strip() for v in git_out.split('\n')]
versions = [v for v in versions if re.match(r'^v[\d\.]+$', v.strip())] # include vX.Y.Z only
versions_pack = [packaging.version.parse(v) for v in versions]
max_version = max(versions_pack)
if max_version.public != version[1:]:
print('Stable version is v{}. This version is {}.'.format(max_version.public, version))
return False
else:
print('This version {} is the stable version'.format(version))
return True
if __name__ == '__main__':
main()
+1 -18
View File
@@ -1,26 +1,9 @@
# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import os
from idf_ci_utils import IDF_PATH
COMMENT_START_MARKER = '### Dynamic Pipeline Report'
REPORT_TEMPLATE_FILEPATH = os.path.join(
IDF_PATH, 'tools', 'ci', 'dynamic_pipelines', 'templates', 'report.template.html'
)
CSS_STYLES_FILEPATH = os.path.join(IDF_PATH, 'tools', 'ci', 'dynamic_pipelines', 'templates', 'styles.css')
JS_SCRIPTS_FILEPATH = os.path.join(IDF_PATH, 'tools', 'ci', 'dynamic_pipelines', 'templates', 'scripts.js')
TOP_N_APPS_BY_SIZE_DIFF = 10
SIZE_DIFFERENCE_BYTES_THRESHOLD = 500
BINARY_SIZE_METRIC_NAME = 'binary_size'
KNOWN_GENERATE_TEST_CHILD_PIPELINE_WARNINGS_FILEPATH = os.path.join(
IDF_PATH, 'tools', 'ci', 'dynamic_pipelines', 'templates', 'known_generate_test_child_pipeline_warnings.yml'
)
CI_JOB_TOKEN = os.getenv('CI_JOB_TOKEN', '')
CI_DASHBOARD_API = os.getenv('CI_DASHBOARD_API', '')
CI_PAGES_URL = os.getenv('CI_PAGES_URL', '')
CI_PROJECT_URL = os.getenv('CI_PROJECT_URL', '')
CI_MERGE_REQUEST_SOURCE_BRANCH_SHA = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', '')
-121
View File
@@ -1,121 +0,0 @@
# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import os
import typing as t
import urllib.parse
from dataclasses import dataclass
from xml.etree.ElementTree import Element
from idf_ci_utils import IDF_PATH
@dataclass
class TestCase:
name: str
file: str
time: float
app_path: t.Optional[str] = None
failure: t.Optional[str] = None
skipped: t.Optional[str] = None
ci_job_url: t.Optional[str] = None
ci_dashboard_url: t.Optional[str] = None
dut_log_url: t.Optional[str] = None
latest_total_count: int = 0
latest_failed_count: int = 0
@property
def is_failure(self) -> bool:
return self.failure is not None
@property
def is_skipped(self) -> bool:
return self.skipped is not None
@property
def is_success(self) -> bool:
return not self.is_failure and not self.is_skipped
@classmethod
def _get_idf_rel_path(cls, path: str) -> str:
if path.startswith(IDF_PATH):
return os.path.relpath(path, IDF_PATH)
else:
return path
@classmethod
def from_test_case_node(cls, node: Element) -> t.Optional['TestCase']:
if 'name' not in node.attrib:
print('WARNING: Node Invalid: ', node)
return None
# url to test cases dashboard
grafana_base_url = urllib.parse.urljoin(os.getenv('CI_DASHBOARD_HOST', ''), '/d/Ucg477Fnz/case-list')
encoded_params = urllib.parse.urlencode({'var-case_id': node.attrib['name']}, quote_via=urllib.parse.quote)
kwargs = {
'name': node.attrib['name'],
'file': node.attrib.get('file'),
'app_path': '|'.join(
cls._get_idf_rel_path(path) for path in node.attrib.get('app_path', 'unknown').split('|')
),
'time': float(node.attrib.get('time') or 0),
'ci_job_url': node.attrib.get('ci_job_url') or 'Not found',
'ci_dashboard_url': f'{grafana_base_url}?{encoded_params}',
'dut_log_url': node.attrib.get('dut_log_url') or 'Not found',
}
failure_node = node.find('failure')
# bool(failure_node) is False, so compare with None
if failure_node is None:
failure_node = node.find('error')
if failure_node is not None:
message = failure_node.attrib.get('message', '')
kwargs['failure'] = message
skipped_node = node.find('skipped')
if skipped_node is not None:
kwargs['skipped'] = skipped_node.attrib['message']
return cls(**kwargs) # type: ignore
@dataclass
class GitlabJob:
id: int
name: str
stage: str
status: str
url: str
ci_dashboard_url: str
failure_reason: t.Optional[str] = None
failure_log: t.Optional[str] = None
latest_total_count: int = 0
latest_failed_count: int = 0
@property
def is_failed(self) -> bool:
return self.status == 'failed'
@property
def is_success(self) -> bool:
return self.status == 'success'
@classmethod
def from_json_data(cls, job_data: dict, failure_data: dict) -> t.Optional['GitlabJob']:
grafana_base_url = urllib.parse.urljoin(os.getenv('CI_DASHBOARD_HOST', ''), '/d/LoUa-qLWz/job-list')
encoded_params = urllib.parse.urlencode({'var-job_name': job_data['name']}, quote_via=urllib.parse.quote)
kwargs = {
'id': job_data['id'],
'name': job_data['name'],
'stage': job_data['stage'],
'status': job_data['status'],
'url': job_data['url'],
'ci_dashboard_url': f'{grafana_base_url}?{encoded_params}',
'failure_reason': job_data['failure_reason'],
'failure_log': job_data['failure_log'],
'latest_total_count': failure_data.get('total_count', 0),
'latest_failed_count': failure_data.get('failed_count', 0),
}
return cls(**kwargs) # type: ignore
-739
View File
@@ -1,739 +0,0 @@
# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import abc
import html
import re
import typing as t
from textwrap import dedent
from gitlab import GitlabUpdateError
from gitlab_api import Gitlab
from idf_build_apps.constants import BuildStatus
from idf_ci_local.app import AppWithMetricsInfo
from idf_ci_utils import idf_relpath
from prettytable import PrettyTable
from .constants import BINARY_SIZE_METRIC_NAME
from .constants import CI_DASHBOARD_API
from .constants import COMMENT_START_MARKER
from .constants import CSS_STYLES_FILEPATH
from .constants import JS_SCRIPTS_FILEPATH
from .constants import REPORT_TEMPLATE_FILEPATH
from .constants import SIZE_DIFFERENCE_BYTES_THRESHOLD
from .constants import TOP_N_APPS_BY_SIZE_DIFF
from .models import GitlabJob
from .models import TestCase
from .utils import format_permalink
from .utils import get_artifacts_url
from .utils import is_url
class ReportGenerator:
REGEX_PATTERN = r'#### {}\n[\s\S]*?(?=\n#### |$)'
def __init__(
self,
project_id: int,
mr_iid: int,
pipeline_id: int,
job_id: int,
commit_id: str,
local_commit_id: str,
*,
title: str,
):
gl_project = Gitlab(project_id).project
if mr_iid is not None:
self.mr = gl_project.mergerequests.get(mr_iid)
else:
self.mr = None
self.pipeline_id = pipeline_id
self.job_id = job_id
self.commit_id = commit_id
self.local_commit_id = local_commit_id
self.title = title
self.output_filepath = self.title.lower().replace(' ', '_') + '.html'
self.additional_info = ''
@property
def get_commit_summary(self) -> str:
return f'with CI commit SHA: {self.commit_id[:8]}, local commit SHA: {self.local_commit_id[:8]}'
@staticmethod
def get_download_link_for_url(url: str) -> str:
if url:
return f'<a href="{url}">Download</a>'
return ''
@staticmethod
def write_report_to_file(report_str: str, job_id: int, output_filepath: str) -> str | None:
"""
Writes the report to a file and constructs a modified URL based on environment settings.
:param report_str: The report content to be written to the file.
:param job_id: The job identifier used to construct the URL.
:param output_filepath: The path to the output file.
:return: The modified URL pointing to the job's artifacts.
"""
if not report_str:
return None
with open(output_filepath, 'w') as file:
file.write(report_str)
# for example, {URL}/-/esp-idf/-/jobs/{id}/artifacts/app_info_84.txt
# CI_PAGES_URL is {URL}/esp-idf, which missed one `-`
report_url: str = get_artifacts_url(job_id, output_filepath)
return report_url
@staticmethod
def _load_file_content(filepath: str) -> str:
"""
Load the content of a file as string
:param filepath: Path to the file to load
:return: Content of the file as string
"""
try:
with open(filepath, encoding='utf-8') as f:
return f.read()
except (OSError, FileNotFoundError) as e:
print(f'Warning: Could not read file {filepath}: {e}')
return ''
def generate_html_report(self, table_str: str) -> str:
# we're using bootstrap table
table_str = table_str.replace(
'<table>',
'<table data-toggle="table" data-search-align="left" data-search="true" data-sticky-header="true">',
)
template = self._load_file_content(REPORT_TEMPLATE_FILEPATH)
css_content = self._load_file_content(CSS_STYLES_FILEPATH)
js_content = self._load_file_content(JS_SCRIPTS_FILEPATH)
template = template.replace('{{css_content}}', css_content)
template = template.replace('{{js_content}}', js_content)
template = template.replace('{{pipeline_id}}', str(self.pipeline_id))
template = template.replace('{{apiBaseUrl}}', CI_DASHBOARD_API)
return template.replace('{{title}}', self.title).replace('{{table}}', table_str)
@staticmethod
def table_to_html_str(table: PrettyTable) -> str:
return html.unescape(table.get_html_string()) # type: ignore
def create_table_section(
self,
title: str,
items: list,
headers: list,
row_attrs: list,
value_functions: list | None = None,
) -> list:
"""
Appends a formatted section to a report based on the provided items. This section includes
a header and a table constructed from the items list with specified headers and attributes.
:param title: Title for the report section. This title is used as a header above the table.
:param items: List of item objects to include in the table. Each item should have attributes
that correspond to the row_attrs and value_functions specified.
:param headers: List of strings that will serve as the column headers in the generated table.
:param row_attrs: List of attributes to include from each item for the table rows. These
should be attributes or keys that exist on the items in the 'items' list.
:param value_functions: Optional list of tuples containing additional header and corresponding
value function. Each tuple should specify a header (as a string) and
a function that takes an item and returns a string. This is used for
generating dynamic columns based on item data.
:return: List with appended HTML sections.
"""
if not items:
return []
report_sections = [
f"""<h2 id="{format_permalink(title)}">{title}<i class="fas fa-link copy-link-icon"
onclick="copyPermalink('#{format_permalink(title)}')"></i></h2>""",
self._create_table_for_items(
items=items, headers=headers, row_attrs=row_attrs, value_functions=value_functions or []
),
]
return report_sections
@staticmethod
def generate_additional_info_section(
title: str, count: int, report_url: str | None = None, add_permalink: bool = True
) -> str:
"""
Generate a section for the additional info string.
:param title: The title of the section.
:param count: The count of test cases.
:param report_url: The URL of the report. If count = 0, only the count will be included.
:param add_permalink: Whether to include a permalink in the report URL. Defaults to True.
:return: The formatted additional info section string.
"""
if count != 0 and report_url:
if add_permalink:
return f'- **{title}:** [{count}]({report_url}/#{format_permalink(title)})\n'
else:
return f'- **{title}:** [{count}]({report_url})\n'
else:
return f'- **{title}:** {count}\n'
def _create_table_for_items(
self,
items: list[TestCase] | list[GitlabJob],
headers: list[str],
row_attrs: list[str],
value_functions: list[tuple[str, t.Callable[[TestCase | GitlabJob], str]]] | None = None,
) -> str:
"""
Create a PrettyTable and convert it to an HTML string for the provided test cases.
:param items: List of item objects to include in the table.
:param headers: List of strings for the table headers.
:param row_attrs: List of attributes to include in each row.
:param value_functions: List of tuples containing additional header and corresponding value function.
:return: HTML table string.
"""
table = PrettyTable()
table.field_names = headers
# Create a mapping of header names to their corresponding index in the headers list
header_index_map = {header: i for i, header in enumerate(headers)}
for item in items:
row = []
for attr in row_attrs:
value = str(getattr(item, attr, ''))
if is_url(value):
link = f'<a href="{value}">link</a>'
row.append(link)
else:
row.append(value)
# Insert values computed by value functions at the correct column position based on their headers
if value_functions:
for header, func in value_functions:
index = header_index_map.get(header)
if index is not None:
computed_value = func(item)
row.insert(index, computed_value)
table.add_row(row)
return self.table_to_html_str(table)
@staticmethod
def _filter_items(
items: list[TestCase] | list[GitlabJob], condition: t.Callable[[TestCase | GitlabJob], bool]
) -> list[TestCase]:
"""
Filter items s based on a given condition.
:param items: List of items to filter by given condition.
:param condition: A function that evaluates to True or False for each items.
:return: List of filtered instances.
"""
return [item for item in items if condition(item)]
@staticmethod
def _sort_items(
items: list[TestCase | GitlabJob | AppWithMetricsInfo],
key: str | t.Callable[[TestCase | GitlabJob | AppWithMetricsInfo], t.Any],
order: str = 'asc',
sort_function: t.Callable[[t.Any], t.Any] | None = None,
) -> list[TestCase | GitlabJob | AppWithMetricsInfo]:
"""
Sort items based on a given key, order, and optional custom sorting function.
:param items: List of items to sort.
:param key: A string representing the attribute name or a function to extract the sorting key.
:param order: Order of sorting ('asc' for ascending, 'desc' for descending).
:param sort_function: A custom function to control sorting logic
(e.g., prioritizing positive/negative/zero values).
:return: List of sorted instances.
"""
key_func = None
if isinstance(key, str):
def key_func(item: t.Any) -> t.Any:
return getattr(item, key)
sorting_key = sort_function if sort_function is not None else key_func
try:
items = sorted(items, key=sorting_key, reverse=(order == 'desc'))
except TypeError:
print(f'Comparison for the key {key} is not supported')
return items
@abc.abstractmethod
def _get_report_str(self) -> str:
raise NotImplementedError
def _generate_comment(self) -> str:
# Report in HTML format to avoid exceeding length limits
comment = f'#### {self.title}\n'
report_str = self._get_report_str()
comment += f'{self.additional_info}\n'
self.write_report_to_file(report_str, self.job_id, self.output_filepath)
return comment
def _update_mr_comment(self, comment: str) -> None:
new_comment = f'{COMMENT_START_MARKER}\n\n{comment}'
for note in self.mr.notes.list(iterator=True):
if note.body.startswith(COMMENT_START_MARKER):
updated_str = self._get_updated_comment(note.body, comment)
note.body = updated_str
try:
note.save()
except GitlabUpdateError:
print('Failed to update MR comment, Creating a new comment')
self.mr.notes.create({'body': new_comment})
break
else:
self.mr.notes.create({'body': new_comment})
def _get_updated_comment(self, existing_comment: str, new_comment: str) -> str:
updated_str = re.sub(self.REGEX_PATTERN.format(self.title), new_comment, existing_comment)
if updated_str == existing_comment:
updated_str = f'{existing_comment.strip()}\n\n{new_comment}'
return updated_str
def post_report(self) -> None:
comment = self._generate_comment()
print(comment)
if self.mr is None:
print('No MR found, skip posting comment')
return
self._update_mr_comment(comment)
class BuildReportGenerator(ReportGenerator):
def __init__(
self,
project_id: int,
mr_iid: int,
pipeline_id: int,
job_id: int,
commit_id: str,
local_commit_id: str,
*,
title: str = 'Build Report',
apps: list[AppWithMetricsInfo],
) -> None:
super().__init__(project_id, mr_iid, pipeline_id, job_id, commit_id, local_commit_id, title=title)
self.apps = apps
self.report_titles_map = {
'failed_apps': 'Failed Apps',
'built_test_related_apps': 'Built Apps - Test Related',
'built_non_test_related_apps': 'Built Apps - Non Test Related',
'new_test_related_apps': 'New Apps - Test Related',
'new_non_test_related_apps': 'New Apps - Non Test Related',
'skipped_apps': 'Skipped Apps',
}
self.failed_apps_report_file = 'failed_apps.html'
self.built_apps_report_file = 'built_apps.html'
self.skipped_apps_report_file = 'skipped_apps.html'
@staticmethod
def custom_sort(item: AppWithMetricsInfo) -> tuple[int, t.Any]:
"""
Custom sort function to:
1. Push items with zero binary sizes to the end.
2. Sort other items by absolute size_difference_percentage.
"""
# Priority: 0 for zero binaries, 1 for non-zero binaries
zero_binary_priority = (
1
if item.metrics[BINARY_SIZE_METRIC_NAME].source_value != 0
or item.metrics[BINARY_SIZE_METRIC_NAME].target_value != 0
else 0
)
# Secondary sort: Negative absolute size_difference_percentage for descending order
size_difference_sort = abs(item.metrics[BINARY_SIZE_METRIC_NAME].difference_percentage)
return zero_binary_priority, size_difference_sort
def _generate_top_n_apps_by_size_table(self) -> str:
"""
Generate a markdown table for the top N apps by size difference.
Only includes apps with size differences greater than 500 bytes.
"""
filtered_apps = [
app
for app in self.apps
if abs(app.metrics[BINARY_SIZE_METRIC_NAME].difference) > SIZE_DIFFERENCE_BYTES_THRESHOLD
]
top_apps = sorted(
filtered_apps, key=lambda app: abs(app.metrics[BINARY_SIZE_METRIC_NAME].difference_percentage), reverse=True
)[:TOP_N_APPS_BY_SIZE_DIFF]
if not top_apps:
return ''
table = (
f'\n⚠️⚠️⚠️ Top {len(top_apps)} Apps with Binary Size Sorted by Size Difference\n'
f'Note: Apps with changes of less than {SIZE_DIFFERENCE_BYTES_THRESHOLD} bytes are not shown.\n'
)
table += '| App Dir | Build Dir | Size Diff (bytes) | Size Diff (%) |\n'
table += '|---------|-----------|-------------------|---------------|\n'
for app in top_apps:
table += dedent(
f'| {app.app_dir} | {app.build_dir} | '
f'{app.metrics[BINARY_SIZE_METRIC_NAME].difference} | '
f'{app.metrics[BINARY_SIZE_METRIC_NAME].difference_percentage}% |\n'
)
table += (
'\n**For more details, please click on the numbers in the summary above '
'to view the corresponding report files.** ⬆️⬆️⬆️\n\n'
)
return table
@staticmethod
def split_new_and_existing_apps(
apps: t.Iterable[AppWithMetricsInfo],
) -> tuple[list[AppWithMetricsInfo], list[AppWithMetricsInfo]]:
"""
Splits apps into new apps and existing apps.
:param apps: Iterable of apps to process.
:return: A tuple (new_apps, existing_apps).
"""
new_apps = [app for app in apps if app.is_new_app]
existing_apps = [app for app in apps if not app.is_new_app]
return new_apps, existing_apps
def filter_apps_by_criteria(self, build_status: str, preserve: bool) -> list[AppWithMetricsInfo]:
"""
Filters apps based on build status and preserve criteria.
:param build_status: Build status to filter by.
:param preserve: Whether to filter preserved apps.
:return: Filtered list of apps.
"""
return [app for app in self.apps if app.build_status == build_status and app.preserve == preserve]
def get_built_apps_report_parts(self) -> list[str]:
"""
Generates report parts for new and existing apps.
:return: List of report parts.
"""
new_test_related_apps, built_test_related_apps = self.split_new_and_existing_apps(
self.filter_apps_by_criteria(BuildStatus.SUCCESS, True)
)
new_non_test_related_apps, built_non_test_related_apps = self.split_new_and_existing_apps(
self.filter_apps_by_criteria(BuildStatus.SUCCESS, False)
)
sections = []
if new_test_related_apps:
new_test_related_apps_table_section = self.create_table_section(
title=self.report_titles_map['new_test_related_apps'],
items=new_test_related_apps,
headers=[
'App Dir',
'Build Dir',
'Download Command',
'Your Branch App Size',
],
row_attrs=[
'app_dir',
'build_dir',
],
value_functions=[
('Your Branch App Size', lambda _app: str(_app.metrics[BINARY_SIZE_METRIC_NAME].source_value)),
(
'Download Command',
lambda _app: f'idf-ci gitlab download-artifacts --pipeline-id {self.pipeline_id} '
f'{idf_relpath(_app.build_path)}',
),
],
)
sections.extend(new_test_related_apps_table_section)
if built_test_related_apps:
built_test_related_apps = self._sort_items(
built_test_related_apps,
key='metrics.binary_size.difference_percentage',
order='desc',
sort_function=self.custom_sort,
)
built_test_related_apps_table_section = self.create_table_section(
title=self.report_titles_map['built_test_related_apps'],
items=built_test_related_apps,
headers=[
'App Dir',
'Build Dir',
'Download Command',
'Your Branch App Size',
'Target Branch App Size',
'Size Diff',
'Size Diff, %',
],
row_attrs=[
'app_dir',
'build_dir',
],
value_functions=[
('Your Branch App Size', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].source_value)),
('Target Branch App Size', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].target_value)),
('Size Diff', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].difference)),
('Size Diff, %', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].difference_percentage)),
(
'Download Command',
lambda _app: f'idf-ci gitlab download-artifacts --pipeline-id {self.pipeline_id} '
f'{idf_relpath(_app.build_path)}',
),
],
)
sections.extend(built_test_related_apps_table_section)
if new_non_test_related_apps:
new_non_test_related_apps_table_section = self.create_table_section(
title=self.report_titles_map['new_non_test_related_apps'],
items=new_non_test_related_apps,
headers=[
'App Dir',
'Build Dir',
'Download Command',
'Your Branch App Size',
],
row_attrs=[
'app_dir',
'build_dir',
],
value_functions=[
(
'Download Command',
lambda _app: f'idf-ci gitlab download-artifacts --pipeline-id {self.pipeline_id} '
f'{idf_relpath(_app.build_path)}',
),
('Your Branch App Size', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].source_value)),
],
)
sections.extend(new_non_test_related_apps_table_section)
if built_non_test_related_apps:
built_non_test_related_apps = self._sort_items(
built_non_test_related_apps,
key='metrics.binary_size.difference_percentage',
order='desc',
sort_function=self.custom_sort,
)
built_non_test_related_apps_table_section = self.create_table_section(
title=self.report_titles_map['built_non_test_related_apps'],
items=built_non_test_related_apps,
headers=[
'App Dir',
'Build Dir',
'Download Command',
'Your Branch App Size',
'Target Branch App Size',
'Size Diff',
'Size Diff, %',
],
row_attrs=[
'app_dir',
'build_dir',
],
value_functions=[
('Your Branch App Size', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].source_value)),
('Target Branch App Size', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].target_value)),
('Size Diff', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].difference)),
('Size Diff, %', lambda app: str(app.metrics[BINARY_SIZE_METRIC_NAME].difference_percentage)),
(
'Download Command',
lambda _app: f'idf-ci gitlab download-artifacts --pipeline-id {self.pipeline_id} '
f'{idf_relpath(_app.build_path)}',
),
],
)
sections.extend(built_non_test_related_apps_table_section)
built_apps_report_url = self.write_report_to_file(
self.generate_html_report(''.join(sections)),
self.job_id,
self.built_apps_report_file,
)
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['built_test_related_apps'],
len(built_test_related_apps),
built_apps_report_url,
)
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['built_non_test_related_apps'],
len(built_non_test_related_apps),
built_apps_report_url,
)
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['new_test_related_apps'],
len(new_test_related_apps),
built_apps_report_url,
)
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['new_non_test_related_apps'],
len(new_non_test_related_apps),
built_apps_report_url,
)
self.additional_info += self._generate_top_n_apps_by_size_table()
return sections
def get_failed_apps_report_parts(self) -> list[str]:
failed_apps = [app for app in self.apps if app.build_status == BuildStatus.FAILED]
if not failed_apps:
return []
failed_apps_table_section = self.create_table_section(
title=self.report_titles_map['failed_apps'],
items=failed_apps,
headers=['App Dir', 'Build Dir', 'Failed Reason', 'Download Command'],
row_attrs=['app_dir', 'build_dir', 'build_comment'],
value_functions=[
(
'Download Command',
lambda _app: f'idf-ci gitlab download-artifacts --pipeline-id {self.pipeline_id} '
f'{idf_relpath(_app.build_path)}',
),
],
)
failed_apps_report_url = self.write_report_to_file(
self.generate_html_report(''.join(failed_apps_table_section)),
self.job_id,
self.failed_apps_report_file,
)
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['failed_apps'], len(failed_apps), failed_apps_report_url
)
return failed_apps_table_section
def get_skipped_apps_report_parts(self) -> list[str]:
skipped_apps = [app for app in self.apps if app.build_status == BuildStatus.SKIPPED]
if not skipped_apps:
return []
skipped_apps_table_section = self.create_table_section(
title=self.report_titles_map['skipped_apps'],
items=skipped_apps,
headers=['App Dir', 'Build Dir', 'Skipped Reason'],
row_attrs=['app_dir', 'build_dir', 'build_comment'],
)
skipped_apps_report_url = self.write_report_to_file(
self.generate_html_report(''.join(skipped_apps_table_section)),
self.job_id,
self.skipped_apps_report_file,
)
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['skipped_apps'], len(skipped_apps), skipped_apps_report_url
)
return skipped_apps_table_section
def _get_report_str(self) -> str:
self.additional_info = (
f'**Build Summary ({self.get_commit_summary}):**\n'
'\n'
'> ℹ️ Note: Binary artifacts stored in MinIO are retained for 4 DAYS from their build date\n'
)
failed_apps_report_parts = self.get_failed_apps_report_parts()
skipped_apps_report_parts = self.get_skipped_apps_report_parts()
built_apps_report_parts = self.get_built_apps_report_parts()
return self.generate_html_report(
''.join(failed_apps_report_parts + built_apps_report_parts + skipped_apps_report_parts)
)
class JobReportGenerator(ReportGenerator):
def __init__(
self,
project_id: int,
mr_iid: int,
pipeline_id: int,
job_id: int,
commit_id: str,
local_commit_id: str,
*,
title: str = 'Job Report',
jobs: list[GitlabJob],
):
super().__init__(project_id, mr_iid, pipeline_id, job_id, commit_id, local_commit_id, title=title)
self.jobs = jobs
self.report_titles_map = {
'failed_jobs': 'Failed Jobs (Excludes "integration_test" and "target_test" jobs)',
'succeeded': 'Succeeded Jobs',
}
self.failed_jobs_report_file = 'job_report.html'
def _get_report_str(self) -> str:
"""
Generate a complete HTML report string by processing jobs.
:return: Complete HTML report string.
"""
report_str: str = ''
if not self.jobs:
print('No jobs found, skip generating job report')
return 'No Job Found'
relevant_failed_jobs = self._sort_items(
self._filter_items(
self.jobs, lambda job: job.is_failed and job.stage not in ['integration_test', 'target_test']
),
key='latest_failed_count',
)
succeeded_jobs = self._filter_items(self.jobs, lambda job: job.is_success)
self.additional_info = f'**Job Summary ({self.get_commit_summary}):**\n'
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['succeeded'], len(succeeded_jobs)
)
if not relevant_failed_jobs:
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['failed_jobs'], len(relevant_failed_jobs)
)
return report_str
report_sections = self.create_table_section(
title='Failed Jobs (Excludes "integration_test" and "target_test" jobs)',
items=relevant_failed_jobs,
headers=[
'Job Name',
'Failure Reason',
'Failure Log',
'Failures across all other branches (10 latest jobs)',
'URL',
'CI Dashboard URL',
],
row_attrs=['name', 'failure_reason', 'failure_log', 'url', 'ci_dashboard_url'],
value_functions=[
(
'Failures across all other branches (10 latest jobs)',
lambda item: f'{getattr(item, "latest_failed_count", "")} '
f'/ {getattr(item, "latest_total_count", "")}',
)
],
)
relevant_failed_jobs_report_url = get_artifacts_url(self.job_id, self.failed_jobs_report_file)
self.additional_info += self.generate_additional_info_section(
self.report_titles_map['failed_jobs'], len(relevant_failed_jobs), relevant_failed_jobs_report_url
)
report_str = self.generate_html_report(''.join(report_sections))
return report_str
@@ -1,121 +0,0 @@
# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import argparse
import glob
import os
import subprocess
import typing as t
import __init__ # noqa: F401 # inject the system path
from idf_build_apps import json_list_files_to_apps
from idf_ci import GitlabEnvVars
from idf_ci_local.app import enrich_apps_with_metrics_info
from dynamic_pipelines.report import BuildReportGenerator
from dynamic_pipelines.report import JobReportGenerator
from dynamic_pipelines.utils import fetch_app_metrics
from dynamic_pipelines.utils import fetch_failed_jobs
def main() -> None:
parser: argparse.ArgumentParser = setup_argument_parser()
args: argparse.Namespace = parser.parse_args()
report_actions: dict[str, t.Callable[[argparse.Namespace], None]] = {
'build': generate_build_report,
'job': generate_jobs_report,
}
report_action = report_actions.get(args.report_type)
if report_action is None:
raise ValueError('Unknown report type is requested to be generated.')
report_action(args)
def setup_argument_parser() -> argparse.ArgumentParser:
report_type_parser: argparse.ArgumentParser = argparse.ArgumentParser(add_help=False)
report_type_parser.add_argument(
'--report-type', choices=['build', 'job'], required=True, help='Type of report to generate'
)
report_type_args: argparse.Namespace
remaining_args: list[str]
report_type_args, remaining_args = report_type_parser.parse_known_args()
parser: argparse.ArgumentParser = argparse.ArgumentParser(
description='Update reports in MR pipelines based on the selected report type',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
parents=[report_type_parser],
)
common_arguments(parser)
conditional_arguments(report_type_args, parser)
return parser
def common_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument('--project-id', type=int, default=os.getenv('CI_PROJECT_ID'), help='Project ID')
parser.add_argument('--mr-iid', type=int, default=os.getenv('CI_MERGE_REQUEST_IID'), help='Merge Request IID')
parser.add_argument('--pipeline-id', type=int, default=os.getenv('PARENT_PIPELINE_ID'), help='Pipeline ID')
parser.add_argument('--job-id', type=int, default=os.getenv('CI_JOB_ID'), help='Job ID')
parser.add_argument('--commit-id', default=os.getenv('CI_COMMIT_SHA', ''), help='MR merged result commit ID')
parser.add_argument('--local-commit-id', default=os.getenv('PIPELINE_COMMIT_SHA', ''), help='local dev commit ID')
def conditional_arguments(report_type_args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
if report_type_args.report_type == 'build':
parser.add_argument('--app-list-filepattern', default='app_info*.txt', help='Pattern to match app list files')
elif report_type_args.report_type == 'target_test':
parser.add_argument(
'--junit-report-filepattern', default='XUNIT_RESULT*.xml', help='Pattern to match JUnit report files'
)
def generate_build_report(args: argparse.Namespace) -> None:
# generate presigned url for the artifacts
subprocess.check_output(
[
'idf-ci',
'gitlab',
'generate-presigned-json',
'--commit-sha',
args.local_commit_id,
'--output',
'presigned.json',
],
)
print('generated presigned.json')
# generate report
apps = json_list_files_to_apps(glob.glob(args.app_list_filepattern))
print(f'loaded {len(apps)} apps')
app_metrics = fetch_app_metrics(
source_commit_sha=args.commit_id,
target_commit_sha=os.environ.get('CI_MERGE_REQUEST_TARGET_BRANCH_SHA'),
)
apps = enrich_apps_with_metrics_info(app_metrics, apps)
report_generator = BuildReportGenerator(
args.project_id, args.mr_iid, args.pipeline_id, args.job_id, args.commit_id, args.local_commit_id, apps=apps
)
report_generator.post_report()
def generate_jobs_report(args: argparse.Namespace) -> None:
jobs: list[t.Any] = fetch_failed_jobs(args.commit_id)
if not jobs:
return
report_generator = JobReportGenerator(
args.project_id, args.mr_iid, args.pipeline_id, args.job_id, args.commit_id, args.local_commit_id, jobs=jobs
)
report_generator.post_report()
if GitlabEnvVars().IDF_CI_IS_DEBUG_PIPELINE:
print('Debug pipeline detected, exit non-zero to fail the pipeline in order to block merge')
exit(30)
if __name__ == '__main__':
main()
@@ -43,7 +43,6 @@
- run_cmd idf-ci build run
--parallel-count ${CI_NODE_TOTAL:-1}
--parallel-index ${CI_NODE_INDEX:-1}
--modified-files ${MR_MODIFIED_FILES}
.dynamic_target_test_template:
image: $TARGET_TEST_ENV_IMAGE
@@ -61,12 +60,6 @@
job: pipeline_variables
- pipeline: $PARENT_PIPELINE_ID
job: snapshot_known_failure_cases
cache:
# Usually do not need submodule-cache in target_test
- key: pip-cache-${LATEST_GIT_TAG}
paths:
- .cache/pip
policy: pull
artifacts:
paths:
- XUNIT_RESULT*.xml
@@ -1,131 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{title}}</title>
<link
rel="shortcut icon"
href="https://www.espressif.com/sites/all/themes/espressif/favicon.ico"
type="image/vnd.microsoft.icon"
/>
<!-- External CSS libraries -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
href="https://unpkg.com/bootstrap-table@1.22.1/dist/bootstrap-table.min.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.0/dist/extensions/sticky-header/bootstrap-table-sticky-header.css"
/>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
rel="stylesheet"
/>
<!-- CSS content will be injected here during template rendering -->
<style>
/* {{css_content}} */
/* Additional styles for disabled tabs */
.report-nav-tabs .nav-tab.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Loading animation for tabs */
.report-nav-tabs .nav-tab.loading:after {
content: "";
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid rgba(0, 0, 0, 0.2);
border-left-color: #333;
border-radius: 50%;
margin-left: 8px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body data-pipeline-id="{{pipeline_id}}">
<!-- Navigation progress bar -->
<div class="nav-progress-container">
<div class="nav-progress-bar" id="nav-progress-bar"></div>
</div>
<div class="container-fluid">
<!-- Report header section -->
<header class="report-header">
<div class="logo-container">
<img
src="https://www.espressif.com/sites/all/themes/espressif/logo-black.svg"
alt="Espressif Logo"
class="logo"
/>
</div>
<div class="title-container">
<h1>
<span style="color: #333">Dynamic Pipeline Report</span>
</h1>
</div>
<div class="spacer"></div>
</header>
<!-- Search and controls section -->
<div class="row mb-4">
<div class="col-md-8">
</div>
<div class="col-md-4 text-end">
<div class="action-buttons">
<button
class="btn btn-esp btn-sm"
id="expand-all-tables"
>
<i class="fas fa-table"></i> Expand All
</button>
<button
class="btn btn-outline-secondary btn-sm ms-2"
id="collapse-all-tables"
>
<i class="fas fa-minus"></i> Collapse All
</button>
</div>
</div>
</div>
<div class="table-responsive">
<div class="table-container">{{table}}</div>
</div>
</div>
<!-- Floating action buttons -->
<div class="floating-actions">
<div class="floating-action-btn back-to-top" id="back-to-top">
<i class="fas fa-arrow-up"></i>
</div>
</div>
<!-- JavaScript libraries -->
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.22.1/dist/bootstrap-table.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.0/dist/extensions/sticky-header/bootstrap-table-sticky-header.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.22.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tableexport.jquery.plugin@1.10.21/tableExport.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.22.1/dist/extensions/filter-control/bootstrap-table-filter-control.min.js"></script>
<!-- Custom scripts -->
<script>
{{js_content}}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,7 @@ all_build_finished:
script:
- echo "all test jobs finished"
generate_pytest_build_report:
generate_presigned_json:
stage: assign_test
image: $ESP_ENV_IMAGE
tags:
@@ -29,16 +29,16 @@ generate_pytest_build_report:
job: pipeline_variables
artifacts:
paths:
- failed_apps.html
- built_apps.html
- skipped_apps.html
- build_report.html
- presigned.json
expire_in: 1 week
when: always
script:
- python tools/ci/dynamic_pipelines/scripts/generate_report.py --report-type build
- python tools/ci/previous_stage_job_status.py --stage build
- idf-ci gitlab generate-presigned-json --output presigned.json
include:
- project: "ci/actions/common"
file:
- "templates/idf/app-size-metrics-full-report.yml"
generate_pytest_child_pipeline:
# finally, we can get some use out of the default behavior that downloads all artifacts from the previous stage
-151
View File
@@ -1,151 +0,0 @@
# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import os
from urllib.parse import urlparse
import requests
from .constants import CI_DASHBOARD_API
from .constants import CI_JOB_TOKEN
from .constants import CI_MERGE_REQUEST_SOURCE_BRANCH_SHA
from .constants import CI_PAGES_URL
from .constants import CI_PROJECT_URL
from .models import GitlabJob
def is_url(string: str) -> bool:
"""
Check if the string is a valid URL by parsing it and verifying if it contains both a scheme and a network location.
:param string: The string to check if it is a URL.
:return: True if the string is a valid URL, False otherwise.
"""
parsed = urlparse(string)
return bool(parsed.scheme) and bool(parsed.netloc)
def fetch_failed_jobs(commit_id: str) -> list[GitlabJob]:
"""
Fetches a list of jobs from the specified commit_id using an API request to ci-dashboard-api.
:param commit_id: The commit ID for which to fetch jobs.
:return: A list of jobs if the request is successful, otherwise an empty list.
"""
response = requests.get(
f'{CI_DASHBOARD_API}/commits/{commit_id}/jobs',
headers={'CI-Job-Token': CI_JOB_TOKEN},
)
if response.status_code != 200:
print(f'Failed to fetch jobs data: {response.status_code} with error: {response.text}')
return []
data = response.json()
jobs = data.get('jobs', [])
if not jobs:
return []
failed_job_names = [job['name'] for job in jobs if job['status'] == 'failed']
response = requests.post(
f'{CI_DASHBOARD_API}/jobs/failure_ratio',
headers={'CI-Job-Token': CI_JOB_TOKEN},
json={
'job_names': failed_job_names,
'exclude_branches': [os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', '')],
},
)
if response.status_code != 200:
print(f'Failed to fetch jobs failure rate data: {response.status_code} with error: {response.text}')
return []
failure_rate_data = response.json()
failure_rates = {item['name']: item for item in failure_rate_data.get('jobs', [])}
combined_jobs = []
for job in jobs:
failure_data = failure_rates.get(job['name'], {})
combined_jobs.append(GitlabJob.from_json_data(job, failure_data))
return combined_jobs
def fetch_app_metrics(
source_commit_sha: str,
target_commit_sha: str,
) -> dict:
"""
Fetches the app metrics for the given source commit SHA and target branch SHA.
:param source_commit_sha: The source commit SHA.
:param target_branch_sha: The commit SHA of the branch to compare app sizes against.
:return: A dict of sizes of built binaries.
"""
print(f'Fetching bin size info: {source_commit_sha=} {target_commit_sha=}')
build_info_map = dict()
response = requests.post(
f'{CI_DASHBOARD_API}/apps/metrics',
headers={'CI-Job-Token': CI_JOB_TOKEN},
json={
'source_commit_sha': source_commit_sha,
'target_commit_sha': target_commit_sha,
},
)
if response.status_code != 200:
print(f'Failed to fetch build info: {response.status_code} - {response.text}')
else:
response_data = response.json()
build_info_map = {
f'{info["app_path"]}_{info["config_name"]}_{info["target"]}': info for info in response_data.get('data', [])
}
return build_info_map
def load_file(file_path: str) -> str:
"""
Loads the content of a file.
:param file_path: The path to the file needs to be loaded.
:return: The content of the file as a string.
"""
with open(file_path) as file:
return file.read()
def format_permalink(s: str) -> str:
"""
Formats a given string into a permalink.
:param s: The string to be formatted into a permalink.
:return: The formatted permalink as a string.
"""
end_index = s.find('(')
if end_index != -1:
trimmed_string = s[:end_index].strip()
else:
trimmed_string = s.strip()
formatted_string = trimmed_string.lower().replace(' ', '-')
return formatted_string
def get_artifacts_url(job_id: int, output_filepath: str) -> str:
"""
Generates the url of the path where the artifact will be stored in the job's artifacts .
:param job_id: The job identifier used to construct the URL.
:param output_filepath: The path to the output file.
:return: The modified URL pointing to the job's artifacts.
"""
url = CI_PAGES_URL.replace('esp-idf', '-/esp-idf')
return f'{url}/-/jobs/{job_id}/artifacts/{output_filepath}'
def get_repository_file_url(file_path: str) -> str:
"""
Generates the url of the file path inside the repository.
:param file_path: The file path where the file is stored.
:return: The modified URL pointing to the file's path in the repository.
"""
return f'{CI_PROJECT_URL}/-/raw/{CI_MERGE_REQUEST_SOURCE_BRANCH_SHA}/{file_path}'
-4
View File
@@ -8,17 +8,14 @@ tools/ci/check_*.txt
tools/ci/check_copyright_config.yaml
tools/ci/checkout_project_ref.py
tools/ci/ci_fetch_submodule.py
tools/ci/ci_get_mr_info.py
tools/ci/cleanup_ignore_lists.py
tools/ci/configure_ci_environment.sh
tools/ci/deploy_docs.py
tools/ci/dynamic_pipelines/**/*
tools/ci/envsubst.py
tools/ci/executable-list.txt
tools/ci/fix_empty_prototypes.sh
tools/ci/generate_rules.py
tools/ci/get-full-sources.sh
tools/ci/get_all_test_results.py
tools/ci/get_supported_examples.sh
tools/ci/gitlab_yaml_linter.py
tools/ci/idf_build_apps_dump_soc_caps.py
@@ -31,7 +28,6 @@ tools/ci/metrics/examples_count/schema.yaml
tools/ci/mirror-submodule-update.sh
tools/ci/multirun_with_pyenv.sh
tools/ci/mypy_ignore_list.txt
tools/ci/previous_stage_job_status.py
tools/ci/push_to_github.sh
tools/ci/python_packages/common_test_methods.py
tools/ci/python_packages/gitlab_api.py
-1
View File
@@ -71,7 +71,6 @@ tools/ci/check_tools_files_patterns.py
tools/ci/check_type_comments.py
tools/ci/checkout_project_ref.py
tools/ci/cleanup_ignore_lists.py
tools/ci/deploy_docs.py
tools/ci/envsubst.py
tools/ci/fix_empty_prototypes.sh
tools/ci/generate_rules.py
+20 -26
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python
#
# SPDX-FileCopyrightText: 2021-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-FileCopyrightText: 2021-2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import argparse
import inspect
@@ -12,22 +12,21 @@ from itertools import product
import yaml
from idf_ci_utils import IDF_PATH
from idf_ci_utils import GitlabYmlConfig
if t.TYPE_CHECKING:
import pygraphviz as pgv
def _list(str_or_list: t.Union[str, t.List]) -> t.List:
def _list(str_or_list: str | list) -> list:
if isinstance(str_or_list, str):
return [str_or_list]
elif isinstance(str_or_list, list):
return str_or_list
else:
raise ValueError('Wrong type: {}. Only supports str or list.'.format(type(str_or_list)))
raise ValueError(f'Wrong type: {type(str_or_list)}. Only supports str or list.')
def _format_nested_dict(_dict: t.Dict[str, t.Dict], f_tuple: t.Tuple[str, ...]) -> t.Dict[str, t.Dict]:
def _format_nested_dict(_dict: dict[str, dict], f_tuple: tuple[str, ...]) -> dict[str, dict]:
res = {}
for k, v in _dict.items():
k = k.split('__')[0]
@@ -41,7 +40,7 @@ def _format_nested_dict(_dict: t.Dict[str, t.Dict], f_tuple: t.Tuple[str, ...])
return res
def _format_nested_list(_list: t.List[str], f_tuple: t.Tuple[str, ...]) -> t.List[str]:
def _format_nested_list(_list: list[str], f_tuple: tuple[str, ...]) -> list[str]:
res = []
for item in _list:
if isinstance(item, list):
@@ -90,7 +89,6 @@ class RulesWriter:
self.cfg = self.expand_matrices()
self.rules = self.expand_rules()
self.yml_config = GitlabYmlConfig()
self.graph = None
def expand_matrices(self): # type: () -> dict
@@ -107,11 +105,11 @@ class RulesWriter:
deploy = v.get('deploy')
if deploy:
for item in _list(deploy):
res['{}-{}'.format(k, item)] = v
res[f'{k}-{item}'] = v
return res
@staticmethod
def _expand_matrix(name: str, cfg: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
def _expand_matrix(name: str, cfg: dict[str, t.Any]) -> dict[str, t.Any]:
"""
Expand matrix into multi keys
:param cfg: single rule dict
@@ -129,7 +127,7 @@ class RulesWriter:
res.update(_format_nested_dict(default, comb))
return res
def expand_rules(self) -> t.Dict[str, t.Dict[str, t.List[str]]]:
def expand_rules(self) -> dict[str, dict[str, list[str]]]:
res = defaultdict(lambda: defaultdict(set)) # type: dict[str, dict[str, set]]
for k, v in self.cfg.items():
if not v:
@@ -155,8 +153,8 @@ class RulesWriter:
if 'patterns' in v:
for _pat in _list(v['patterns']):
# Patterns must be pre-defined
if '.patterns-{}'.format(_pat) not in self.rules_cfg:
print('WARNING: pattern {} not exists'.format(_pat))
if f'.patterns-{_pat}' not in self.rules_cfg:
print(f'WARNING: pattern {_pat} not exists')
continue
res[item]['patterns'].add(_pat)
@@ -195,14 +193,10 @@ class RulesWriter:
if k.startswith('pattern'):
continue
if '.rules:' + k not in self.yml_config.used_templates:
print(f'WARNING: unused rule: {k}, skipping...')
continue
res.append(self.RULES_TEMPLATE.format(k, self._format_rule(k, v)))
return '\n\n'.join(res)
def _format_rule(self, name: str, cfg: t.Dict[str, t.Any]) -> str:
def _format_rule(self, name: str, cfg: dict[str, t.Any]) -> str:
_rules = [self.RULE_REVERT_BRANCH]
if name.endswith('-production'):
_rules.append(self.RULE_PROTECTED_PUSH)
@@ -216,21 +210,21 @@ class RulesWriter:
if f'.{specific_rule}' in self.rules_cfg:
_rules.append(self.SPECIFIC_RULE_TEMPLATE.format(specific_rule))
else:
print('WARNING: specific_rule {} not exists'.format(specific_rule))
print(f'WARNING: specific_rule {specific_rule} not exists')
for label in cfg['labels']:
_rules.append(self.RULE_LABEL_TEMPLATE.format(label))
for pattern in cfg['patterns']:
if '.patterns-{}'.format(pattern) in self.rules_cfg:
if f'.patterns-{pattern}' in self.rules_cfg:
_rules.append(self.RULE_PATTERN_TEMPLATE.format(pattern))
else:
print('WARNING: pattern {} not exists'.format(pattern))
print(f'WARNING: pattern {pattern} not exists')
return '\n'.join(_rules)
def update_rules_yml(self) -> bool:
with open(self.rules_yml) as fr:
file_str = fr.read()
auto_generate_str = '\n{}\n\n{}\n'.format(self.new_labels_str(), self.new_rules_str())
auto_generate_str = f'\n{self.new_labels_str()}\n\n{self.new_rules_str()}\n'
rest, marker, old = file_str.partition(self.AUTO_GENERATE_MARKER)
if old == auto_generate_str:
return False
@@ -246,7 +240,7 @@ PATTERN_COLOR = 'cyan'
RULE_COLOR = 'blue'
def build_graph(rules_dict: t.Dict[str, t.Dict[str, t.List[str]]]) -> 'pgv.AGraph':
def build_graph(rules_dict: dict[str, dict[str, list[str]]]) -> 'pgv.AGraph':
from pygraphviz import pgv
graph = pgv.AGraph(directed=True, rankdir='LR', concentrate=True)
@@ -263,13 +257,13 @@ def build_graph(rules_dict: t.Dict[str, t.Dict[str, t.List[str]]]) -> 'pgv.AGrap
labels = v.get('labels')
if labels:
for _label in labels:
graph.add_node('label:{}'.format(_label), color=LABEL_COLOR)
graph.add_edge('label:{}'.format(_label), k, color=LABEL_COLOR)
graph.add_node(f'label:{_label}', color=LABEL_COLOR)
graph.add_edge(f'label:{_label}', k, color=LABEL_COLOR)
patterns = v.get('patterns')
if patterns:
for _pat in patterns:
graph.add_node('pattern:{}'.format(_pat), color=PATTERN_COLOR)
graph.add_edge('pattern:{}'.format(_pat), k, color=PATTERN_COLOR)
graph.add_node(f'pattern:{_pat}', color=PATTERN_COLOR)
graph.add_edge(f'pattern:{_pat}', k, color=PATTERN_COLOR)
return graph
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env python
#
# SPDX-FileCopyrightText: 2021-2022 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
'''
Download artifacts from all test jobs
CI_PROJECT_DIR
└── TEST_RESULTS
├── <job_id>
│ └── XUNIT_RESULT.xml
├── <job_id>
│ └── XUNIT_RESULT.xml
├── <job_id>
│ └── TEST_LOGS
'''
import argparse
import json
import os
import shutil
import gitlab_api
CI_PROJECT_ID = int(os.getenv('CI_PROJECT_ID', ''))
CI_PIPELINE_ID = int(os.getenv('CI_PIPELINE_ID', ''))
IDF_PATH = os.getenv('IDF_PATH', '.')
TEST_RESULT_PATH = os.path.join(IDF_PATH, 'TEST_RESULTS')
def download_all_test_results(result_path: str, include_retried: bool = False) -> None:
if os.path.exists(result_path):
shutil.rmtree(result_path)
os.makedirs(result_path, exist_ok=True)
gitlab_inst = gitlab_api.Gitlab(CI_PROJECT_ID)
pipelines = gitlab_inst.project.pipelines.get(CI_PIPELINE_ID)
_include_retried = 'true' if include_retried else 'false'
jobs = pipelines.jobs.list(all=True, per_page=100, include_retried=_include_retried)
job_info_list = []
for job in jobs:
if job.stage in ['target_test', 'host_test']:
log_path = ''
if job.status not in ['success', 'failed']:
print('Job {}({}) is not finished'.format(job.id, job.name))
elif not hasattr(job, 'artifacts_file'):
print('Job {}({}) has no artifacts.'.format(job.id, job.name))
else:
log_path = os.path.join(result_path, 'job_{}'.format(job.id))
print('Downloading artifacts from: {}'.format(job.name))
os.makedirs(log_path, exist_ok=True)
gitlab_inst.download_artifacts(job.id, log_path)
job_info = {
'id': job.id,
'name': job.name,
'tag_list': job.tag_list,
'status': job.status,
'stage': job.stage,
'web_url': job.web_url,
'commit_url': job.commit['web_url'],
'log_path': log_path,
}
job_info_list.append(job_info)
with open(os.path.join(result_path, 'index.json'), 'w') as f:
f.write(json.dumps({'jobs': job_info_list}, indent=1, sort_keys=True))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('--path', '-p', default=TEST_RESULT_PATH, help='Path to save result files.')
parser.add_argument('--include_retried', action='store_true', help='Including retried jobs.')
args = parser.parse_args()
download_all_test_results(args.path, args.include_retried)
if __name__ == '__main__':
main()
+6 -30
View File
@@ -1,27 +1,27 @@
#!/usr/bin/env python
# SPDX-FileCopyrightText: 2023-2024 Espressif Systems (Shanghai) CO LTD
# SPDX-FileCopyrightText: 2023-2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""
Check gitlab ci yaml files
"""
import argparse
import os
import typing as t
from functools import cached_property
from idf_ci_utils import get_submodule_dirs
from idf_ci_utils import GitlabYmlConfig
from idf_ci_utils import IDF_PATH
from idf_ci_utils import GitlabYmlConfig
from idf_ci_utils import get_submodule_dirs
class YmlLinter:
def __init__(self, yml_config: GitlabYmlConfig) -> None:
self.yml_config = yml_config
self._errors: t.List[str] = []
self._errors: list[str] = []
@cached_property
def lint_functions(self) -> t.List[str]:
def lint_functions(self) -> list[str]:
funcs = []
for func in dir(self):
if func.startswith('_lint_'):
@@ -43,17 +43,6 @@ class YmlLinter:
exit(exit_code)
# name it like _1_ to make it run first
def _lint_1_yml_parser(self) -> None:
for k, v in self.yml_config.config.items():
if (
k not in self.yml_config.global_keys
and k not in self.yml_config.anchors
and k not in self.yml_config.templates
and k not in self.yml_config.jobs
):
raise SystemExit(f'Parser incorrect. Key {k} not in global keys, anchors, templates, or jobs')
def _lint_default_values_artifacts(self) -> None:
defaults_artifacts = self.yml_config.default.get('artifacts', {})
@@ -79,19 +68,6 @@ class YmlLinter:
for item in undefined_patterns:
self._errors.append(f'undefined pattern {item}. Please add {item} to .patterns-submodule')
def _lint_gitlab_yml_templates(self) -> None:
unused_templates = self.yml_config.templates.keys() - self.yml_config.used_templates
for item in unused_templates:
# known unused ones
if item not in [
'.before_script:fetch:target_test', # used in dynamic pipeline
]:
self._errors.append(f'Unused template: {item}, please remove it')
undefined_templates = self.yml_config.used_templates - self.yml_config.templates.keys()
for item in undefined_templates:
self._errors.append(f'Undefined template: {item}')
def _lint_dependencies_and_needs(self) -> None:
"""
Use `dependencies: []` together with `needs: []` could cause missing artifacts issue.
-103
View File
@@ -5,13 +5,10 @@ import subprocess
import sys
import typing as t
from dynamic_pipelines.constants import BINARY_SIZE_METRIC_NAME
from idf_build_apps import App
from idf_build_apps import CMakeApp
from idf_build_apps.constants import BuildStatus
from idf_build_apps.utils import rmdir
from idf_ci_utils import APP_EXTRA_S3_ARTIFACT_TYPE
from idf_ci_utils import idf_relpath
if t.TYPE_CHECKING:
pass
@@ -65,103 +62,3 @@ class IdfCMakeApp(CMakeApp):
self.build_path,
exclude_file_patterns=['build_log.txt', 'size*.json'],
)
class Metrics:
"""
Represents a metric and its values for source, target, and the differences.
"""
def __init__(
self,
source_value: float | None = None,
target_value: float | None = None,
difference: float | None = None,
difference_percentage: float | None = None,
) -> None:
self.source_value = source_value or 0.0
self.target_value = target_value or 0.0
self.difference = difference or 0.0
self.difference_percentage = difference_percentage or 0.0
def to_dict(self) -> dict[str, t.Any]:
"""
Converts the Metrics object to a dictionary.
"""
return {
'source_value': self.source_value,
'target_value': self.target_value,
'difference': self.difference,
'difference_percentage': self.difference_percentage,
}
class AppWithMetricsInfo(IdfCMakeApp):
metrics: dict[str, Metrics]
is_new_app: bool
def __init__(self, **kwargs: t.Any) -> None:
super().__init__(**kwargs)
self.metrics = {metric_name: metric_data for metric_name, metric_data in kwargs.get('metrics', {}).items()}
self.is_new_app = kwargs.get('is_new_app', False)
class Config:
arbitrary_types_allowed = True
def enrich_apps_with_metrics_info(
app_metrics_info_map: dict[str, dict[str, t.Any]], apps: list[App]
) -> list[AppWithMetricsInfo]:
def _get_full_attributes(obj: App) -> dict[str, t.Any]:
"""
Retrieves all attributes of an object, including properties and computed fields.
"""
attributes: dict[str, t.Any] = obj.__dict__.copy()
for attr in dir(obj):
if not attr.startswith('_'): # Skip private/internal attributes
try:
value = getattr(obj, attr)
# Include only if it's not already in __dict__
if attr not in attributes:
attributes[attr] = value
except Exception:
# Skip attributes that raise exceptions (e.g., methods needing args)
pass
return attributes
default_metrics_structure = {
BINARY_SIZE_METRIC_NAME: Metrics(
source_value=0,
target_value=0,
difference=0,
difference_percentage=0.0,
),
}
apps_with_metrics_info = []
for app in apps:
app.app_dir = idf_relpath(app.app_dir)
key = f'{app.app_dir}_{app.config_name}_{app.target}'
app_attributes = _get_full_attributes(app)
metrics = {metric_name: default_metric for metric_name, default_metric in default_metrics_structure.items()}
is_new_app = False
if key in app_metrics_info_map:
info = app_metrics_info_map[key]
for metric_name, metric_data in info.get('metrics', {}).items():
metrics[metric_name] = Metrics(
source_value=metric_data.get('source_value', 0),
target_value=metric_data.get('target_value', 0),
difference=metric_data.get('difference', 0),
difference_percentage=metric_data.get('difference_percentage', 0.0),
)
is_new_app = info.get('is_new_app', False)
app_attributes.update({'metrics': metrics, 'is_new_app': is_new_app})
apps_with_metrics_info.append(AppWithMetricsInfo(**app_attributes))
return apps_with_metrics_info
+94 -112
View File
@@ -4,7 +4,6 @@
# some CI related util functions
import logging
import os
import re
import subprocess
import sys
import typing as t
@@ -125,35 +124,106 @@ class GitlabYmlConfig:
# avoid unused import in other pre-commit hooks
import yaml
all_config = dict()
root_yml = yaml.load(open(root_yml_filepath), Loader=yaml.FullLoader)
# expanding "include"
for item in root_yml.pop('include', []) or []:
if isinstance(item, dict):
if 'project' in item:
continue
elif 'local' in item:
item = item['local']
else:
continue
all_config.update(yaml.load(open(os.path.join(IDF_PATH, item)), Loader=yaml.FullLoader))
merged_yaml = self._compile_via_gitlab_api(root_yml_filepath)
all_config = yaml.load(merged_yaml, Loader=yaml.FullLoader) or {}
if 'default' in all_config:
self._defaults = all_config.pop('default')
self._config = all_config
# anchor is the string that will be reused in templates
self._anchor_keys: set[str] = set()
# template is a dict that will be extended
self._template_keys: set[str] = set()
self._used_template_keys: set[str] = set() # tracing the used templates
# job is a dict that will be executed
self._job_keys: set[str] = set()
def _inline_local_includes(self, root_yml_filepath: str) -> str:
"""
Recursively resolve `include: local` entries straight from disk (so uncommitted local
changes are always picked up -- CI runners also work off a disk checkout, so there's no
need to fetch a ref remotely via `ci_lint`'s `content_ref`/`ref`/`dry_run_ref` GET
params). `include: project` entries (including ones nested inside local files, e.g.
`.gitlab/ci/common.yml` including `templates/idf/common-scripts.yml`) are collected and
left in the final `include:` list, since those files live in another GitLab project and
can only be resolved remotely.
self.expand_extends()
A dedicated Loader/Dumper pair round-trips `!reference` tags as a marker list subclass,
since GitLab CI uses `!reference` which plain YAML doesn't know, and we need to parse
(to merge dicts, not just string-concat) then re-dump losslessly.
:param root_yml_filepath: path to the local root yml file to compile
:return: yml content with local includes resolved and merged
"""
import yaml
class _Loader(yaml.FullLoader):
pass
class _Dumper(yaml.Dumper):
pass
class _Reference(list):
pass
_Loader.add_constructor(
'!reference', lambda loader, node: _Reference(loader.construct_sequence(t.cast(yaml.SequenceNode, node)))
)
_Dumper.add_representer(_Reference, lambda dumper, data: dumper.represent_sequence('!reference', list(data)))
def resolve(yml_filepath: str) -> tuple[dict, list]:
with open(yml_filepath) as fr:
data = yaml.load(fr, Loader=_Loader) or {}
includes = to_list(data.pop('include', None))
merged: dict = {}
remaining_project_includes: list = []
for item in includes:
if isinstance(item, dict):
if 'project' in item:
remaining_project_includes.append(item)
continue
elif 'local' in item:
local_path = item['local']
else:
continue
elif isinstance(item, str):
local_path = item
else:
continue
sub_merged, sub_remaining = resolve(os.path.join(IDF_PATH, local_path.lstrip('/')))
merged.update(sub_merged)
remaining_project_includes.extend(sub_remaining)
# this file's own top-level keys override whatever its includes defined
merged.update(data)
return merged, remaining_project_includes
merged_config, project_includes = resolve(root_yml_filepath)
if project_includes:
merged_config['include'] = project_includes
return yaml.dump(merged_config, Dumper=_Dumper, sort_keys=False) # type: ignore
def _compile_via_gitlab_api(self, root_yml_filepath: str) -> str:
"""
Call the GitLab CI Lint API to get the fully compiled (all `include`s resolved) yml,
same as what `glab ci config compile` does. This replaces the old recursive local-only
parsing, since the project now includes configs from other projects as well.
:param root_yml_filepath: path to the local root yml file to compile
:return: merged (fully resolved) yml content as a string
"""
sys.path.insert(0, os.path.join(IDF_PATH, 'tools', 'ci', 'python_packages'))
import gitlab_api
content = self._inline_local_includes(root_yml_filepath)
gitlab_inst = gitlab_api.Gitlab()
project_id = os.getenv('CI_PROJECT_ID') or gitlab_inst.get_project_id('esp-idf', namespace='espressif')
project = gitlab_inst.gitlab_inst.projects.get(project_id, lazy=True)
lint_result = project.ci_lint.create({'content': content})
if not lint_result.valid:
raise RuntimeError(f'Failed to compile {root_yml_filepath} via GitLab CI Lint API: {lint_result.errors}')
return lint_result.merged_yaml # type: ignore
@property
def default(self) -> dict[str, t.Any]:
@@ -167,97 +237,9 @@ class GitlabYmlConfig:
def global_keys(self) -> list[str]:
return ['default', 'include', 'workflow', 'variables', 'stages']
@cached_property
def anchors(self) -> dict[str, t.Any]:
return {k: v for k, v in self.config.items() if k in self._anchor_keys}
@cached_property
def jobs(self) -> dict[str, t.Any]:
return {k: v for k, v in self.config.items() if k in self._job_keys}
@cached_property
def templates(self) -> dict[str, t.Any]:
return {k: v for k, v in self.config.items() if k in self._template_keys}
@cached_property
def used_templates(self) -> set[str]:
return self._used_template_keys
def expand_extends(self) -> None:
"""
expand the `extends` key in-place.
"""
for k, v in self.config.items():
if k in self.global_keys:
continue
if isinstance(v, str | list):
self._anchor_keys.add(k)
elif k.startswith('.if-'):
self._anchor_keys.add(k)
elif k.startswith('.'):
self._template_keys.add(k)
elif isinstance(v, dict):
self._job_keys.add(k)
else:
raise ValueError(f'Unknown type for key {k} with value {v}')
# no need to expand anchor
# expand template first
for k in self._template_keys:
self._expand_extends(k)
# expand job
for k in self._job_keys:
self._expand_extends(k)
def _merge_dict(self, d1: dict[str, t.Any], d2: dict[str, t.Any]) -> t.Any:
for k, v in d2.items():
if k in d1:
if isinstance(v, dict) and isinstance(d1[k], dict):
d1[k] = self._merge_dict(d1[k], v)
else:
d1[k] = v
else:
d1[k] = v
return d1
def _expand_extends(self, name: str) -> dict[str, t.Any]:
extends = to_list(self.config[name].pop('extends', None))
if not extends:
return self.config[name] # type: ignore
original_d = self.config[name].copy()
d = {}
while extends:
self._used_template_keys.update(extends) # for tracking
for i in extends:
d.update(self._expand_extends(i))
extends = to_list(self.config[name].pop('extends', None))
self.config[name] = self._merge_dict(d, original_d)
return self.config[name] # type: ignore
def sanitize_job_name(name: str) -> str:
"""
Sanitize the job name from CI_JOB_NAME
- for job with `parallel: int` set, the `CI_JOB_NAME` would be `job_name index/total`, like `foo 1/3`
- for job with `parallel: matrix` set, the `CI_JOB_NAME` would be `job_name: [var1, var2]`, like `foo: [a, b]`
We consider
- the jobs generated by `parallel: int` as the same job, i.e., we remove the index/total part.
- the jobs generated by `parallel: matrix` as different jobs, so we keep the matrix part.
:param name: job name
:return: sanitized job name
"""
return re.sub(r' \d+/\d+', '', name)
return {k: v for k, v in self.config.items() if not k.startswith('.') and k not in self.global_keys}
def idf_relpath(p: str) -> str:
-15
View File
@@ -149,21 +149,6 @@ class IdfLocalPlugin:
return 'linux' in targets
@pytest.hookimpl(trylast=True)
def pytest_generate_tests(self, metafunc: Metafunc) -> None:
if 'embedded_services' not in metafunc.fixturenames:
return
if self._has_parametrized_arg(metafunc, 'embedded_services'):
return
if metafunc.definition.get_closest_marker('qemu') is not None:
metafunc.parametrize('embedded_services', ['idf,qemu'], indirect=True)
return
if self._is_linux_target_run(metafunc.config):
metafunc.parametrize('embedded_services', ['idf'], indirect=True)
@pytest.hookimpl(wrapper=True)
def pytest_collection_modifyitems(self, config: Config, items: list[Function]) -> t.Generator[None, None, None]:
yield # throw it back to idf-ci
-36
View File
@@ -1,36 +0,0 @@
# SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import argparse
import os
import sys
import requests
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--stage', type=str, help='Stage name for check jobs status')
args = parser.parse_args()
GITLAB_TOKEN = os.getenv('ESPCI_TOKEN')
GITLAB_HTTP_SERVER = os.getenv('GITLAB_HTTP_SERVER')
CI_PROJECT_ID = os.getenv('CI_PROJECT_ID')
CI_PIPELINE_ID = os.getenv('CI_PIPELINE_ID')
api_path = f'projects/{CI_PROJECT_ID}/pipelines/{CI_PIPELINE_ID}/jobs?scope[]=failed&per_page=100'
page = 0
while True:
response = requests.get(
f'{GITLAB_HTTP_SERVER}/api/v4/{api_path}&page={page}',
headers={'PRIVATE-TOKEN': GITLAB_TOKEN}
)
jobs = response.json()
if not jobs:
break
for job in jobs:
if job['stage'] == args.stage:
print(f'Jobs from the previous stage {args.stage} should pass; otherwise, this job will fail.')
sys.exit(1)
page += 1
+13 -22
View File
@@ -66,7 +66,6 @@ class Gitlab:
JOB_NAME_PATTERN = re.compile(r'(\w+)(\s+(\d+)/(\d+))?')
DOWNLOAD_ERROR_MAX_RETRIES = 3
DEFAULT_BUILD_CHILD_PIPELINE_NAME = 'Build Child Pipeline'
def __init__(self, project_id: int | str | None = None):
config_data_from_env = os.getenv('PYTHON_GITLAB_CONFIG')
@@ -116,7 +115,7 @@ class Gitlab:
:param namespace: namespace to match when we have multiple project with same name
:return: project ID
"""
projects = self.gitlab_inst.projects.list(search=name)
projects = self.gitlab_inst.projects.list(search=name, get_all=True)
res = []
for project in projects:
if namespace is None:
@@ -297,36 +296,28 @@ class Gitlab:
def get_downstream_pipeline_ids(self, main_pipeline_id: int) -> list[int]:
"""
Retrieve the IDs of all downstream child pipelines for a given main pipeline.
Retrieve the IDs of all downstream child pipelines for a given main pipeline,
recursing through arbitrarily nested child pipelines.
:param main_pipeline_id: The ID of the main pipeline to start the search.
:return: A list of IDs of all downstream child pipelines.
:return: A list of IDs of all downstream child pipelines (all levels).
"""
bridge_pipeline_ids = []
child_pipeline_ids = []
child_pipeline_ids: list[int] = []
main_pipeline_bridges = self.project.pipelines.get(main_pipeline_id).bridges.list()
for bridge in main_pipeline_bridges:
pipeline_bridges = self.project.pipelines.get(main_pipeline_id).bridges.list()
for bridge in pipeline_bridges:
downstream_pipeline = bridge.attributes.get('downstream_pipeline')
if not downstream_pipeline:
continue
bridge_pipeline_ids.append(downstream_pipeline['id'])
for bridge_pipeline_id in bridge_pipeline_ids:
child_pipeline_ids.append(bridge_pipeline_id)
bridge_pipeline = self.project.pipelines.get(bridge_pipeline_id)
if not bridge_pipeline.name == self.DEFAULT_BUILD_CHILD_PIPELINE_NAME:
downstream_pipeline_id = downstream_pipeline.get('id')
if downstream_pipeline_id is None:
continue
child_bridges = bridge_pipeline.bridges.list()
for child_bridge in child_bridges:
downstream_child_pipeline = child_bridge.attributes.get('downstream_pipeline')
if not downstream_child_pipeline:
continue
child_pipeline_ids.append(downstream_child_pipeline.get('id'))
child_pipeline_ids.append(downstream_pipeline_id)
# recurse to collect further nested (grandchild+) pipelines
child_pipeline_ids.extend(self.get_downstream_pipeline_ids(downstream_pipeline_id))
return [pid for pid in child_pipeline_ids if pid is not None]
return child_pipeline_ids
def retry_failed_jobs(self, pipeline_id: int, retry_allowed_failures: bool = False) -> list[int]:
"""
-35
View File
@@ -164,38 +164,3 @@ function join_by {
printf %s "$f" "${@/#/$d}"
fi
}
function is_based_on_commits() {
# This function would accept space-separated args as multiple commits.
# The return value would be 0 if current HEAD is based on any of the specified commits.
#
# In our CI, we use environment variable $REQUIRED_ANCESTOR_COMMITS to declare the ancestor commits.
# Please remember to set one commit for each release branch.
commits=$*
if [[ -z $commits ]]; then
info "Not specifying commits that branches should be based on, skipping check..."
return 0
fi
commits_str="$(join_by " or " $commits)" # no doublequotes here, passing array
info "Checking if current branch is based on $commits_str..."
for i in $commits; do
if git merge-base --is-ancestor "$i" HEAD >/dev/null 2>&1; then
info "Current branch is based on $i"
return 0
else
info "Current branch is not based on $i"
fi
done
error "The base commit of your branch is too old."
error "The branch should be more recent than either of the following commits:"
error " $commits_str"
error "To fix the issue:"
error " - If your merge request is 'Draft', or has conflicts with the target branch, rebase it to the latest master or release branch"
error " - Otherwise, simply run a new pipeline."
return 1
}
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_linux_freertos_SMP(dut: Dut) -> None:
dut.expect_exact('Press ENTER to see the list of tests.')
@@ -35,6 +35,15 @@ def configs_for_app(app_path: str, configs: Sequence[str]) -> list[tuple[str, st
return [(app_path, config) for config in configs]
def configs_with_esp32s2_xfail(
configs: Sequence[tuple[str, str]], reason: str
) -> list[tuple[str, str] | tuple[str, str, Any]]:
return [
(config, target, pytest.mark.xfail(reason=reason, run=False)) if target == 'esp32s2' else (config, target)
for config, target in configs
]
CONFIGS = configs_for_app(PANIC_APP, ['gdbstub', 'panic'])
CONFIGS_UBSAN = configs_for_app(PANIC_APP, ['gdbstub', 'panic'])
@@ -722,7 +731,7 @@ def test_dcache_read_violation(dut: PanicTestDut, test_func_name: str) -> None:
# TODO: IDF-6820: ESP32-S2 -> Fix multiple panic reasons in different runs
@pytest.mark.generic
@pytest.mark.xfail(targets=['esp32s2'], reason='Incorrect panic reason may be observed', run=False)
@pytest.mark.xfail(reason='Incorrect panic reason may be observed', run=False)
@idf_parametrize('config, target', CONFIGS_MEMPROT_DCACHE, indirect=['config', 'target'])
def test_dcache_write_violation(dut: PanicTestDut, test_func_name: str) -> None:
dut.run_test_func(test_func_name)
@@ -862,11 +871,14 @@ def iram_reg4_write_violation(dut: PanicTestDut, test_func_name: str) -> None:
dut.expect_cpu_reset()
# TODO: IDF-6820: ESP32-S2 -> Fix incorrect panic reason: Unhandled debug exception
@pytest.mark.generic
@pytest.mark.xfail(targets=['esp32s2'], reason='Incorrect panic reason may be observed', run=False)
@pytest.mark.temp_skip_ci(targets=['esp32h21'], reason='lack of runners')
@idf_parametrize('config, target', CONFIGS_MEMPROT_IDRAM, indirect=['config', 'target'])
# TODO: IDF-6820: ESP32-S2 -> Fix incorrect panic reason: Unhandled debug exception
@idf_parametrize(
'config,target,markers',
configs_with_esp32s2_xfail(CONFIGS_MEMPROT_IDRAM, 'Incorrect panic reason may be observed'),
indirect=['config', 'target'],
)
def test_iram_reg4_write_violation(dut: PanicTestDut, test_func_name: str) -> None:
iram_reg_write_violation(dut, test_func_name)
@@ -894,11 +906,14 @@ def dram_reg1_execute_violation(dut: PanicTestDut, test_func_name: str) -> None:
dut.expect_cpu_reset()
# TODO: IDF-6820: ESP32-S2 -> Fix multiple panic reasons in different runs
@pytest.mark.generic
@pytest.mark.xfail(targets=['esp32s2'], reason='Multiple panic reasons for the same test may surface', run=False)
@pytest.mark.temp_skip_ci(targets=['esp32h21'], reason='lack of runners')
@idf_parametrize('config, target', CONFIGS_MEMPROT_IDRAM, indirect=['config', 'target'])
# TODO: IDF-6820: ESP32-S2 -> Fix multiple panic reasons in different runs
@idf_parametrize(
'config,target,markers',
configs_with_esp32s2_xfail(CONFIGS_MEMPROT_IDRAM, 'Multiple panic reasons for the same test may surface'),
indirect=['config', 'target'],
)
def test_dram_reg1_execute_violation(dut: PanicTestDut, test_func_name: str) -> None:
dram_reg1_execute_violation(dut, test_func_name)
@@ -925,11 +940,14 @@ def dram_reg2_execute_violation(dut: PanicTestDut, test_func_name: str) -> None:
dut.expect_cpu_reset()
# TODO: IDF-6820: ESP32-S2 -> Fix multiple panic reasons in different runs
@pytest.mark.generic
@pytest.mark.xfail(targets=['esp32s2'], reason='Multiple panic reasons for the same test may surface', run=False)
@pytest.mark.temp_skip_ci(targets=['esp32h21'], reason='lack of runners')
@idf_parametrize('config, target', CONFIGS_MEMPROT_IDRAM, indirect=['config', 'target'])
# TODO: IDF-6820: ESP32-S2 -> Fix multiple panic reasons in different runs
@idf_parametrize(
'config,target,markers',
configs_with_esp32s2_xfail(CONFIGS_MEMPROT_IDRAM, 'Multiple panic reasons for the same test may surface'),
indirect=['config', 'target'],
)
def test_dram_reg2_execute_violation(dut: PanicTestDut, test_func_name: str) -> None:
dram_reg2_execute_violation(dut, test_func_name)
@@ -974,11 +992,14 @@ def test_rtc_fast_reg2_execute_violation(dut: PanicTestDut, test_func_name: str)
dut.expect_cpu_reset()
# TODO: IDF-6820: ESP32-S2 -> Fix multiple panic reasons in different runs
@pytest.mark.generic
@pytest.mark.xfail(targets=['esp32s2'], reason='Multiple panic reasons for the same test may surface', run=False)
@pytest.mark.temp_skip_ci(targets=['esp32h21'], reason='lack of runners')
@idf_parametrize('config, target', CONFIGS_MEMPROT_RTC_FAST_MEM, indirect=['config', 'target'])
# TODO: IDF-6820: ESP32-S2 -> Fix multiple panic reasons in different runs
@idf_parametrize(
'config,target,markers',
configs_with_esp32s2_xfail(CONFIGS_MEMPROT_RTC_FAST_MEM, 'Multiple panic reasons for the same test may surface'),
indirect=['config', 'target'],
)
def test_rtc_fast_reg3_execute_violation(dut: PanicTestDut, test_func_name: str) -> None:
dut.run_test_func(test_func_name)