mirror of
https://github.com/espressif/esp-idf.git
synced 2026-04-27 19:13:21 +00:00
c360d8be98
and mark it as root dependency
371 lines
14 KiB
Python
371 lines
14 KiB
Python
# SPDX-FileCopyrightText: 2022-2026 Espressif Systems (Shanghai) CO LTD
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import typing
|
|
from pathlib import Path
|
|
from tempfile import mkdtemp
|
|
|
|
import pytest
|
|
from _pytest.config import Config
|
|
from _pytest.fixtures import FixtureRequest
|
|
from _pytest.main import Session
|
|
from _pytest.nodes import Item
|
|
from test_build_system_helpers import EXT_IDF_PATH
|
|
from test_build_system_helpers import EnvDict
|
|
from test_build_system_helpers import IdfPyFunc
|
|
from test_build_system_helpers import get_idf_build_env
|
|
from test_build_system_helpers import run_idf_py
|
|
|
|
|
|
def _get_git_submodule_paths(repo_path: Path) -> list[str]:
|
|
"""Get list of submodule paths from .gitmodules file."""
|
|
gitmodules = repo_path / '.gitmodules'
|
|
if not gitmodules.exists():
|
|
return []
|
|
|
|
submodule_paths = []
|
|
with open(gitmodules, encoding='utf-8') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line.startswith('path = '):
|
|
submodule_paths.append(line[7:]) # Remove 'path = ' prefix
|
|
return submodule_paths
|
|
|
|
|
|
def _create_idf_copy_via_worktree(path_from: Path, path_to: Path) -> str:
|
|
"""
|
|
Create IDF copy using git worktree (fast) + copying submodule directories.
|
|
|
|
Git worktree creates a fast checkout of tracked files, but submodules
|
|
appear as empty directories. We copy submodule content from the source
|
|
repo (which has them already checked out) instead of running git submodule
|
|
update (which can fail due to auth issues on CI).
|
|
"""
|
|
import uuid
|
|
|
|
timestamp = datetime.datetime.now().strftime('%H%M%S')
|
|
branch_name = f'test-worktree-{timestamp}-{uuid.uuid4().hex[:8]}'
|
|
|
|
logging.debug(f'creating git worktree at {path_to} (branch: {branch_name})')
|
|
subprocess.run(
|
|
['git', 'worktree', 'add', '-b', branch_name, str(path_to)], cwd=path_from, capture_output=True, check=True
|
|
)
|
|
|
|
# Copy submodule directories from source (they're already checked out there)
|
|
submodule_paths = _get_git_submodule_paths(path_from)
|
|
for submodule_rel_path in submodule_paths:
|
|
src_submodule = path_from / submodule_rel_path
|
|
dst_submodule = path_to / submodule_rel_path
|
|
|
|
# Only copy if source submodule exists and has content
|
|
if src_submodule.exists() and any(src_submodule.iterdir()):
|
|
logging.debug(f'copying submodule {submodule_rel_path}')
|
|
# Remove the empty directory created by worktree
|
|
if dst_submodule.exists():
|
|
shutil.rmtree(dst_submodule, ignore_errors=True)
|
|
# Copy the submodule content
|
|
shutil.copytree(src_submodule, dst_submodule, symlinks=True, ignore=shutil.ignore_patterns('.git'))
|
|
|
|
return branch_name
|
|
|
|
|
|
def _cleanup_worktree(path_from: Path, path_to: Path, branch_name: str) -> None:
|
|
"""Remove git worktree and its temporary branch."""
|
|
logging.debug(f'removing git worktree at {path_to}')
|
|
# Remove the worktree
|
|
subprocess.run(
|
|
['git', 'worktree', 'remove', '--force', str(path_to)],
|
|
cwd=path_from,
|
|
check=False, # Don't fail if already removed
|
|
)
|
|
# Delete the temporary branch
|
|
subprocess.run(
|
|
['git', 'branch', '-D', branch_name],
|
|
cwd=path_from,
|
|
check=False, # Don't fail if branch doesn't exist
|
|
)
|
|
|
|
|
|
def _create_idf_copy_via_shutil(path_from: Path, path_to: Path) -> None:
|
|
"""Create IDF copy using shutil.copytree (slower but always works)."""
|
|
# if the new directory inside the original directory,
|
|
# make sure not to go into recursion.
|
|
ignore = shutil.ignore_patterns(
|
|
path_to.name,
|
|
# also ignore the build directories which may be quite large
|
|
# plus ignore .git since it is causing trouble when removing on Windows
|
|
'**/build',
|
|
'.git',
|
|
)
|
|
logging.debug(f'copying {path_from} to {path_to} (shutil.copytree)')
|
|
shutil.copytree(path_from, path_to, ignore=ignore, symlinks=True)
|
|
|
|
|
|
# Pytest hook used to check if the test has passed or failed, from a fixture.
|
|
# Based on https://docs.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures
|
|
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
def pytest_runtest_makereport(item: typing.Any, call: typing.Any) -> typing.Generator[None, pytest.TestReport, None]: # pylint: disable=unused-argument
|
|
outcome = yield # Execute all other hooks to obtain the report object
|
|
report = outcome.get_result()
|
|
if report.when == 'call' and report.passed:
|
|
# set an attribute which can be checked using 'should_clean_test_dir' function below
|
|
setattr(item, 'passed', True)
|
|
|
|
|
|
def should_clean_test_dir(request: FixtureRequest) -> bool:
|
|
# Only remove the test directory if the test has passed
|
|
return getattr(request.node, 'passed', False) or request.config.getoption('cleanup_idf_copy', False)
|
|
|
|
|
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
parser.addoption(
|
|
'--work-dir',
|
|
action='store',
|
|
default=None,
|
|
help='Directory for temporary files. If not specified, an OS-specific temporary directory will be used.',
|
|
)
|
|
parser.addoption(
|
|
'--cleanup-idf-copy',
|
|
action='store_true',
|
|
help='Always clean up the IDF copy after the test. By default, the copy is cleaned up only if the test passes.',
|
|
)
|
|
parser.addoption(
|
|
'--buildv2',
|
|
action='store_true',
|
|
help='Use the IDF build system v2 project for testing.',
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope='session')
|
|
def _session_work_dir(request: FixtureRequest) -> typing.Generator[tuple[Path, bool], None, None]:
|
|
work_dir = request.config.getoption('--work-dir')
|
|
|
|
if work_dir:
|
|
work_dir = os.path.join(work_dir, datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d_%H-%M-%S'))
|
|
logging.debug(f'using work directory: {work_dir}')
|
|
os.makedirs(work_dir, exist_ok=True)
|
|
clean_dir = None
|
|
is_temp_dir = False
|
|
else:
|
|
work_dir = mkdtemp()
|
|
logging.debug(f'created temporary work directory: {work_dir}')
|
|
clean_dir = work_dir
|
|
is_temp_dir = True
|
|
|
|
# resolve allows using relative paths with --work-dir option
|
|
yield Path(work_dir).resolve(), is_temp_dir
|
|
|
|
if clean_dir:
|
|
logging.debug(f'cleaning up {clean_dir}')
|
|
shutil.rmtree(clean_dir, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture(name='func_work_dir', autouse=True)
|
|
def work_dir(request: FixtureRequest, _session_work_dir: tuple[Path, bool]) -> typing.Generator[Path, None, None]:
|
|
session_work_dir, is_temp_dir = _session_work_dir
|
|
|
|
if request._pyfuncitem.keywords.get('force_temp_work_dir') and not is_temp_dir:
|
|
work_dir = Path(mkdtemp()).resolve()
|
|
logging.debug('Force using temporary work directory')
|
|
clean_dir = work_dir
|
|
else:
|
|
work_dir = session_work_dir
|
|
clean_dir = None
|
|
|
|
# resolve allows using relative paths with --work-dir option
|
|
yield work_dir
|
|
|
|
if clean_dir:
|
|
logging.debug(f'cleaning up {clean_dir}')
|
|
shutil.rmtree(clean_dir, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_app_copy(func_work_dir: Path, request: FixtureRequest) -> typing.Generator[Path, None, None]:
|
|
# by default, use hello_world app and copy it to a temporary directory with
|
|
# the name resembling that of the test
|
|
if request.config.getoption('buildv2', False):
|
|
copy_from = 'tools/test_build_system/buildv2_test_app'
|
|
else:
|
|
copy_from = 'tools/test_build_system/build_test_app'
|
|
# sanitize test name in case pytest.mark.parametrize was used
|
|
test_name_sanitized = request.node.name.replace('[', '_').replace(']', '')
|
|
copy_to = test_name_sanitized + '_app'
|
|
|
|
# allow overriding source and destination via pytest.mark.test_app_copy()
|
|
mark = request.node.get_closest_marker('test_app_copy')
|
|
if mark:
|
|
copy_from = mark.args[0]
|
|
if len(mark.args) > 1:
|
|
copy_to = mark.args[1]
|
|
|
|
path_from = Path(os.environ['IDF_PATH']) / copy_from
|
|
path_to = func_work_dir / copy_to
|
|
|
|
# if the new directory inside the original directory,
|
|
# make sure not to go into recursion.
|
|
ignore = shutil.ignore_patterns(
|
|
path_to.name,
|
|
# also ignore files which may be present in the work directory
|
|
'build',
|
|
'sdkconfig',
|
|
)
|
|
|
|
logging.debug(f'copying {path_from} to {path_to}')
|
|
shutil.copytree(path_from, path_to, ignore=ignore, symlinks=True)
|
|
|
|
old_cwd = Path.cwd()
|
|
os.chdir(path_to)
|
|
|
|
yield Path(path_to)
|
|
|
|
os.chdir(old_cwd)
|
|
|
|
if should_clean_test_dir(request):
|
|
logging.debug(f'cleaning up work directory after a successful test: {path_to}')
|
|
shutil.rmtree(path_to, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_git_template_app(func_work_dir: Path, request: FixtureRequest) -> typing.Generator[Path, None, None]:
|
|
# sanitize test name in case pytest.mark.parametrize was used
|
|
test_name_sanitized = request.node.name.replace('[', '_').replace(']', '')
|
|
copy_to = test_name_sanitized + '_app'
|
|
path_to = func_work_dir / copy_to
|
|
|
|
logging.debug(f'cloning git-template app to {path_to}')
|
|
path_to.mkdir()
|
|
# No need to clone full repository, just a single master branch
|
|
subprocess.run(
|
|
[
|
|
'git',
|
|
'clone',
|
|
'--single-branch',
|
|
'-b',
|
|
'master',
|
|
'--depth',
|
|
'1',
|
|
'https://github.com/espressif/esp-idf-template.git',
|
|
'.',
|
|
],
|
|
cwd=path_to,
|
|
capture_output=True,
|
|
)
|
|
|
|
old_cwd = Path.cwd()
|
|
os.chdir(path_to)
|
|
|
|
yield Path(path_to)
|
|
|
|
os.chdir(old_cwd)
|
|
|
|
if should_clean_test_dir(request):
|
|
logging.debug(f'cleaning up work directory after a successful test: {path_to}')
|
|
shutil.rmtree(path_to, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def idf_copy(func_work_dir: Path, request: FixtureRequest) -> typing.Generator[Path, None, None]:
|
|
# sanitize test name in case pytest.mark.parametrize was used
|
|
test_name_sanitized = request.node.name.replace('[', '_').replace(']', '')
|
|
copy_to = test_name_sanitized + '_idf'
|
|
# allow overriding the destination via pytest.mark.idf_copy_with_space so the destination contain space
|
|
mark_with_space = request.node.get_closest_marker('idf_copy_with_space')
|
|
if mark_with_space:
|
|
copy_to = test_name_sanitized + ' idf'
|
|
|
|
# allow overriding the destination via pytest.mark.idf_copy()
|
|
mark = request.node.get_closest_marker('idf_copy')
|
|
if mark:
|
|
copy_to = mark.args[0]
|
|
|
|
path_from = Path(EXT_IDF_PATH)
|
|
path_to = func_work_dir / copy_to
|
|
|
|
orig_idf_path = os.environ['IDF_PATH']
|
|
branch_name: str | None = None
|
|
|
|
# Try git worktree first (much faster), fall back to shutil.copytree
|
|
try:
|
|
branch_name = _create_idf_copy_via_worktree(path_from, path_to)
|
|
except (subprocess.CalledProcessError, FileNotFoundError, OSError) as e:
|
|
logging.debug(f'git worktree failed ({e}), falling back to shutil.copytree')
|
|
# Clean up any partial worktree before fallback
|
|
if path_to.exists():
|
|
shutil.rmtree(path_to, ignore_errors=True)
|
|
_create_idf_copy_via_shutil(path_from, path_to)
|
|
|
|
os.environ['IDF_PATH'] = str(path_to)
|
|
|
|
yield Path(path_to)
|
|
|
|
os.environ['IDF_PATH'] = orig_idf_path
|
|
|
|
if should_clean_test_dir(request):
|
|
logging.debug(f'cleaning up work directory after a successful test: {path_to}')
|
|
if branch_name:
|
|
_cleanup_worktree(path_from, path_to, branch_name)
|
|
else:
|
|
shutil.rmtree(path_to, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture(name='default_idf_env')
|
|
def fixture_default_idf_env() -> EnvDict:
|
|
return get_idf_build_env(os.environ['IDF_PATH']) # type: ignore
|
|
|
|
|
|
@pytest.fixture
|
|
def idf_py(default_idf_env: EnvDict) -> IdfPyFunc:
|
|
def result(*args: str, check: bool = True, input_str: str | None = None) -> subprocess.CompletedProcess:
|
|
return run_idf_py(*args, env=default_idf_env, workdir=os.getcwd(), check=check, input_str=input_str) # type: ignore
|
|
|
|
return result
|
|
|
|
|
|
def pytest_collection_modifyitems(session: Session, config: Config, items: list[Item]) -> None:
|
|
buildv2_dir = Path(__file__).parent / 'buildv2'
|
|
is_buildv2 = config.getoption('--buildv2', False)
|
|
|
|
for item in items:
|
|
if is_buildv2:
|
|
marker = item.get_closest_marker('buildv2_skip')
|
|
if marker:
|
|
reason = marker.args[0] if marker.args else 'Skipped as this test is specific to build system v1.'
|
|
item.add_marker(pytest.mark.skip(reason=reason))
|
|
else:
|
|
if buildv2_dir in item.path.parents or item.path == buildv2_dir:
|
|
item.add_marker(pytest.mark.skip(reason='Skipped as build system v2 tests are disabled.'))
|
|
|
|
|
|
def pytest_report_header(config: Config) -> str:
|
|
"""Add a clear header to the terminal output whether buildv1 or buildv2 testing is in progress."""
|
|
if config.getoption('--buildv2'):
|
|
return 'Testing ESP-IDF CMake-based build system v2'
|
|
else:
|
|
return 'Testing ESP-IDF CMake-based build system v1'
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def revert_later(request: FixtureRequest) -> typing.Generator[None, None, None]:
|
|
origin_content_d: dict[str, str] = {}
|
|
|
|
_marker = request.node.get_closest_marker('revert_later')
|
|
if _marker:
|
|
for filename in _marker.args[0]:
|
|
if not os.path.isabs(filename):
|
|
filename = os.path.join(EXT_IDF_PATH, filename)
|
|
|
|
with open(filename, encoding='utf-8') as fr:
|
|
origin_content_d[filename] = fr.read()
|
|
|
|
yield
|
|
|
|
if origin_content_d:
|
|
for filename, content in origin_content_d.items():
|
|
with open(filename, 'w', encoding='utf-8') as fw:
|
|
fw.write(content)
|