diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 12d2482..6fb81bb 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -4,6 +4,7 @@ on: push: branches: [main, dev] pull_request: + workflow_dispatch: jobs: lint: @@ -42,11 +43,11 @@ jobs: - name: Integration Tests (PR smoke subset) if: github.event_name == 'pull_request' - run: python3 -m pytest tests/integration/ -n 4 --dist=loadgroup -m ci --durations=25 --tb=short -q + run: python3 -m pytest tests/integration/ -n 4 --dist=load -m ci --durations=25 --tb=short -q - name: Integration Tests (full suite) if: github.event_name == 'push' - run: python3 -m pytest tests/integration/ -n 4 --dist=loadgroup --durations=25 --tb=short -q + run: python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv" --durations=25 --tb=short -q sanitizers: runs-on: ubuntu-latest @@ -133,4 +134,4 @@ jobs: - name: Valgrind Memcheck run: valgrind --leak-check=full --show-leak-kinds=definite --error-exitcode=1 ./build/tests env: - FASTSYNC_UNDER_VALGRIND: "1" \ No newline at end of file + FASTSYNC_UNDER_VALGRIND: "1" diff --git a/AGENTS.md b/AGENTS.md index e84937c..b2293ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,12 +20,12 @@ docker build -t fastsync-ci:local . # Build, run unit tests, and run integration tests inside the container docker run --rm -v "$PWD:/workspace" -w /workspace fastsync-ci:local \ - sh -c 'cmake -B build -S . && cmake --build build -j$(nproc) && ./build/tests && python3 -m pytest tests/integration/ -n 4 --dist=loadgroup' + sh -c 'cmake -B build -S . && cmake --build build -j$(nproc) && ./build/tests && python3 -m pytest tests/integration/ -n 4 --dist=load' # Avoid root-owned build/ artifacts by matching your host UID/GID docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/workspace" \ -w /workspace fastsync-ci:local \ - sh -c 'cmake -B build -S . && cmake --build build -j$(nproc) && ./build/tests && python3 -m pytest tests/integration/ -n 4 --dist=loadgroup' + sh -c 'cmake -B build -S . && cmake --build build -j$(nproc) && ./build/tests && python3 -m pytest tests/integration/ -n 4 --dist=load' ``` > **Note:** The first `cmake configure` (`cmake -B build -S .`) fetches xxHash from GitHub via `FetchContent` — network access is required. Subsequent reconfigures reuse the cached source. @@ -41,7 +41,7 @@ cmake -B build -S . -DSANITIZER=address # AddressSanitizer (ASan) cmake -B build -S . -DSANITIZER=thread # ThreadSanitizer (TSan) ``` -The CI workflow (`.gitea/workflows/ci.yaml`) runs lint (clang-format, cppcheck), then a **fast PR gate** — build + unit + a representative subset of integration tests marked `@pytest.mark.ci`, parallelized with pytest-xdist (`-n 4`). The full coverage jobs (full integration suite, sanitizer, fuzz, coverage, valgrind) run **only on push to `dev`/`main`**; pull requests skip them to keep PR CI under ~3 minutes. +The CI workflow (`.gitea/workflows/ci.yaml`) runs lint (clang-format, cppcheck), then a **fast PR gate** — build + unit + a representative subset of integration tests marked `@pytest.mark.ci`, parallelized with pytest-xdist (`-n 4 --dist=load`). The full coverage jobs (full integration suite as `-m "not setpriv"`, sanitizer, fuzz, coverage, valgrind) run **only on push to `dev`/`main`**; pull requests skip them to keep PR CI under ~3 minutes. The two `setpriv` privilege tests are excluded from CI via a marker because their result depends on the runner/container uid and host mount permissions. ## Build @@ -53,8 +53,8 @@ cmake -B build -S . && cmake --build build -j$(nproc) ```bash ./build/tests # unit tests -python3 -m pytest tests/integration/ -n 4 --dist=loadgroup # full integration suite -python3 -m pytest tests/integration/ -n 4 --dist=loadgroup -m ci # PR-gate subset only +python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv" # full integration suite (CI excludes env-dependent privilege tests) +python3 -m pytest tests/integration/ -n 4 --dist=load -m ci # PR-gate subset only ``` ## CI Workflow — Waiting for Results diff --git a/pytest.ini b/pytest.ini index 2f2ed93..9047220 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,7 @@ [pytest] ; Fast integration subset run on every pull request (see .gitea/workflows/ci.yaml). markers = - ci: fast, representative integration tests run on the PR CI gate \ No newline at end of file + ci: fast, representative integration tests run on the PR CI gate + setpriv: privilege-dependent tests (drop to an unprivileged user); excluded + from CI because their result depends on the runner/container uid and the + host mount permissions, but run locally as root diff --git a/tests/conftest.py b/tests/conftest.py index 51d2ded..d4dd89d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,31 @@ """Shared pytest configuration for integration tests.""" import os +import shutil import sys import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "integration")) -from common import ServerManager +from common import ServerManager, TEST_DATA_DIR @pytest.fixture(scope="session") def shared_server(): - """One server for the entire test session. Avoids 27+ server start/stop cycles.""" + """One server for the entire test session. Avoids 27+ server start/stop cycles. + + Under pytest-xdist this session fixture is instantiated once per worker + process, so each worker gets its own server on an ephemeral port.""" server = ServerManager() server.start() yield server server.stop() + + +@pytest.fixture(scope="session", autouse=True) +def _cleanup_worker_test_data(): + """Remove this (worker-keyed) TEST_DATA_DIR at the end of the session. + + Modules clean only their own rows; this final pass ensures the per-worker + directory never lingers in the working tree.""" + yield + shutil.rmtree(TEST_DATA_DIR, ignore_errors=True) diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 00a07c1..4d8aedb 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -25,7 +25,11 @@ def setup_test_data(): generate_test_files(SOURCE_DIR, full=False) clean_dir(DEST_DIR) yield - shutil.rmtree(TEST_DATA_DIR, ignore_errors=True) + # Remove only this module's own dirs. Under pytest-xdist the whole + # (worker-keyed) TEST_DATA_DIR is shared with concurrently-interleaved + # modules, so never rmtree it here. + shutil.rmtree(SOURCE_DIR, ignore_errors=True) + shutil.rmtree(DEST_DIR, ignore_errors=True) class TestDryRun: @@ -2855,6 +2859,7 @@ class TestDeletePolicy: "--clear-groups"] + cmd, text=True, capture_output=True) @pytest.mark.parametrize("mt", [False, True]) + @pytest.mark.setpriv def test_ignore_errors_keeps_deletion_active_on_scan_error(self, mt): """A source I/O error (unreadable subdirectory) aborts the run so no deletion happens by default; --ignore-errors continues, still transfers @@ -2901,6 +2906,7 @@ class TestDeletePolicy: @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("timing", ["--delete", "--delete-before"]) + @pytest.mark.setpriv def test_ignore_errors_unreadable_root_never_deletes(self, mt, timing): """An unreadable SOURCE ROOT must never be treated as a skippable scan error: with --ignore-errors the sequential scanner treats the root as diff --git a/tests/integration/test_ssh.py b/tests/integration/test_ssh.py index 7cf69b9..20b1ac6 100644 --- a/tests/integration/test_ssh.py +++ b/tests/integration/test_ssh.py @@ -60,7 +60,8 @@ def setup_test_data(): generate_test_files(SOURCE_DIR, full=False) clean_dir(DEST_DIR) yield - shutil.rmtree(TEST_DATA_DIR, ignore_errors=True) + shutil.rmtree(SOURCE_DIR, ignore_errors=True) + shutil.rmtree(DEST_DIR, ignore_errors=True) def _run_ssh_test(name, flags, expected_missing=None): diff --git a/tests/integration/test_tcp.py b/tests/integration/test_tcp.py index fdf1917..68f5fa2 100644 --- a/tests/integration/test_tcp.py +++ b/tests/integration/test_tcp.py @@ -21,7 +21,8 @@ def setup_test_data(): generate_test_files(SOURCE_DIR, full=False) clean_dir(DEST_DIR) yield - shutil.rmtree(TEST_DATA_DIR, ignore_errors=True) + shutil.rmtree(SOURCE_DIR, ignore_errors=True) + shutil.rmtree(DEST_DIR, ignore_errors=True) def _run_tcp_test(name, port, flags, use_metadata=True, posix=False): diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py index 29074b2..bf82fc8 100644 --- a/tests/integration/test_tls.py +++ b/tests/integration/test_tls.py @@ -92,10 +92,12 @@ def setup_test_data(): generate_test_files(SOURCE_DIR, full=False) clean_dir(DEST_DIR) yield - shutil.rmtree(TEST_DATA_DIR, ignore_errors=True) + shutil.rmtree(SOURCE_DIR, ignore_errors=True) + shutil.rmtree(DEST_DIR, ignore_errors=True) class TestTLSBasic: + @pytest.mark.ci def test_tls_server_client(self, certs): """Basic TLS: server with cert/key, client with cert/key + CA.""" clean_dir(DEST_DIR)