From d2f0827bd1f9a7c349dd104dbde01b697e3eba42 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 18 Feb 2026 11:27:48 +1100 Subject: [PATCH 001/245] Add push-triggered personal E2E workflow * Add push-triggered personal E2E workflow --- ci/e2e/run.sh | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 ci/e2e/run.sh diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh new file mode 100644 index 000000000..e30d2ad42 --- /dev/null +++ b/ci/e2e/run.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Required environment variables: +# ONEDRIVE_BIN +# E2E_TARGET +# RUN_ID + +OUT_DIR="ci/e2e/out" +SYNC_ROOT="$RUNNER_TEMP/sync-${E2E_TARGET}" + +mkdir -p "$OUT_DIR" +mkdir -p "$SYNC_ROOT" + +RESULTS_FILE="${OUT_DIR}/results.json" +LOG_FILE="${OUT_DIR}/sync.log" + +echo "E2E target: ${E2E_TARGET}" +echo "Sync root: ${SYNC_ROOT}" + +CASE_NAME="basic-resync" + +pass_count=0 +fail_count=0 + +echo "Running: onedrive --sync --verbose --resync --resync-auth" + +set +e +"$ONEDRIVE_BIN" \ + --sync \ + --verbose \ + --resync \ + --resync-auth \ + --syncdir "$SYNC_ROOT" \ + > "$LOG_FILE" 2>&1 +rc=$? +set -e + +if [ "$rc" -eq 0 ]; then + pass_count=1 + status="pass" +else + fail_count=1 + status="fail" +fi + +# Write minimal results.json +cat > "$RESULTS_FILE" < Date: Wed, 18 Feb 2026 11:28:48 +1100 Subject: [PATCH 002/245] Add files via upload --- .github/workflows/e2e-personal.yaml | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/e2e-personal.yaml diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml new file mode 100644 index 000000000..2164c0b8c --- /dev/null +++ b/.github/workflows/e2e-personal.yaml @@ -0,0 +1,66 @@ +name: E2E Personal (push only) + +on: + push: + branches-ignore: + - master + - main + +permissions: + contents: read + +jobs: + e2e_personal: + runs-on: ubuntu-latest + container: fedora:latest + environment: onedrive-e2e + + steps: + - uses: actions/checkout@v4 + + - name: Install deps + run: | + dnf -y update + dnf -y group install development-tools + dnf -y install ldc libcurl-devel sqlite-devel dbus-devel + + - name: Build + local install prefix + run: | + ./configure --prefix="$PWD/.ci/prefix" + make -j"$(nproc)" + make install + "$PWD/.ci/prefix/bin/onedrive" --version + + - name: Prepare isolated HOME + run: | + set -euo pipefail + export HOME="$RUNNER_TEMP/home-personal" + echo "HOME=$HOME" >> "$GITHUB_ENV" + echo "XDG_CONFIG_HOME=$HOME/.config" >> "$GITHUB_ENV" + echo "XDG_CACHE_HOME=$HOME/.cache" >> "$GITHUB_ENV" + mkdir -p "$HOME" + + - name: Inject refresh token into onedrive config + env: + REFRESH_TOKEN_PERSONAL: ${{ secrets.REFRESH_TOKEN_PERSONAL }} + run: | + set -euo pipefail + mkdir -p "$XDG_CONFIG_HOME/onedrive" + umask 077 + printf "%s" "$REFRESH_TOKEN_PERSONAL" > "$XDG_CONFIG_HOME/onedrive/refresh_token" + chmod 600 "$XDG_CONFIG_HOME/onedrive/refresh_token" + + - name: Run E2E harness + env: + ONEDRIVE_BIN: ${{ github.workspace }}/.ci/prefix/bin/onedrive + E2E_TARGET: personal + RUN_ID: ${{ github.run_id }} + run: | + bash ci/e2e/run.sh + + - name: Upload E2E artefacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-personal + path: ci/e2e/out/** From 0d063d6a0c84bfc502c8a75c0ab67e2da35820d3 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 18 Feb 2026 11:55:16 +1100 Subject: [PATCH 003/245] Update PR * Update PR --- .github/workflows/e2e-personal.yaml | 4 ++-- ci/e2e/run.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 2164c0b8c..cc3141ad6 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -1,4 +1,4 @@ -name: E2E Personal (push only) +name: E2E Personal Account Testing (push only) on: push: @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install deps + - name: Install Dependencies run: | dnf -y update dnf -y group install development-tools diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh index e30d2ad42..a5a1804af 100644 --- a/ci/e2e/run.sh +++ b/ci/e2e/run.sh @@ -32,8 +32,8 @@ set +e --resync \ --resync-auth \ --syncdir "$SYNC_ROOT" \ - > "$LOG_FILE" 2>&1 -rc=$? + 2>&1 | tee "$LOG_FILE" +rc=${PIPESTATUS[0]} set -e if [ "$rc" -eq 0 ]; then From 8f2cc53d3d6612b888b368c4e1805683518477f2 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 18 Feb 2026 12:29:12 +1100 Subject: [PATCH 004/245] Update PR * Update PR --- .github/workflows/e2e-personal.yaml | 100 ++++++++++++++++++++++++++++ ci/e2e/run.sh | 77 +++++++++++++-------- 2 files changed, 151 insertions(+), 26 deletions(-) diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index cc3141ad6..2f772b95b 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: write jobs: e2e_personal: @@ -64,3 +65,102 @@ jobs: with: name: e2e-personal path: ci/e2e/out/** + + pr_comment: + name: Post PR summary comment + needs: [ e2e_personal ] + runs-on: ubuntu-latest + if: always() + + steps: + - uses: actions/checkout@v4 + + # Download the artifact produced by the e2e_personal job + - name: Download artefact + uses: actions/download-artifact@v4 + with: + name: e2e-personal + path: artifacts/e2e-personal + + - name: Build markdown summary + id: summary + run: | + set -euo pipefail + f="artifacts/e2e-personal/results.json" + if [ ! -f "$f" ]; then + echo "md=⚠️ E2E ran but results.json was not found." >> "$GITHUB_OUTPUT" + exit 0 + fi + + target=$(jq -r '.target // "personal"' "$f") + total=$(jq -r '.cases | length' "$f") + passed=$(jq -r '[.cases[] | select(.status=="pass")] | length' "$f") + failed=$(jq -r '[.cases[] | select(.status=="fail")] | length' "$f") + + # Build failures list + failures=$(jq -r '.cases[] + | select(.status=="fail") + | "- Test Case \(.id // "????"): \(.name) — \(.reason // "no reason provided")"' "$f" || true) + + md="## ${target^} Account Testing\n" + md+="${total} Test Cases Run \n" + md+="**${passed}** Test Cases Passed \n" + md+="**${failed}** Test Cases Failed \n\n" + + if [ "$failed" -gt 0 ] && [ -n "$failures" ]; then + md+="### ${target^} Account Test Failures\n" + md+="$failures\n" + fi + + echo "md<> "$GITHUB_OUTPUT" + echo -e "$md" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Find PR associated with this commit + id: pr + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const sha = context.sha; + + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, repo, commit_sha: sha + }); + + if (!prs.data.length) { + core.setOutput("found", "false"); + return; + } + + core.setOutput("found", "true"); + core.setOutput("number", String(prs.data[0].number)); + + - name: Post or update PR comment (sticky) + if: steps.pr.outputs.found == 'true' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = Number("${{ steps.pr.outputs.number }}"); + + const marker = ""; + const body = `${marker}\n${{ toJSON(steps.summary.outputs.md) }}`.slice(1, -1); + + const comments = await github.rest.issues.listComments({ + owner, repo, issue_number, per_page: 100 + }); + + const existing = comments.data.find(c => c.body && c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body + }); + } else { + await github.rest.issues.createComment({ + owner, repo, issue_number, body + }); + } + + diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh index a5a1804af..aa13f6801 100644 --- a/ci/e2e/run.sh +++ b/ci/e2e/run.sh @@ -5,9 +5,12 @@ set -euo pipefail # ONEDRIVE_BIN # E2E_TARGET # RUN_ID +# +# Optional (provided by GitHub Actions): +# RUNNER_TEMP OUT_DIR="ci/e2e/out" -SYNC_ROOT="$RUNNER_TEMP/sync-${E2E_TARGET}" +SYNC_ROOT="${RUNNER_TEMP:-/tmp}/sync-${E2E_TARGET}" mkdir -p "$OUT_DIR" mkdir -p "$SYNC_ROOT" @@ -15,16 +18,43 @@ mkdir -p "$SYNC_ROOT" RESULTS_FILE="${OUT_DIR}/results.json" LOG_FILE="${OUT_DIR}/sync.log" +# We'll collect cases as JSON objects in a bash array, then assemble results.json. +declare -a CASES=() +pass_count=0 +fail_count=0 + +# Helper: add a PASS case +add_pass() { + local id="$1" + local name="$2" + CASES+=("$(jq -cn --arg id "$id" --arg name "$name" \ + '{id:$id,name:$name,status:"pass"}')") + pass_count=$((pass_count + 1)) +} + +# Helper: add a FAIL case (with reason) +add_fail() { + local id="$1" + local name="$2" + local reason="$3" + CASES+=("$(jq -cn --arg id "$id" --arg name "$name" --arg reason "$reason" \ + '{id:$id,name:$name,status:"fail",reason:$reason}')") + fail_count=$((fail_count + 1)) +} + echo "E2E target: ${E2E_TARGET}" echo "Sync root: ${SYNC_ROOT}" -CASE_NAME="basic-resync" - -pass_count=0 -fail_count=0 +############################################### +# Test Case 0001: basic resync +############################################### +TC_ID="0001" +TC_NAME="basic-resync (sync + verbose + resync + resync-auth)" +echo "Running test case ${TC_ID}: ${TC_NAME}" echo "Running: onedrive --sync --verbose --resync --resync-auth" +# Stream output to console AND log file (Option A) while preserving exit code. set +e "$ONEDRIVE_BIN" \ --sync \ @@ -37,34 +67,29 @@ rc=${PIPESTATUS[0]} set -e if [ "$rc" -eq 0 ]; then - pass_count=1 - status="pass" + add_pass "$TC_ID" "$TC_NAME" else - fail_count=1 - status="fail" + add_fail "$TC_ID" "$TC_NAME" "onedrive exited with code ${rc}" fi -# Write minimal results.json -cat > "$RESULTS_FILE" < "$RESULTS_FILE" -echo "Exit code: ${rc}" echo "Results written to ${RESULTS_FILE}" echo "Passed: ${pass_count}" echo "Failed: ${fail_count}" -# Fail job if command failed -if [ "$rc" -ne 0 ]; then - echo "E2E failed - see sync.log" +# Fail the job if any cases failed. +if [ "$fail_count" -ne 0 ]; then exit 1 fi From af6f9499539a0b770dba9f540068a1d430e9c03e Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 18 Feb 2026 12:37:22 +1100 Subject: [PATCH 005/245] Update e2e-personal.yaml * Update PR --- .github/workflows/e2e-personal.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 2f772b95b..2380fc3c2 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -23,7 +23,7 @@ jobs: run: | dnf -y update dnf -y group install development-tools - dnf -y install ldc libcurl-devel sqlite-devel dbus-devel + dnf -y install ldc libcurl-devel sqlite-devel dbus-devel jq - name: Build + local install prefix run: | @@ -86,8 +86,9 @@ jobs: id: summary run: | set -euo pipefail - f="artifacts/e2e-personal/results.json" - if [ ! -f "$f" ]; then + + f="$(find artifacts/e2e-personal -name results.json -type f | head -n 1 || true)" + if [ -z "$f" ] || [ ! -f "$f" ]; then echo "md=⚠️ E2E ran but results.json was not found." >> "$GITHUB_OUTPUT" exit 0 fi @@ -97,7 +98,6 @@ jobs: passed=$(jq -r '[.cases[] | select(.status=="pass")] | length' "$f") failed=$(jq -r '[.cases[] | select(.status=="fail")] | length' "$f") - # Build failures list failures=$(jq -r '.cases[] | select(.status=="fail") | "- Test Case \(.id // "????"): \(.name) — \(.reason // "no reason provided")"' "$f" || true) @@ -115,7 +115,6 @@ jobs: echo "md<> "$GITHUB_OUTPUT" echo -e "$md" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" - - name: Find PR associated with this commit id: pr uses: actions/github-script@v7 From 5f13ec265ea6383f51c6a1a5a86c7af437faf648 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 18 Feb 2026 12:57:00 +1100 Subject: [PATCH 006/245] Update e2e-personal.yaml * Update comment output formatting --- .github/workflows/e2e-personal.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 2380fc3c2..4c9ba424a 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -138,13 +138,17 @@ jobs: - name: Post or update PR comment (sticky) if: steps.pr.outputs.found == 'true' uses: actions/github-script@v7 + env: + COMMENT_MD: ${{ steps.summary.outputs.md }} with: script: | const { owner, repo } = context.repo; const issue_number = Number("${{ steps.pr.outputs.number }}"); const marker = ""; - const body = `${marker}\n${{ toJSON(steps.summary.outputs.md) }}`.slice(1, -1); + const md = process.env.COMMENT_MD || "⚠️ No summary text produced."; + + const body = `${marker}\n${md}`; const comments = await github.rest.issues.listComments({ owner, repo, issue_number, per_page: 100 @@ -161,5 +165,3 @@ jobs: owner, repo, issue_number, body }); } - - From 011afcf140dc60a1afae7f47c2a2aa7d4dd31392 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 18 Feb 2026 13:31:50 +1100 Subject: [PATCH 007/245] Update run.sh * Add test case --- ci/e2e/run.sh | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh index aa13f6801..b662aa751 100644 --- a/ci/e2e/run.sh +++ b/ci/e2e/run.sh @@ -72,6 +72,104 @@ else add_fail "$TC_ID" "$TC_NAME" "onedrive exited with code ${rc}" fi + +############################################### +# Test Case 0002: upload-only does not download +############################################### +TC_ID="0002" +TC_NAME="upload-only: uploads local changes, does not download remote-only changes" + +REMOTE_PREFIX="ci_e2e/${RUN_ID}/${E2E_TARGET}/upload_only" +SEED_DIR="${RUNNER_TEMP:-/tmp}/seed-${E2E_TARGET}-${RUN_ID}" +UP_DIR="${RUNNER_TEMP:-/tmp}/uploadonly-${E2E_TARGET}-${RUN_ID}" +VERIFY_DIR="${RUNNER_TEMP:-/tmp}/verify-${E2E_TARGET}-${RUN_ID}" + +SEED_LOG="${OUT_DIR}/tc0002-seed.log" +UP_LOG="${OUT_DIR}/tc0002-uploadonly.log" +VERIFY_LOG="${OUT_DIR}/tc0002-verify.log" + +REMOTE_ONLY_FILE="remote_only_${RUN_ID}.txt" +LOCAL_ONLY_FILE="local_only_${RUN_ID}.txt" + +echo "Running test case ${TC_ID}: ${TC_NAME}" +echo "Remote prefix: ${REMOTE_PREFIX}" + +rm -rf "$SEED_DIR" "$UP_DIR" "$VERIFY_DIR" +mkdir -p "$SEED_DIR" "$UP_DIR" "$VERIFY_DIR" + +# Step A: Create a file and upload it via the seeder dir +# This makes it "remote-only" relative to UP_DIR (because UP_DIR has never synced yet) +echo "Created remotely by seeder at run ${RUN_ID}" > "${SEED_DIR}/${REMOTE_ONLY_FILE}" + +set +e +"$ONEDRIVE_BIN" \ + --sync \ + --verbose \ + --syncdir "$SEED_DIR" \ + --single-directory "$REMOTE_PREFIX" \ + 2>&1 | tee "$SEED_LOG" +rc_seed=${PIPESTATUS[0]} +set -e + +if [ "$rc_seed" -ne 0 ]; then + add_fail "$TC_ID" "$TC_NAME" "Seeder sync failed (exit code ${rc_seed})" +else + # Step B: Create a local-only file in the upload-only dir + echo "Created locally for upload-only at run ${RUN_ID}" > "${UP_DIR}/${LOCAL_ONLY_FILE}" + + # Step C: Run upload-only sync. It must NOT download REMOTE_ONLY_FILE. + set +e + "$ONEDRIVE_BIN" \ + --sync \ + --verbose \ + --upload-only \ + --syncdir "$UP_DIR" \ + --single-directory "$REMOTE_PREFIX" \ + 2>&1 | tee "$UP_LOG" + rc_up=${PIPESTATUS[0]} + set -e + + if [ "$rc_up" -ne 0 ]; then + add_fail "$TC_ID" "$TC_NAME" "Upload-only sync failed (exit code ${rc_up})" + else + # Assertion 1: upload-only must NOT download the remote-only file + if [ -f "${UP_DIR}/${REMOTE_ONLY_FILE}" ]; then + add_fail "$TC_ID" "$TC_NAME" "Upload-only unexpectedly downloaded remote-only file: ${REMOTE_ONLY_FILE}" + else + # Step D: Verify the local-only file exists online by doing a download-only into VERIFY_DIR + set +e + "$ONEDRIVE_BIN" \ + --sync \ + --verbose \ + --download-only \ + --syncdir "$VERIFY_DIR" \ + --single-directory "$REMOTE_PREFIX" \ + 2>&1 | tee "$VERIFY_LOG" + rc_ver=${PIPESTATUS[0]} + set -e + + if [ "$rc_ver" -ne 0 ]; then + add_fail "$TC_ID" "$TC_NAME" "Verifier download-only failed (exit code ${rc_ver})" + elif [ ! -f "${VERIFY_DIR}/${LOCAL_ONLY_FILE}" ]; then + add_fail "$TC_ID" "$TC_NAME" "Uploaded file not found online (not downloaded by verifier): ${LOCAL_ONLY_FILE}" + else + # Optional log validations (soft but useful): + # - upload-only log should mention local file name + # - upload-only log should NOT mention the remote-only file name + if ! grep -Fq "$LOCAL_ONLY_FILE" "$UP_LOG"; then + add_fail "$TC_ID" "$TC_NAME" "Upload-only log did not mention uploaded file: ${LOCAL_ONLY_FILE}" + elif grep -Fq "$REMOTE_ONLY_FILE" "$UP_LOG"; then + add_fail "$TC_ID" "$TC_NAME" "Upload-only log mentioned remote-only file (possible download): ${REMOTE_ONLY_FILE}" + else + add_pass "$TC_ID" "$TC_NAME" + fi + fi + fi + fi +fi + + + ############################################### # Write results.json ############################################### From 5ce774f74550336ff9dd5959a640de6b079f236f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 18 Feb 2026 13:57:38 +1100 Subject: [PATCH 008/245] Update run.sh * Update Test Case 0002 --- ci/e2e/run.sh | 114 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 76 insertions(+), 38 deletions(-) diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh index b662aa751..b168ef71d 100644 --- a/ci/e2e/run.sh +++ b/ci/e2e/run.sh @@ -75,18 +75,25 @@ fi ############################################### # Test Case 0002: upload-only does not download +# Uses separate --confdir profiles to avoid sync_dir change resync issues. ############################################### TC_ID="0002" TC_NAME="upload-only: uploads local changes, does not download remote-only changes" REMOTE_PREFIX="ci_e2e/${RUN_ID}/${E2E_TARGET}/upload_only" + SEED_DIR="${RUNNER_TEMP:-/tmp}/seed-${E2E_TARGET}-${RUN_ID}" UP_DIR="${RUNNER_TEMP:-/tmp}/uploadonly-${E2E_TARGET}-${RUN_ID}" -VERIFY_DIR="${RUNNER_TEMP:-/tmp}/verify-${E2E_TARGET}-${RUN_ID}" +VER_DIR="${RUNNER_TEMP:-/tmp}/verify-${E2E_TARGET}-${RUN_ID}" + +CONF_BASE="${RUNNER_TEMP:-/tmp}/conf-${E2E_TARGET}-${RUN_ID}" +CONF_SEED="${CONF_BASE}/seed" +CONF_UP="${CONF_BASE}/upload" +CONF_VER="${CONF_BASE}/verify" SEED_LOG="${OUT_DIR}/tc0002-seed.log" UP_LOG="${OUT_DIR}/tc0002-uploadonly.log" -VERIFY_LOG="${OUT_DIR}/tc0002-verify.log" +VER_LOG="${OUT_DIR}/tc0002-verify.log" REMOTE_ONLY_FILE="remote_only_${RUN_ID}.txt" LOCAL_ONLY_FILE="local_only_${RUN_ID}.txt" @@ -94,68 +101,98 @@ LOCAL_ONLY_FILE="local_only_${RUN_ID}.txt" echo "Running test case ${TC_ID}: ${TC_NAME}" echo "Remote prefix: ${REMOTE_PREFIX}" -rm -rf "$SEED_DIR" "$UP_DIR" "$VERIFY_DIR" -mkdir -p "$SEED_DIR" "$UP_DIR" "$VERIFY_DIR" - -# Step A: Create a file and upload it via the seeder dir -# This makes it "remote-only" relative to UP_DIR (because UP_DIR has never synced yet) -echo "Created remotely by seeder at run ${RUN_ID}" > "${SEED_DIR}/${REMOTE_ONLY_FILE}" - -set +e -"$ONEDRIVE_BIN" \ - --sync \ - --verbose \ - --syncdir "$SEED_DIR" \ - --single-directory "$REMOTE_PREFIX" \ - 2>&1 | tee "$SEED_LOG" -rc_seed=${PIPESTATUS[0]} -set -e +# Helper: locate the already-injected refresh token (from workflow step) +TOKEN_SRC="" +if [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/onedrive/refresh_token" ]; then + TOKEN_SRC="${XDG_CONFIG_HOME:-$HOME/.config}/onedrive/refresh_token" +elif [ -f "$HOME/.config/onedrive/refresh_token" ]; then + TOKEN_SRC="$HOME/.config/onedrive/refresh_token" +fi -if [ "$rc_seed" -ne 0 ]; then - add_fail "$TC_ID" "$TC_NAME" "Seeder sync failed (exit code ${rc_seed})" +if [ -z "$TOKEN_SRC" ]; then + add_fail "$TC_ID" "$TC_NAME" "Could not locate existing refresh_token to seed confdirs" else - # Step B: Create a local-only file in the upload-only dir - echo "Created locally for upload-only at run ${RUN_ID}" > "${UP_DIR}/${LOCAL_ONLY_FILE}" + # Clean state for this test + rm -rf "$SEED_DIR" "$UP_DIR" "$VER_DIR" "$CONF_BASE" + mkdir -p "$SEED_DIR" "$UP_DIR" "$VER_DIR" + mkdir -p "$CONF_SEED" "$CONF_UP" "$CONF_VER" + + # Copy refresh_token into each confdir (confdir becomes the config root) + umask 077 + cp -f "$TOKEN_SRC" "${CONF_SEED}/refresh_token" + cp -f "$TOKEN_SRC" "${CONF_UP}/refresh_token" + cp -f "$TOKEN_SRC" "${CONF_VER}/refresh_token" + + ######################################################## + # Step A: Seeder uploads a "remote-only" file + ######################################################## + echo "Seeder creating remote-only file: ${REMOTE_ONLY_FILE}" + printf "Created by seeder at run %s\n" "$RUN_ID" > "${SEED_DIR}/${REMOTE_ONLY_FILE}" - # Step C: Run upload-only sync. It must NOT download REMOTE_ONLY_FILE. set +e "$ONEDRIVE_BIN" \ + --confdir "$CONF_SEED" \ --sync \ --verbose \ - --upload-only \ - --syncdir "$UP_DIR" \ + --syncdir "$SEED_DIR" \ --single-directory "$REMOTE_PREFIX" \ - 2>&1 | tee "$UP_LOG" - rc_up=${PIPESTATUS[0]} + 2>&1 | tee "$SEED_LOG" + rc_seed=${PIPESTATUS[0]} set -e - if [ "$rc_up" -ne 0 ]; then - add_fail "$TC_ID" "$TC_NAME" "Upload-only sync failed (exit code ${rc_up})" + if [ "$rc_seed" -ne 0 ]; then + add_fail "$TC_ID" "$TC_NAME" "Seeder sync failed (exit code ${rc_seed})" else - # Assertion 1: upload-only must NOT download the remote-only file - if [ -f "${UP_DIR}/${REMOTE_ONLY_FILE}" ]; then + ######################################################## + # Step B: Uploader creates a local-only file + ######################################################## + echo "Uploader creating local-only file: ${LOCAL_ONLY_FILE}" + printf "Created by uploader at run %s\n" "$RUN_ID" > "${UP_DIR}/${LOCAL_ONLY_FILE}" + + ######################################################## + # Step C: Run upload-only from uploader profile + # Must not download REMOTE_ONLY_FILE into UP_DIR. + ######################################################## + set +e + "$ONEDRIVE_BIN" \ + --confdir "$CONF_UP" \ + --sync \ + --verbose \ + --upload-only \ + --syncdir "$UP_DIR" \ + --single-directory "$REMOTE_PREFIX" \ + 2>&1 | tee "$UP_LOG" + rc_up=${PIPESTATUS[0]} + set -e + + if [ "$rc_up" -ne 0 ]; then + add_fail "$TC_ID" "$TC_NAME" "Upload-only sync failed (exit code ${rc_up})" + elif [ -f "${UP_DIR}/${REMOTE_ONLY_FILE}" ]; then add_fail "$TC_ID" "$TC_NAME" "Upload-only unexpectedly downloaded remote-only file: ${REMOTE_ONLY_FILE}" else - # Step D: Verify the local-only file exists online by doing a download-only into VERIFY_DIR + ######################################################## + # Step D: Verify upload landed online using verifier + ######################################################## set +e "$ONEDRIVE_BIN" \ + --confdir "$CONF_VER" \ --sync \ --verbose \ --download-only \ - --syncdir "$VERIFY_DIR" \ + --syncdir "$VER_DIR" \ --single-directory "$REMOTE_PREFIX" \ - 2>&1 | tee "$VERIFY_LOG" + 2>&1 | tee "$VER_LOG" rc_ver=${PIPESTATUS[0]} set -e if [ "$rc_ver" -ne 0 ]; then add_fail "$TC_ID" "$TC_NAME" "Verifier download-only failed (exit code ${rc_ver})" - elif [ ! -f "${VERIFY_DIR}/${LOCAL_ONLY_FILE}" ]; then + elif [ ! -f "${VER_DIR}/${LOCAL_ONLY_FILE}" ]; then add_fail "$TC_ID" "$TC_NAME" "Uploaded file not found online (not downloaded by verifier): ${LOCAL_ONLY_FILE}" else - # Optional log validations (soft but useful): - # - upload-only log should mention local file name - # - upload-only log should NOT mention the remote-only file name + # Optional log sanity checks (secondary): + # - UP log should mention the uploaded filename + # - UP log should not mention the remote-only filename if ! grep -Fq "$LOCAL_ONLY_FILE" "$UP_LOG"; then add_fail "$TC_ID" "$TC_NAME" "Upload-only log did not mention uploaded file: ${LOCAL_ONLY_FILE}" elif grep -Fq "$REMOTE_ONLY_FILE" "$UP_LOG"; then @@ -170,6 +207,7 @@ fi + ############################################### # Write results.json ############################################### From 244e31145cc0c561224c51bc4a2ed8f9ff4d9f6e Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 18 Feb 2026 14:03:51 +1100 Subject: [PATCH 009/245] Update run.sh * debug .. whats tripping --- ci/e2e/run.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh index b168ef71d..ac3f3e63b 100644 --- a/ci/e2e/run.sh +++ b/ci/e2e/run.sh @@ -134,6 +134,7 @@ else --confdir "$CONF_SEED" \ --sync \ --verbose \ + --verbose \ --syncdir "$SEED_DIR" \ --single-directory "$REMOTE_PREFIX" \ 2>&1 | tee "$SEED_LOG" @@ -158,6 +159,7 @@ else --confdir "$CONF_UP" \ --sync \ --verbose \ + --verbose \ --upload-only \ --syncdir "$UP_DIR" \ --single-directory "$REMOTE_PREFIX" \ @@ -178,6 +180,7 @@ else --confdir "$CONF_VER" \ --sync \ --verbose \ + --verbose \ --download-only \ --syncdir "$VER_DIR" \ --single-directory "$REMOTE_PREFIX" \ From 8097bf6a75b945cb3cb14154cd6bf2dbcbe5d787 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 20 Feb 2026 05:57:11 +1100 Subject: [PATCH 010/245] Update run.sh * add resync to Test Case 0002 --- ci/e2e/run.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh index ac3f3e63b..38b0a4670 100644 --- a/ci/e2e/run.sh +++ b/ci/e2e/run.sh @@ -134,7 +134,8 @@ else --confdir "$CONF_SEED" \ --sync \ --verbose \ - --verbose \ + --resync \ + --resync-auth \ --syncdir "$SEED_DIR" \ --single-directory "$REMOTE_PREFIX" \ 2>&1 | tee "$SEED_LOG" @@ -159,7 +160,8 @@ else --confdir "$CONF_UP" \ --sync \ --verbose \ - --verbose \ + --resync \ + --resync-auth \ --upload-only \ --syncdir "$UP_DIR" \ --single-directory "$REMOTE_PREFIX" \ @@ -180,7 +182,8 @@ else --confdir "$CONF_VER" \ --sync \ --verbose \ - --verbose \ + --resync \ + --resync-auth \ --download-only \ --syncdir "$VER_DIR" \ --single-directory "$REMOTE_PREFIX" \ From 14464acb4b64c013cbd8ff7263fc7a8a5681eb64 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 6 Mar 2026 18:45:31 +1100 Subject: [PATCH 011/245] Update run.sh * Remove broken Test Case 0002 --- ci/e2e/run.sh | 139 -------------------------------------------------- 1 file changed, 139 deletions(-) diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh index 38b0a4670..a0d3b3e8d 100644 --- a/ci/e2e/run.sh +++ b/ci/e2e/run.sh @@ -73,145 +73,6 @@ else fi -############################################### -# Test Case 0002: upload-only does not download -# Uses separate --confdir profiles to avoid sync_dir change resync issues. -############################################### -TC_ID="0002" -TC_NAME="upload-only: uploads local changes, does not download remote-only changes" - -REMOTE_PREFIX="ci_e2e/${RUN_ID}/${E2E_TARGET}/upload_only" - -SEED_DIR="${RUNNER_TEMP:-/tmp}/seed-${E2E_TARGET}-${RUN_ID}" -UP_DIR="${RUNNER_TEMP:-/tmp}/uploadonly-${E2E_TARGET}-${RUN_ID}" -VER_DIR="${RUNNER_TEMP:-/tmp}/verify-${E2E_TARGET}-${RUN_ID}" - -CONF_BASE="${RUNNER_TEMP:-/tmp}/conf-${E2E_TARGET}-${RUN_ID}" -CONF_SEED="${CONF_BASE}/seed" -CONF_UP="${CONF_BASE}/upload" -CONF_VER="${CONF_BASE}/verify" - -SEED_LOG="${OUT_DIR}/tc0002-seed.log" -UP_LOG="${OUT_DIR}/tc0002-uploadonly.log" -VER_LOG="${OUT_DIR}/tc0002-verify.log" - -REMOTE_ONLY_FILE="remote_only_${RUN_ID}.txt" -LOCAL_ONLY_FILE="local_only_${RUN_ID}.txt" - -echo "Running test case ${TC_ID}: ${TC_NAME}" -echo "Remote prefix: ${REMOTE_PREFIX}" - -# Helper: locate the already-injected refresh token (from workflow step) -TOKEN_SRC="" -if [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/onedrive/refresh_token" ]; then - TOKEN_SRC="${XDG_CONFIG_HOME:-$HOME/.config}/onedrive/refresh_token" -elif [ -f "$HOME/.config/onedrive/refresh_token" ]; then - TOKEN_SRC="$HOME/.config/onedrive/refresh_token" -fi - -if [ -z "$TOKEN_SRC" ]; then - add_fail "$TC_ID" "$TC_NAME" "Could not locate existing refresh_token to seed confdirs" -else - # Clean state for this test - rm -rf "$SEED_DIR" "$UP_DIR" "$VER_DIR" "$CONF_BASE" - mkdir -p "$SEED_DIR" "$UP_DIR" "$VER_DIR" - mkdir -p "$CONF_SEED" "$CONF_UP" "$CONF_VER" - - # Copy refresh_token into each confdir (confdir becomes the config root) - umask 077 - cp -f "$TOKEN_SRC" "${CONF_SEED}/refresh_token" - cp -f "$TOKEN_SRC" "${CONF_UP}/refresh_token" - cp -f "$TOKEN_SRC" "${CONF_VER}/refresh_token" - - ######################################################## - # Step A: Seeder uploads a "remote-only" file - ######################################################## - echo "Seeder creating remote-only file: ${REMOTE_ONLY_FILE}" - printf "Created by seeder at run %s\n" "$RUN_ID" > "${SEED_DIR}/${REMOTE_ONLY_FILE}" - - set +e - "$ONEDRIVE_BIN" \ - --confdir "$CONF_SEED" \ - --sync \ - --verbose \ - --resync \ - --resync-auth \ - --syncdir "$SEED_DIR" \ - --single-directory "$REMOTE_PREFIX" \ - 2>&1 | tee "$SEED_LOG" - rc_seed=${PIPESTATUS[0]} - set -e - - if [ "$rc_seed" -ne 0 ]; then - add_fail "$TC_ID" "$TC_NAME" "Seeder sync failed (exit code ${rc_seed})" - else - ######################################################## - # Step B: Uploader creates a local-only file - ######################################################## - echo "Uploader creating local-only file: ${LOCAL_ONLY_FILE}" - printf "Created by uploader at run %s\n" "$RUN_ID" > "${UP_DIR}/${LOCAL_ONLY_FILE}" - - ######################################################## - # Step C: Run upload-only from uploader profile - # Must not download REMOTE_ONLY_FILE into UP_DIR. - ######################################################## - set +e - "$ONEDRIVE_BIN" \ - --confdir "$CONF_UP" \ - --sync \ - --verbose \ - --resync \ - --resync-auth \ - --upload-only \ - --syncdir "$UP_DIR" \ - --single-directory "$REMOTE_PREFIX" \ - 2>&1 | tee "$UP_LOG" - rc_up=${PIPESTATUS[0]} - set -e - - if [ "$rc_up" -ne 0 ]; then - add_fail "$TC_ID" "$TC_NAME" "Upload-only sync failed (exit code ${rc_up})" - elif [ -f "${UP_DIR}/${REMOTE_ONLY_FILE}" ]; then - add_fail "$TC_ID" "$TC_NAME" "Upload-only unexpectedly downloaded remote-only file: ${REMOTE_ONLY_FILE}" - else - ######################################################## - # Step D: Verify upload landed online using verifier - ######################################################## - set +e - "$ONEDRIVE_BIN" \ - --confdir "$CONF_VER" \ - --sync \ - --verbose \ - --resync \ - --resync-auth \ - --download-only \ - --syncdir "$VER_DIR" \ - --single-directory "$REMOTE_PREFIX" \ - 2>&1 | tee "$VER_LOG" - rc_ver=${PIPESTATUS[0]} - set -e - - if [ "$rc_ver" -ne 0 ]; then - add_fail "$TC_ID" "$TC_NAME" "Verifier download-only failed (exit code ${rc_ver})" - elif [ ! -f "${VER_DIR}/${LOCAL_ONLY_FILE}" ]; then - add_fail "$TC_ID" "$TC_NAME" "Uploaded file not found online (not downloaded by verifier): ${LOCAL_ONLY_FILE}" - else - # Optional log sanity checks (secondary): - # - UP log should mention the uploaded filename - # - UP log should not mention the remote-only filename - if ! grep -Fq "$LOCAL_ONLY_FILE" "$UP_LOG"; then - add_fail "$TC_ID" "$TC_NAME" "Upload-only log did not mention uploaded file: ${LOCAL_ONLY_FILE}" - elif grep -Fq "$REMOTE_ONLY_FILE" "$UP_LOG"; then - add_fail "$TC_ID" "$TC_NAME" "Upload-only log mentioned remote-only file (possible download): ${REMOTE_ONLY_FILE}" - else - add_pass "$TC_ID" "$TC_NAME" - fi - fi - fi - fi -fi - - ############################################### From 8fa8283b6e1f959d348d8d8156cd2aeb1601496b Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 7 Mar 2026 11:54:51 +1100 Subject: [PATCH 012/245] Update PR * Convert to a python harness --- .github/workflows/e2e-personal.yaml | 8 +- ci/e2e/framework/__init__.py | 3 + ci/e2e/framework/base.py | 23 +++++ ci/e2e/framework/context.py | 66 +++++++++++++ ci/e2e/framework/result.py | 50 ++++++++++ ci/e2e/framework/utils.py | 78 +++++++++++++++ ci/e2e/run.py | 124 ++++++++++++++++++++++++ ci/e2e/run.sh | 98 ------------------- ci/e2e/testcases/__init__.py | 3 + ci/e2e/testcases/tc0001_basic_resync.py | 89 +++++++++++++++++ 10 files changed, 440 insertions(+), 102 deletions(-) create mode 100644 ci/e2e/framework/__init__.py create mode 100644 ci/e2e/framework/base.py create mode 100644 ci/e2e/framework/context.py create mode 100644 ci/e2e/framework/result.py create mode 100644 ci/e2e/framework/utils.py create mode 100644 ci/e2e/run.py delete mode 100644 ci/e2e/run.sh create mode 100644 ci/e2e/testcases/__init__.py create mode 100644 ci/e2e/testcases/tc0001_basic_resync.py diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 4c9ba424a..f765f39cb 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -23,7 +23,7 @@ jobs: run: | dnf -y update dnf -y group install development-tools - dnf -y install ldc libcurl-devel sqlite-devel dbus-devel jq + dnf -y install python3 ldc libcurl-devel sqlite-devel dbus-devel jq - name: Build + local install prefix run: | @@ -57,7 +57,7 @@ jobs: E2E_TARGET: personal RUN_ID: ${{ github.run_id }} run: | - bash ci/e2e/run.sh + python3 ci/e2e/run.py - name: Upload E2E artefacts if: always() @@ -75,7 +75,6 @@ jobs: steps: - uses: actions/checkout@v4 - # Download the artifact produced by the e2e_personal job - name: Download artefact uses: actions/download-artifact@v4 with: @@ -115,6 +114,7 @@ jobs: echo "md<> "$GITHUB_OUTPUT" echo -e "$md" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" + - name: Find PR associated with this commit id: pr uses: actions/github-script@v7 @@ -164,4 +164,4 @@ jobs: await github.rest.issues.createComment({ owner, repo, issue_number, body }); - } + } \ No newline at end of file diff --git a/ci/e2e/framework/__init__.py b/ci/e2e/framework/__init__.py new file mode 100644 index 000000000..c6d256113 --- /dev/null +++ b/ci/e2e/framework/__init__.py @@ -0,0 +1,3 @@ +""" +E2E framework package for GitHub Actions based validation. +""" \ No newline at end of file diff --git a/ci/e2e/framework/base.py b/ci/e2e/framework/base.py new file mode 100644 index 000000000..a8f323ca1 --- /dev/null +++ b/ci/e2e/framework/base.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from framework.context import E2EContext +from framework.result import TestResult + + +class E2ETestCase(ABC): + """ + Base class for all E2E test cases. + """ + + case_id: str = "" + name: str = "" + description: str = "" + + @abstractmethod + def run(self, context: E2EContext) -> TestResult: + """ + Execute the test case and return a structured TestResult. + """ + raise NotImplementedError \ No newline at end of file diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py new file mode 100644 index 000000000..66bb76d18 --- /dev/null +++ b/ci/e2e/framework/context.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from framework.utils import ensure_directory, timestamp_now, write_text_file_append + + +@dataclass +class E2EContext: + """ + Runtime context for the E2E framework. + """ + + onedrive_bin: str + e2e_target: str + run_id: str + + repo_root: Path + out_dir: Path + logs_dir: Path + state_dir: Path + work_root: Path + + @classmethod + def from_environment(cls) -> "E2EContext": + onedrive_bin = os.environ.get("ONEDRIVE_BIN", "").strip() + e2e_target = os.environ.get("E2E_TARGET", "").strip() + run_id = os.environ.get("RUN_ID", "").strip() + + if not onedrive_bin: + raise RuntimeError("Environment variable ONEDRIVE_BIN must be set") + if not e2e_target: + raise RuntimeError("Environment variable E2E_TARGET must be set") + if not run_id: + raise RuntimeError("Environment variable RUN_ID must be set") + + repo_root = Path.cwd() + out_dir = repo_root / "ci" / "e2e" / "out" + logs_dir = out_dir / "logs" + state_dir = out_dir / "state" + + runner_temp = os.environ.get("RUNNER_TEMP", "/tmp").strip() + work_root = Path(runner_temp) / f"onedrive-e2e-{e2e_target}" + + return cls( + onedrive_bin=onedrive_bin, + e2e_target=e2e_target, + run_id=run_id, + repo_root=repo_root, + out_dir=out_dir, + logs_dir=logs_dir, + state_dir=state_dir, + work_root=work_root, + ) + + @property + def master_log_file(self) -> Path: + return self.out_dir / "run.log" + + def log(self, message: str) -> None: + ensure_directory(self.out_dir) + line = f"[{timestamp_now()}] {message}\n" + print(line, end="") + write_text_file_append(self.master_log_file, line) \ No newline at end of file diff --git a/ci/e2e/framework/result.py b/ci/e2e/framework/result.py new file mode 100644 index 000000000..ff6eb98ed --- /dev/null +++ b/ci/e2e/framework/result.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class TestResult: + """ + Structured test result returned by each test case. + """ + + case_id: str + name: str + status: str + reason: str = "" + artifacts: list[str] = field(default_factory=list) + details: dict = field(default_factory=dict) + + @staticmethod + def pass_result( + case_id: str, + name: str, + artifacts: list[str] | None = None, + details: dict | None = None, + ) -> "TestResult": + return TestResult( + case_id=case_id, + name=name, + status="pass", + reason="", + artifacts=artifacts or [], + details=details or {}, + ) + + @staticmethod + def fail_result( + case_id: str, + name: str, + reason: str, + artifacts: list[str] | None = None, + details: dict | None = None, + ) -> "TestResult": + return TestResult( + case_id=case_id, + name=name, + status="fail", + reason=reason, + artifacts=artifacts or [], + details=details or {}, + ) \ No newline at end of file diff --git a/ci/e2e/framework/utils.py b/ci/e2e/framework/utils.py new file mode 100644 index 000000000..52e72d787 --- /dev/null +++ b/ci/e2e/framework/utils.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + + +@dataclass +class CommandResult: + command: list[str] + returncode: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.returncode == 0 + + +def timestamp_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def ensure_directory(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def reset_directory(path: Path) -> None: + if path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True, exist_ok=True) + + +def write_text_file(path: Path, content: str) -> None: + ensure_directory(path.parent) + path.write_text(content, encoding="utf-8") + + +def write_text_file_append(path: Path, content: str) -> None: + ensure_directory(path.parent) + with path.open("a", encoding="utf-8") as fp: + fp.write(content) + + +def run_command( + command: list[str], + cwd: Path | None = None, + env: dict[str, str] | None = None, +) -> CommandResult: + merged_env = os.environ.copy() + if env: + merged_env.update(env) + + completed = subprocess.run( + command, + cwd=str(cwd) if cwd else None, + env=merged_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + + return CommandResult( + command=command, + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + +def command_to_string(command: list[str]) -> str: + return " ".join(command) \ No newline at end of file diff --git a/ci/e2e/run.py b/ci/e2e/run.py new file mode 100644 index 000000000..dc699d05b --- /dev/null +++ b/ci/e2e/run.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +import traceback +from pathlib import Path + +from framework.context import E2EContext +from framework.result import TestResult +from framework.utils import ensure_directory, write_text_file +from testcases.tc0001_basic_resync import TestCase0001BasicResync + + +def build_test_suite() -> list: + """ + Return the ordered list of E2E test cases to execute. + + Add future test cases here in the required execution order. + """ + return [ + TestCase0001BasicResync(), + ] + + +def result_to_actions_case(result: TestResult) -> dict: + """ + Convert the internal TestResult into the JSON structure expected by the + GitHub Actions workflow summary/reporting logic. + """ + output = { + "id": result.case_id, + "name": result.name, + "status": result.status, + } + + if result.reason: + output["reason"] = result.reason + + if result.artifacts: + output["artifacts"] = result.artifacts + + if result.details: + output["details"] = result.details + + return output + + +def main() -> int: + context = E2EContext.from_environment() + ensure_directory(context.out_dir) + ensure_directory(context.logs_dir) + ensure_directory(context.state_dir) + ensure_directory(context.work_root) + + context.log( + f"Initialising E2E framework for target='{context.e2e_target}', " + f"run_id='{context.run_id}'" + ) + + cases = [] + failed = False + + for testcase in build_test_suite(): + context.log(f"Starting test case {testcase.case_id}: {testcase.name}") + + try: + result = testcase.run(context) + + if result.case_id != testcase.case_id: + raise RuntimeError( + f"Test case returned mismatched case_id: " + f"expected '{testcase.case_id}', got '{result.case_id}'" + ) + + cases.append(result_to_actions_case(result)) + + if result.status != "pass": + failed = True + context.log( + f"Test case {testcase.case_id} FAILED: {result.reason or 'no reason provided'}" + ) + else: + context.log(f"Test case {testcase.case_id} PASSED") + + except Exception as exc: + failed = True + tb = traceback.format_exc() + + context.log(f"Unhandled exception in test case {testcase.case_id}: {exc}") + context.log(tb) + + error_log = context.logs_dir / f"{testcase.case_id}_exception.log" + write_text_file(error_log, tb) + + failure_result = TestResult( + case_id=testcase.case_id, + name=testcase.name, + status="fail", + reason=f"Unhandled exception: {exc}", + artifacts=[str(error_log)], + details={ + "exception_type": type(exc).__name__, + }, + ) + cases.append(result_to_actions_case(failure_result)) + + results = { + "target": context.e2e_target, + "run_id": context.run_id, + "cases": cases, + } + + results_file = context.out_dir / "results.json" + results_json = json.dumps(results, indent=2, sort_keys=False) + write_text_file(results_file, results_json) + + context.log(f"Wrote results to {results_file}") + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/ci/e2e/run.sh b/ci/e2e/run.sh deleted file mode 100644 index a0d3b3e8d..000000000 --- a/ci/e2e/run.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Required environment variables: -# ONEDRIVE_BIN -# E2E_TARGET -# RUN_ID -# -# Optional (provided by GitHub Actions): -# RUNNER_TEMP - -OUT_DIR="ci/e2e/out" -SYNC_ROOT="${RUNNER_TEMP:-/tmp}/sync-${E2E_TARGET}" - -mkdir -p "$OUT_DIR" -mkdir -p "$SYNC_ROOT" - -RESULTS_FILE="${OUT_DIR}/results.json" -LOG_FILE="${OUT_DIR}/sync.log" - -# We'll collect cases as JSON objects in a bash array, then assemble results.json. -declare -a CASES=() -pass_count=0 -fail_count=0 - -# Helper: add a PASS case -add_pass() { - local id="$1" - local name="$2" - CASES+=("$(jq -cn --arg id "$id" --arg name "$name" \ - '{id:$id,name:$name,status:"pass"}')") - pass_count=$((pass_count + 1)) -} - -# Helper: add a FAIL case (with reason) -add_fail() { - local id="$1" - local name="$2" - local reason="$3" - CASES+=("$(jq -cn --arg id "$id" --arg name "$name" --arg reason "$reason" \ - '{id:$id,name:$name,status:"fail",reason:$reason}')") - fail_count=$((fail_count + 1)) -} - -echo "E2E target: ${E2E_TARGET}" -echo "Sync root: ${SYNC_ROOT}" - -############################################### -# Test Case 0001: basic resync -############################################### -TC_ID="0001" -TC_NAME="basic-resync (sync + verbose + resync + resync-auth)" - -echo "Running test case ${TC_ID}: ${TC_NAME}" -echo "Running: onedrive --sync --verbose --resync --resync-auth" - -# Stream output to console AND log file (Option A) while preserving exit code. -set +e -"$ONEDRIVE_BIN" \ - --sync \ - --verbose \ - --resync \ - --resync-auth \ - --syncdir "$SYNC_ROOT" \ - 2>&1 | tee "$LOG_FILE" -rc=${PIPESTATUS[0]} -set -e - -if [ "$rc" -eq 0 ]; then - add_pass "$TC_ID" "$TC_NAME" -else - add_fail "$TC_ID" "$TC_NAME" "onedrive exited with code ${rc}" -fi - - - - -############################################### -# Write results.json -############################################### -# Build JSON array from CASES[] -cases_json="$(printf '%s\n' "${CASES[@]}" | jq -cs '.')" - -jq -n \ - --arg target "$E2E_TARGET" \ - --argjson run_id "$RUN_ID" \ - --argjson cases "$cases_json" \ - '{target:$target, run_id:$run_id, cases:$cases}' \ - > "$RESULTS_FILE" - -echo "Results written to ${RESULTS_FILE}" -echo "Passed: ${pass_count}" -echo "Failed: ${fail_count}" - -# Fail the job if any cases failed. -if [ "$fail_count" -ne 0 ]; then - exit 1 -fi diff --git a/ci/e2e/testcases/__init__.py b/ci/e2e/testcases/__init__.py new file mode 100644 index 000000000..3db46e2ba --- /dev/null +++ b/ci/e2e/testcases/__init__.py @@ -0,0 +1,3 @@ +""" +E2E test case package. +""" \ No newline at end of file diff --git a/ci/e2e/testcases/tc0001_basic_resync.py b/ci/e2e/testcases/tc0001_basic_resync.py new file mode 100644 index 000000000..3ee55d8ba --- /dev/null +++ b/ci/e2e/testcases/tc0001_basic_resync.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0001BasicResync(E2ETestCase): + """ + Test Case 0001: basic resync + + Purpose: + - validate that the E2E framework can invoke the client + - validate that the configured environment is sufficient to run a basic sync + - provide a simple baseline smoke test before more advanced E2E scenarios + """ + + case_id = "0001" + name = "basic resync" + description = "Run a basic --sync --resync --resync-auth operation and capture the outcome" + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / f"tc{self.case_id}" + case_log_dir = context.logs_dir / f"tc{self.case_id}" + state_dir = context.state_dir / f"tc{self.case_id}" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + + stdout_file = case_log_dir / "stdout.log" + stderr_file = case_log_dir / "stderr.log" + metadata_file = state_dir / "metadata.txt" + + command = [ + context.onedrive_bin, + "--sync", + "--verbose", + "--resync", + "--resync-auth", + ] + + context.log( + f"Executing Test Case {self.case_id}: {command_to_string(command)}" + ) + + result = run_command(command, cwd=context.repo_root) + + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + + metadata_lines = [ + f"case_id={self.case_id}", + f"name={self.name}", + f"command={command_to_string(command)}", + f"returncode={result.returncode}", + ] + write_text_file(metadata_file, "\n".join(metadata_lines) + "\n") + + artifacts = [ + str(stdout_file), + str(stderr_file), + str(metadata_file), + ] + + details = { + "command": command, + "returncode": result.returncode, + } + + if result.returncode != 0: + reason = f"onedrive exited with non-zero status {result.returncode}" + return TestResult.fail_result( + case_id=self.case_id, + name=self.name, + reason=reason, + artifacts=artifacts, + details=details, + ) + + return TestResult.pass_result( + case_id=self.case_id, + name=self.name, + artifacts=artifacts, + details=details, + ) \ No newline at end of file From 96b2212679eb8c3ace47379002703ec34b98efe1 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 7 Mar 2026 12:21:06 +1100 Subject: [PATCH 013/245] Update e2e-personal.yaml * update yaml --- .github/workflows/e2e-personal.yaml | 50 +++++++++++++---------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index f765f39cb..429c6ad41 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -1,14 +1,20 @@ -name: E2E Personal Account Testing (push only) +name: E2E Personal Account Testing (push + PR) on: push: branches-ignore: - master - main + pull_request: + types: + - opened + - reopened + - synchronize permissions: contents: read pull-requests: write + issues: write jobs: e2e_personal: @@ -68,9 +74,9 @@ jobs: pr_comment: name: Post PR summary comment - needs: [ e2e_personal ] + needs: [e2e_personal] runs-on: ubuntu-latest - if: always() + if: always() && github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 @@ -115,35 +121,14 @@ jobs: echo -e "$md" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" - - name: Find PR associated with this commit - id: pr - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const sha = context.sha; - - const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner, repo, commit_sha: sha - }); - - if (!prs.data.length) { - core.setOutput("found", "false"); - return; - } - - core.setOutput("found", "true"); - core.setOutput("number", String(prs.data[0].number)); - - name: Post or update PR comment (sticky) - if: steps.pr.outputs.found == 'true' uses: actions/github-script@v7 env: COMMENT_MD: ${{ steps.summary.outputs.md }} with: script: | const { owner, repo } = context.repo; - const issue_number = Number("${{ steps.pr.outputs.number }}"); + const issue_number = context.payload.pull_request.number; const marker = ""; const md = process.env.COMMENT_MD || "⚠️ No summary text produced."; @@ -151,17 +136,26 @@ jobs: const body = `${marker}\n${md}`; const comments = await github.rest.issues.listComments({ - owner, repo, issue_number, per_page: 100 + owner, + repo, + issue_number, + per_page: 100 }); const existing = comments.data.find(c => c.body && c.body.includes(marker)); if (existing) { await github.rest.issues.updateComment({ - owner, repo, comment_id: existing.id, body + owner, + repo, + comment_id: existing.id, + body }); } else { await github.rest.issues.createComment({ - owner, repo, issue_number, body + owner, + repo, + issue_number, + body }); } \ No newline at end of file From 305f420422264b3b2da7f38e685e49b24e354a95 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 7 Mar 2026 12:25:32 +1100 Subject: [PATCH 014/245] Update e2e-personal.yaml Update yaml --- .github/workflows/e2e-personal.yaml | 63 +++++++++++++---------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 429c6ad41..0f29ca268 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -1,15 +1,10 @@ -name: E2E Personal Account Testing (push + PR) +name: E2E Personal Account Testing (push only) on: push: branches-ignore: - master - main - pull_request: - types: - - opened - - reopened - - synchronize permissions: contents: read @@ -74,9 +69,9 @@ jobs: pr_comment: name: Post PR summary comment - needs: [e2e_personal] + needs: [ e2e_personal ] runs-on: ubuntu-latest - if: always() && github.event_name == 'pull_request' + if: always() steps: - uses: actions/checkout@v4 @@ -121,41 +116,41 @@ jobs: echo -e "$md" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" - - name: Post or update PR comment (sticky) + - name: Find PR associated with this commit + id: pr + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const sha = context.sha; + + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, repo, commit_sha: sha + }); + + if (!prs.data.length) { + core.setOutput("found", "false"); + return; + } + + core.setOutput("found", "true"); + core.setOutput("number", String(prs.data[0].number)); + + - name: Post PR comment + if: steps.pr.outputs.found == 'true' uses: actions/github-script@v7 env: COMMENT_MD: ${{ steps.summary.outputs.md }} with: script: | const { owner, repo } = context.repo; - const issue_number = context.payload.pull_request.number; + const issue_number = Number("${{ steps.pr.outputs.number }}"); - const marker = ""; const md = process.env.COMMENT_MD || "⚠️ No summary text produced."; - const body = `${marker}\n${md}`; - - const comments = await github.rest.issues.listComments({ + await github.rest.issues.createComment({ owner, repo, issue_number, - per_page: 100 - }); - - const existing = comments.data.find(c => c.body && c.body.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body - }); - } \ No newline at end of file + body: md + }); \ No newline at end of file From 2484d950f02d99f176351f1df655e0bfe60c490f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 07:43:44 +1100 Subject: [PATCH 015/245] Update PR * Add 'sync_list' Test Case --- ci/e2e/framework/manifest.py | 50 +++ ci/e2e/run.py | 2 + .../testcases/tc0002_sync_list_validation.py | 337 ++++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 ci/e2e/framework/manifest.py create mode 100644 ci/e2e/testcases/tc0002_sync_list_validation.py diff --git a/ci/e2e/framework/manifest.py b/ci/e2e/framework/manifest.py new file mode 100644 index 000000000..9000e0c1c --- /dev/null +++ b/ci/e2e/framework/manifest.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + + +def build_manifest(root: Path) -> list[str]: + """ + Build a deterministic manifest of all files and directories beneath root. + + Paths are returned relative to root using POSIX separators. + """ + entries: list[str] = [] + + if not root.exists(): + return entries + + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + entries.append(rel) + + return entries + + +def write_manifest(path: Path, entries: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(entries) + ("\n" if entries else ""), encoding="utf-8") + + +def compare_manifest( + actual_entries: list[str], + expected_present: list[str], + expected_absent: list[str], +) -> list[str]: + """ + Compare actual manifest entries against expected present/absent paths. + + Returns a list of diff lines. Empty list means success. + """ + diffs: list[str] = [] + actual_set = set(actual_entries) + + for expected in expected_present: + if expected not in actual_set: + diffs.append(f"MISSING expected path: {expected}") + + for unexpected in expected_absent: + if unexpected in actual_set: + diffs.append(f"FOUND unexpected path: {unexpected}") + + return diffs \ No newline at end of file diff --git a/ci/e2e/run.py b/ci/e2e/run.py index dc699d05b..17ae9eb82 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -10,6 +10,7 @@ from framework.result import TestResult from framework.utils import ensure_directory, write_text_file from testcases.tc0001_basic_resync import TestCase0001BasicResync +from testcases.tc0002_sync_list_validation import TestCase0002SyncListValidation def build_test_suite() -> list: @@ -20,6 +21,7 @@ def build_test_suite() -> list: """ return [ TestCase0001BasicResync(), + TestCase0002SyncListValidation(), ] diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py new file mode 100644 index 000000000..b689a8760 --- /dev/null +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, compare_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +@dataclass +class SyncListScenario: + scenario_id: str + description: str + sync_list: list[str] + expected_present: list[str] + expected_absent: list[str] + + +class TestCase0002SyncListValidation(E2ETestCase): + """ + Test Case 0002: sync_list validation + + This test case runs multiple isolated sync_list scenarios against a fixed + test fixture and reports a single overall pass/fail result back to the E2E + harness. + """ + + case_id = "0002" + name = "sync_list validation" + description = "Validate sync_list behaviour across a scenario matrix" + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / f"tc{self.case_id}" + case_log_dir = context.logs_dir / f"tc{self.case_id}" + state_dir = context.state_dir / f"tc{self.case_id}" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + + fixture_root = case_work_dir / "fixture" + sync_root = case_work_dir / "syncroot" + + self._create_fixture_tree(fixture_root) + + scenarios = self._build_scenarios() + + failures: list[str] = [] + all_artifacts: list[str] = [] + + for scenario in scenarios: + context.log( + f"Running Test Case {self.case_id} scenario {scenario.scenario_id}: " + f"{scenario.description}" + ) + + scenario_dir = state_dir / scenario.scenario_id + scenario_log_dir = case_log_dir / scenario.scenario_id + config_dir = case_work_dir / f"config-{scenario.scenario_id}" + + reset_directory(scenario_dir) + reset_directory(scenario_log_dir) + reset_directory(config_dir) + reset_directory(sync_root) + + # Seed the local sync directory from the canonical fixture. + shutil.copytree(fixture_root, sync_root, dirs_exist_ok=True) + + sync_list_path = config_dir / "sync_list" + stdout_file = scenario_log_dir / "stdout.log" + stderr_file = scenario_log_dir / "stderr.log" + actual_manifest_file = scenario_dir / "actual_manifest.txt" + diff_file = scenario_dir / "diff.txt" + metadata_file = scenario_dir / "metadata.txt" + + write_text_file(sync_list_path, "\n".join(scenario.sync_list) + "\n") + + command = [ + context.onedrive_bin, + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--syncdir", + str(sync_root), + "--confdir", + str(config_dir), + ] + + result = run_command(command, cwd=context.repo_root) + + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + + metadata_lines = [ + f"scenario_id={scenario.scenario_id}", + f"description={scenario.description}", + f"command={command_to_string(command)}", + f"returncode={result.returncode}", + ] + write_text_file(metadata_file, "\n".join(metadata_lines) + "\n") + + all_artifacts.extend( + [ + str(sync_list_path), + str(stdout_file), + str(stderr_file), + str(metadata_file), + ] + ) + + if result.returncode != 0: + failures.append( + f"{scenario.scenario_id}: onedrive exited with non-zero status {result.returncode}" + ) + continue + + actual_manifest = build_manifest(sync_root) + write_manifest(actual_manifest_file, actual_manifest) + all_artifacts.append(str(actual_manifest_file)) + + diffs = compare_manifest( + actual_entries=actual_manifest, + expected_present=scenario.expected_present, + expected_absent=scenario.expected_absent, + ) + + if diffs: + write_text_file(diff_file, "\n".join(diffs) + "\n") + all_artifacts.append(str(diff_file)) + failures.append(f"{scenario.scenario_id}: " + "; ".join(diffs)) + + details = { + "scenario_count": len(scenarios), + "failed_scenarios": len(failures), + } + + if failures: + reason = f"{len(failures)} of {len(scenarios)} sync_list scenarios failed: " + ", ".join( + failure.split(":")[0] for failure in failures + ) + details["failures"] = failures + return TestResult.fail_result( + case_id=self.case_id, + name=self.name, + reason=reason, + artifacts=all_artifacts, + details=details, + ) + + return TestResult.pass_result( + case_id=self.case_id, + name=self.name, + artifacts=all_artifacts, + details=details, + ) + + def _create_fixture_tree(self, root: Path) -> None: + reset_directory(root) + + dirs = [ + "Backup", + "Blender", + "Documents", + "Documents/Notes", + "Documents/Notes/.config", + "Documents/Notes/temp123", + "Work", + "Work/ProjectA", + "Work/ProjectA/.gradle", + "Work/ProjectB", + "Secret_data", + "Random", + "Random/Backup", + ] + + for rel in dirs: + (root / rel).mkdir(parents=True, exist_ok=True) + + files = { + "Backup/root-backup.txt": "backup-root\n", + "Blender/scene.blend": "blend-scene\n", + "Documents/latest_report.docx": "latest report\n", + "Documents/report.pdf": "report pdf\n", + "Documents/Notes/keep.txt": "keep\n", + "Documents/Notes/.config/app.json": '{"ok": true}\n', + "Documents/Notes/temp123/ignored.txt": "ignored\n", + "Work/ProjectA/keep.txt": "project a\n", + "Work/ProjectA/.gradle/state.bin": "gradle\n", + "Work/ProjectB/latest_report.docx": "project b report\n", + "Secret_data/secret.txt": "secret\n", + "Random/Backup/nested-backup.txt": "nested backup\n", + } + + for rel, content in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def _build_scenarios(self) -> list[SyncListScenario]: + """ + First-cut scenario matrix. + + These focus on download-side validation only. + """ + return [ + SyncListScenario( + scenario_id="SL-0001", + description="root directory include with trailing slash", + sync_list=[ + "/Backup/", + ], + expected_present=[ + "Backup", + "Backup/root-backup.txt", + ], + expected_absent=[ + "Blender", + "Blender/scene.blend", + "Documents", + "Work", + "Secret_data", + "Random", + ], + ), + SyncListScenario( + scenario_id="SL-0002", + description="root include without trailing slash", + sync_list=[ + "/Blender", + ], + expected_present=[ + "Blender", + "Blender/scene.blend", + ], + expected_absent=[ + "Backup", + "Documents", + "Work", + "Secret_data", + "Random", + ], + ), + SyncListScenario( + scenario_id="SL-0003", + description="non-root include by name", + sync_list=[ + "Backup", + ], + expected_present=[ + "Backup", + "Backup/root-backup.txt", + "Random/Backup", + "Random/Backup/nested-backup.txt", + ], + expected_absent=[ + "Blender", + "Documents", + "Work", + "Secret_data", + ], + ), + SyncListScenario( + scenario_id="SL-0004", + description="include tree with nested exclusion", + sync_list=[ + "/Documents/", + "!/Documents/Notes/.config/*", + ], + expected_present=[ + "Documents", + "Documents/latest_report.docx", + "Documents/report.pdf", + "Documents/Notes", + "Documents/Notes/keep.txt", + "Documents/Notes/temp123", + "Documents/Notes/temp123/ignored.txt", + ], + expected_absent=[ + "Documents/Notes/.config", + "Documents/Notes/.config/app.json", + "Backup", + "Blender", + "Work", + "Secret_data", + "Random", + ], + ), + SyncListScenario( + scenario_id="SL-0005", + description="included tree with hidden directory excluded", + sync_list=[ + "/Work/", + "!.gradle/*", + ], + expected_present=[ + "Work", + "Work/ProjectA", + "Work/ProjectA/keep.txt", + "Work/ProjectB", + "Work/ProjectB/latest_report.docx", + ], + expected_absent=[ + "Work/ProjectA/.gradle", + "Work/ProjectA/.gradle/state.bin", + "Backup", + "Blender", + "Documents", + "Secret_data", + "Random", + ], + ), + SyncListScenario( + scenario_id="SL-0006", + description="file-specific include inside named directory", + sync_list=[ + "Documents/latest_report.docx", + ], + expected_present=[ + "Documents", + "Documents/latest_report.docx", + ], + expected_absent=[ + "Documents/report.pdf", + "Documents/Notes", + "Backup", + "Blender", + "Work", + "Secret_data", + "Random", + ], + ), + ] \ No newline at end of file From cdb35c37f87c133c6e8e066e8a26cc08a34c9b33 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 08:11:58 +1100 Subject: [PATCH 016/245] Fix token use across test cases Fix token use across test cases --- ci/e2e/framework/context.py | 42 +++++++++++++++++++ ci/e2e/testcases/tc0001_basic_resync.py | 3 ++ .../testcases/tc0002_sync_list_validation.py | 7 ++++ 3 files changed, 52 insertions(+) diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py index 66bb76d18..f95f1a2a1 100644 --- a/ci/e2e/framework/context.py +++ b/ci/e2e/framework/context.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil from dataclasses import dataclass from pathlib import Path @@ -59,6 +60,47 @@ def from_environment(cls) -> "E2EContext": def master_log_file(self) -> Path: return self.out_dir / "run.log" + @property + def default_onedrive_config_dir(self) -> Path: + """ + Return the default OneDrive config directory created by the workflow. + """ + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "").strip() + if xdg_config_home: + return Path(xdg_config_home) / "onedrive" + + home = os.environ.get("HOME", "").strip() + if not home: + raise RuntimeError("Neither XDG_CONFIG_HOME nor HOME is set") + return Path(home) / ".config" / "onedrive" + + @property + def default_refresh_token_path(self) -> Path: + return self.default_onedrive_config_dir / "refresh_token" + + def ensure_refresh_token_available(self) -> None: + if not self.default_refresh_token_path.is_file(): + raise RuntimeError( + f"Required refresh_token file not found at: {self.default_refresh_token_path}" + ) + + def bootstrap_config_dir(self, config_dir: Path) -> Path: + """ + Create a usable OneDrive config directory for a test case by copying the + existing refresh_token into the supplied config directory. + + Returns the path to the copied refresh_token. + """ + self.ensure_refresh_token_available() + ensure_directory(config_dir) + + source = self.default_refresh_token_path + destination = config_dir / "refresh_token" + shutil.copy2(source, destination) + os.chmod(destination, 0o600) + + return destination + def log(self, message: str) -> None: ensure_directory(self.out_dir) line = f"[{timestamp_now()}] {message}\n" diff --git a/ci/e2e/testcases/tc0001_basic_resync.py b/ci/e2e/testcases/tc0001_basic_resync.py index 3ee55d8ba..88b0e15ba 100644 --- a/ci/e2e/testcases/tc0001_basic_resync.py +++ b/ci/e2e/testcases/tc0001_basic_resync.py @@ -23,6 +23,9 @@ class TestCase0001BasicResync(E2ETestCase): description = "Run a basic --sync --resync --resync-auth operation and capture the outcome" def run(self, context: E2EContext) -> TestResult: + + context.ensure_refresh_token_available() + case_work_dir = context.work_root / f"tc{self.case_id}" case_log_dir = context.logs_dir / f"tc{self.case_id}" state_dir = context.state_dir / f"tc{self.case_id}" diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index b689a8760..5d92b773f 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -66,6 +66,8 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(scenario_log_dir) reset_directory(config_dir) reset_directory(sync_root) + + copied_refresh_token = context.bootstrap_config_dir(config_dir) # Seed the local sync directory from the canonical fixture. shutil.copytree(fixture_root, sync_root, dirs_exist_ok=True) @@ -110,8 +112,13 @@ def run(self, context: E2EContext) -> TestResult: str(stdout_file), str(stderr_file), str(metadata_file), + str(copied_refresh_token), ] ) + + context.log( + f"Scenario {scenario.scenario_id} bootstrapped config dir: {config_dir}" + ) if result.returncode != 0: failures.append( From 9ff830f684ef6f79b07b8fa30676a3f38dd60e2c Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 09:48:24 +1100 Subject: [PATCH 017/245] Rework Test Case 0002 Rework Test Case 0002 --- ci/e2e/framework/context.py | 9 +- ci/e2e/framework/manifest.py | 26 +- ci/e2e/testcases/tc0001_basic_resync.py | 4 +- .../testcases/tc0002_sync_list_validation.py | 477 +++++++++++++----- 4 files changed, 352 insertions(+), 164 deletions(-) diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py index f95f1a2a1..2dc86421a 100644 --- a/ci/e2e/framework/context.py +++ b/ci/e2e/framework/context.py @@ -62,9 +62,6 @@ def master_log_file(self) -> Path: @property def default_onedrive_config_dir(self) -> Path: - """ - Return the default OneDrive config directory created by the workflow. - """ xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "").strip() if xdg_config_home: return Path(xdg_config_home) / "onedrive" @@ -72,6 +69,7 @@ def default_onedrive_config_dir(self) -> Path: home = os.environ.get("HOME", "").strip() if not home: raise RuntimeError("Neither XDG_CONFIG_HOME nor HOME is set") + return Path(home) / ".config" / "onedrive" @property @@ -86,10 +84,7 @@ def ensure_refresh_token_available(self) -> None: def bootstrap_config_dir(self, config_dir: Path) -> Path: """ - Create a usable OneDrive config directory for a test case by copying the - existing refresh_token into the supplied config directory. - - Returns the path to the copied refresh_token. + Copy the existing refresh_token into a per-test/per-scenario config dir. """ self.ensure_refresh_token_available() ensure_directory(config_dir) diff --git a/ci/e2e/framework/manifest.py b/ci/e2e/framework/manifest.py index 9000e0c1c..669b620d7 100644 --- a/ci/e2e/framework/manifest.py +++ b/ci/e2e/framework/manifest.py @@ -23,28 +23,4 @@ def build_manifest(root: Path) -> list[str]: def write_manifest(path: Path, entries: list[str]) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("\n".join(entries) + ("\n" if entries else ""), encoding="utf-8") - - -def compare_manifest( - actual_entries: list[str], - expected_present: list[str], - expected_absent: list[str], -) -> list[str]: - """ - Compare actual manifest entries against expected present/absent paths. - - Returns a list of diff lines. Empty list means success. - """ - diffs: list[str] = [] - actual_set = set(actual_entries) - - for expected in expected_present: - if expected not in actual_set: - diffs.append(f"MISSING expected path: {expected}") - - for unexpected in expected_absent: - if unexpected in actual_set: - diffs.append(f"FOUND unexpected path: {unexpected}") - - return diffs \ No newline at end of file + path.write_text("\n".join(entries) + ("\n" if entries else ""), encoding="utf-8") \ No newline at end of file diff --git a/ci/e2e/testcases/tc0001_basic_resync.py b/ci/e2e/testcases/tc0001_basic_resync.py index 88b0e15ba..34ad764b6 100644 --- a/ci/e2e/testcases/tc0001_basic_resync.py +++ b/ci/e2e/testcases/tc0001_basic_resync.py @@ -24,8 +24,6 @@ class TestCase0001BasicResync(E2ETestCase): def run(self, context: E2EContext) -> TestResult: - context.ensure_refresh_token_available() - case_work_dir = context.work_root / f"tc{self.case_id}" case_log_dir = context.logs_dir / f"tc{self.case_id}" state_dir = context.state_dir / f"tc{self.case_id}" @@ -33,6 +31,8 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(case_work_dir) reset_directory(case_log_dir) reset_directory(state_dir) + + context.ensure_refresh_token_available() stdout_file = case_log_dir / "stdout.log" stderr_file = case_log_dir / "stderr.log" diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 5d92b773f..d4018c9b6 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -1,38 +1,125 @@ from __future__ import annotations +import json +import re import shutil -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from framework.base import E2ETestCase from framework.context import E2EContext -from framework.manifest import build_manifest, compare_manifest, write_manifest +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult from framework.utils import command_to_string, reset_directory, run_command, write_text_file +FIXTURE_ROOT_NAME = "ZZ_E2E_SYNC_LIST" + + +@dataclass +class ParsedEvent: + event_type: str + raw_path: str + normalised_path: str + line: str + + @dataclass class SyncListScenario: scenario_id: str description: str sync_list: list[str] - expected_present: list[str] - expected_absent: list[str] + + # Paths explicitly allowed for non-skip operations. + allowed_exact: list[str] = field(default_factory=list) + allowed_prefixes: list[str] = field(default_factory=list) + + # Paths explicitly forbidden for non-skip operations. + forbidden_exact: list[str] = field(default_factory=list) + forbidden_prefixes: list[str] = field(default_factory=list) + + # Evidence we require to prove the scenario really exercised the rule. + required_processed: list[str] = field(default_factory=list) + required_skipped: list[str] = field(default_factory=list) + + def expanded_allowed_exact(self) -> set[str]: + """ + Expand allowed exact paths to include ancestor directories. + """ + expanded: set[str] = set() + + for item in self.allowed_exact: + path = item.strip("/") + if not path: + continue + + parts = path.split("/") + for idx in range(1, len(parts) + 1): + expanded.add("/".join(parts[:idx])) + + for prefix in self.allowed_prefixes: + path = prefix.strip("/") + if not path: + continue + + parts = path.split("/") + for idx in range(1, len(parts)): + expanded.add("/".join(parts[:idx])) + + return expanded + + def path_matches_prefix(self, path: str, prefix: str) -> bool: + prefix = prefix.strip("/") + if not prefix: + return False + return path == prefix or path.startswith(prefix + "/") + + def is_forbidden(self, path: str) -> bool: + if path in self.forbidden_exact: + return True + + for prefix in self.forbidden_prefixes: + if self.path_matches_prefix(path, prefix): + return True + + return False + + def is_allowed_non_skip(self, path: str) -> bool: + if self.is_forbidden(path): + return False + + if path in self.expanded_allowed_exact(): + return True + + for prefix in self.allowed_prefixes: + if self.path_matches_prefix(path, prefix): + return True + + return False class TestCase0002SyncListValidation(E2ETestCase): """ Test Case 0002: sync_list validation - This test case runs multiple isolated sync_list scenarios against a fixed - test fixture and reports a single overall pass/fail result back to the E2E - harness. + This validates sync_list as a policy-conformance test. + + The test is considered successful when all observed sync operations + involving the fixture tree match the active sync_list rules. """ case_id = "0002" name = "sync_list validation" description = "Validate sync_list behaviour across a scenario matrix" + EVENT_PATTERNS = [ + ("skip", re.compile(r"^Skipping path - excluded by sync_list config: (.+)$")), + ("include_dir", re.compile(r"^Including path - included by sync_list config: (.+)$")), + ("include_file", re.compile(r"^Including file - included by sync_list config: (.+)$")), + ("upload_file", re.compile(r"^Uploading new file: (.+?) \.\.\.")), + ("create_remote_dir", re.compile(r"^OneDrive Client requested to create this directory online: (.+)$")), + ] + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / f"tc{self.case_id}" case_log_dir = context.logs_dir / f"tc{self.case_id}" @@ -45,6 +132,7 @@ def run(self, context: E2EContext) -> TestResult: fixture_root = case_work_dir / "fixture" sync_root = case_work_dir / "syncroot" + context.ensure_refresh_token_available() self._create_fixture_tree(fixture_root) scenarios = self._build_scenarios() @@ -66,8 +154,11 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(scenario_log_dir) reset_directory(config_dir) reset_directory(sync_root) - + copied_refresh_token = context.bootstrap_config_dir(config_dir) + context.log( + f"Scenario {scenario.scenario_id} bootstrapped config dir: {config_dir}" + ) # Seed the local sync directory from the canonical fixture. shutil.copytree(fixture_root, sync_root, dirs_exist_ok=True) @@ -75,9 +166,10 @@ def run(self, context: E2EContext) -> TestResult: sync_list_path = config_dir / "sync_list" stdout_file = scenario_log_dir / "stdout.log" stderr_file = scenario_log_dir / "stderr.log" + metadata_file = scenario_dir / "metadata.txt" + events_file = scenario_dir / "events.json" actual_manifest_file = scenario_dir / "actual_manifest.txt" diff_file = scenario_dir / "diff.txt" - metadata_file = scenario_dir / "metadata.txt" write_text_file(sync_list_path, "\n".join(scenario.sync_list) + "\n") @@ -103,6 +195,8 @@ def run(self, context: E2EContext) -> TestResult: f"description={scenario.description}", f"command={command_to_string(command)}", f"returncode={result.returncode}", + f"config_dir={config_dir}", + f"refresh_token_path={copied_refresh_token}", ] write_text_file(metadata_file, "\n".join(metadata_lines) + "\n") @@ -112,34 +206,56 @@ def run(self, context: E2EContext) -> TestResult: str(stdout_file), str(stderr_file), str(metadata_file), - str(copied_refresh_token), ] ) - - context.log( - f"Scenario {scenario.scenario_id} bootstrapped config dir: {config_dir}" - ) if result.returncode != 0: - failures.append( - f"{scenario.scenario_id}: onedrive exited with non-zero status {result.returncode}" + failure_message = ( + f"{scenario.scenario_id}: onedrive exited with non-zero status " + f"{result.returncode}" ) + failures.append(failure_message) + context.log(f"Scenario {scenario.scenario_id} FAILED: {failure_message}") continue + events = self._parse_events(result.stdout) + fixture_events = [ + event for event in events if self._is_fixture_path(event.normalised_path) + ] + + write_text_file( + events_file, + json.dumps( + [ + { + "event_type": event.event_type, + "raw_path": event.raw_path, + "normalised_path": event.normalised_path, + "line": event.line, + } + for event in fixture_events + ], + indent=2, + ) + + "\n", + ) + all_artifacts.append(str(events_file)) + actual_manifest = build_manifest(sync_root) write_manifest(actual_manifest_file, actual_manifest) all_artifacts.append(str(actual_manifest_file)) - diffs = compare_manifest( - actual_entries=actual_manifest, - expected_present=scenario.expected_present, - expected_absent=scenario.expected_absent, - ) + diffs = self._validate_scenario(scenario, fixture_events) if diffs: write_text_file(diff_file, "\n".join(diffs) + "\n") all_artifacts.append(str(diff_file)) - failures.append(f"{scenario.scenario_id}: " + "; ".join(diffs)) + + failure_message = f"{scenario.scenario_id}: " + "; ".join(diffs) + failures.append(failure_message) + context.log(f"Scenario {scenario.scenario_id} FAILED: {failure_message}") + else: + context.log(f"Scenario {scenario.scenario_id} PASSED") details = { "scenario_count": len(scenarios), @@ -147,8 +263,9 @@ def run(self, context: E2EContext) -> TestResult: } if failures: - reason = f"{len(failures)} of {len(scenarios)} sync_list scenarios failed: " + ", ".join( - failure.split(":")[0] for failure in failures + reason = ( + f"{len(failures)} of {len(scenarios)} sync_list scenarios failed: " + + ", ".join(failure.split(":")[0] for failure in failures) ) details["failures"] = failures return TestResult.fail_result( @@ -166,41 +283,155 @@ def run(self, context: E2EContext) -> TestResult: details=details, ) + def _normalise_log_path(self, raw_path: str) -> str: + path = raw_path.strip() + if path.startswith("./"): + path = path[2:] + path = path.rstrip("/") + return path + + def _parse_events(self, stdout: str) -> list[ParsedEvent]: + events: list[ParsedEvent] = [] + + for line in stdout.splitlines(): + stripped = line.strip() + + for event_type, pattern in self.EVENT_PATTERNS: + match = pattern.match(stripped) + if not match: + continue + + raw_path = match.group(1).strip() + normalised_path = self._normalise_log_path(raw_path) + + events.append( + ParsedEvent( + event_type=event_type, + raw_path=raw_path, + normalised_path=normalised_path, + line=stripped, + ) + ) + break + + return events + + def _is_fixture_path(self, path: str) -> bool: + return path == FIXTURE_ROOT_NAME or path.startswith(FIXTURE_ROOT_NAME + "/") + + def _path_matches(self, path: str, prefix: str) -> bool: + prefix = prefix.strip("/") + if not prefix: + return False + return path == prefix or path.startswith(prefix + "/") + + def _find_matching_events( + self, + events: list[ParsedEvent], + wanted_path: str, + event_type: str | None = None, + non_skip_only: bool = False, + ) -> list[ParsedEvent]: + matches: list[ParsedEvent] = [] + + for event in events: + if event_type and event.event_type != event_type: + continue + if non_skip_only and event.event_type == "skip": + continue + if self._path_matches(event.normalised_path, wanted_path): + matches.append(event) + + return matches + + def _validate_scenario( + self, + scenario: SyncListScenario, + events: list[ParsedEvent], + ) -> list[str]: + diffs: list[str] = [] + + if not events: + diffs.append("No fixture-related sync_list events were captured") + return diffs + + for event in events: + path = event.normalised_path + + if event.event_type == "skip": + if scenario.is_allowed_non_skip(path): + diffs.append( + f"Allowed path was skipped by sync_list: {path} " + f"(line: {event.line})" + ) + continue + + # Non-skip operation + if scenario.is_forbidden(path): + diffs.append( + f"Forbidden path was processed by sync_list: {path} " + f"(line: {event.line})" + ) + continue + + if not scenario.is_allowed_non_skip(path): + diffs.append( + f"Unexpected path was processed by sync_list: {path} " + f"(line: {event.line})" + ) + + for required in scenario.required_processed: + matches = self._find_matching_events(events, required, non_skip_only=True) + if not matches: + diffs.append( + f"Expected allowed processing was not observed for: {required}" + ) + + for required in scenario.required_skipped: + matches = self._find_matching_events(events, required, event_type="skip") + if not matches: + diffs.append( + f"Expected excluded skip was not observed for: {required}" + ) + + return diffs + def _create_fixture_tree(self, root: Path) -> None: reset_directory(root) dirs = [ - "Backup", - "Blender", - "Documents", - "Documents/Notes", - "Documents/Notes/.config", - "Documents/Notes/temp123", - "Work", - "Work/ProjectA", - "Work/ProjectA/.gradle", - "Work/ProjectB", - "Secret_data", - "Random", - "Random/Backup", + FIXTURE_ROOT_NAME, + f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Blender", + f"{FIXTURE_ROOT_NAME}/Documents", + f"{FIXTURE_ROOT_NAME}/Documents/Notes", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/temp123", + f"{FIXTURE_ROOT_NAME}/Work", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", + f"{FIXTURE_ROOT_NAME}/Work/ProjectB", + f"{FIXTURE_ROOT_NAME}/Secret_data", + f"{FIXTURE_ROOT_NAME}/Random", + f"{FIXTURE_ROOT_NAME}/Random/Backup", ] for rel in dirs: (root / rel).mkdir(parents=True, exist_ok=True) files = { - "Backup/root-backup.txt": "backup-root\n", - "Blender/scene.blend": "blend-scene\n", - "Documents/latest_report.docx": "latest report\n", - "Documents/report.pdf": "report pdf\n", - "Documents/Notes/keep.txt": "keep\n", - "Documents/Notes/.config/app.json": '{"ok": true}\n', - "Documents/Notes/temp123/ignored.txt": "ignored\n", - "Work/ProjectA/keep.txt": "project a\n", - "Work/ProjectA/.gradle/state.bin": "gradle\n", - "Work/ProjectB/latest_report.docx": "project b report\n", - "Secret_data/secret.txt": "secret\n", - "Random/Backup/nested-backup.txt": "nested backup\n", + f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt": "backup-root\n", + f"{FIXTURE_ROOT_NAME}/Blender/scene.blend": "blend-scene\n", + f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx": "latest report\n", + f"{FIXTURE_ROOT_NAME}/Documents/report.pdf": "report pdf\n", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt": "keep\n", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config/app.json": '{"ok": true}\n', + f"{FIXTURE_ROOT_NAME}/Documents/Notes/temp123/ignored.txt": "ignored\n", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/keep.txt": "project a\n", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle/state.bin": "gradle\n", + f"{FIXTURE_ROOT_NAME}/Work/ProjectB/latest_report.docx": "project b report\n", + f"{FIXTURE_ROOT_NAME}/Secret_data/secret.txt": "secret\n", + f"{FIXTURE_ROOT_NAME}/Random/Backup/nested-backup.txt": "nested backup\n", } for rel, content in files.items(): @@ -209,47 +440,41 @@ def _create_fixture_tree(self, root: Path) -> None: path.write_text(content, encoding="utf-8") def _build_scenarios(self) -> list[SyncListScenario]: - """ - First-cut scenario matrix. - - These focus on download-side validation only. - """ return [ SyncListScenario( scenario_id="SL-0001", description="root directory include with trailing slash", sync_list=[ - "/Backup/", + f"/{FIXTURE_ROOT_NAME}/Backup/", ], - expected_present=[ - "Backup", - "Backup/root-backup.txt", + allowed_exact=[ + f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Backup", ], - expected_absent=[ - "Blender", - "Blender/scene.blend", - "Documents", - "Work", - "Secret_data", - "Random", + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Blender", + f"{FIXTURE_ROOT_NAME}/Documents", ], ), SyncListScenario( scenario_id="SL-0002", description="root include without trailing slash", sync_list=[ - "/Blender", + f"/{FIXTURE_ROOT_NAME}/Blender", ], - expected_present=[ - "Blender", - "Blender/scene.blend", + allowed_exact=[ + f"{FIXTURE_ROOT_NAME}/Blender", + f"{FIXTURE_ROOT_NAME}/Blender/scene.blend", ], - expected_absent=[ - "Backup", - "Documents", - "Work", - "Secret_data", - "Random", + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Blender", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Documents", ], ), SyncListScenario( @@ -258,87 +483,79 @@ def _build_scenarios(self) -> list[SyncListScenario]: sync_list=[ "Backup", ], - expected_present=[ - "Backup", - "Backup/root-backup.txt", - "Random/Backup", - "Random/Backup/nested-backup.txt", + allowed_exact=[ + f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", + f"{FIXTURE_ROOT_NAME}/Random/Backup", + f"{FIXTURE_ROOT_NAME}/Random/Backup/nested-backup.txt", ], - expected_absent=[ - "Blender", - "Documents", - "Work", - "Secret_data", + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Random/Backup", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Blender", + f"{FIXTURE_ROOT_NAME}/Documents", ], ), SyncListScenario( scenario_id="SL-0004", description="include tree with nested exclusion", sync_list=[ - "/Documents/", - "!/Documents/Notes/.config/*", + f"/{FIXTURE_ROOT_NAME}/Documents/", + f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.config/*", ], - expected_present=[ - "Documents", - "Documents/latest_report.docx", - "Documents/report.pdf", - "Documents/Notes", - "Documents/Notes/keep.txt", - "Documents/Notes/temp123", - "Documents/Notes/temp123/ignored.txt", + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Documents", ], - expected_absent=[ - "Documents/Notes/.config", - "Documents/Notes/.config/app.json", - "Backup", - "Blender", - "Work", - "Secret_data", - "Random", + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Documents", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Backup", ], ), SyncListScenario( scenario_id="SL-0005", description="included tree with hidden directory excluded", sync_list=[ - "/Work/", + f"/{FIXTURE_ROOT_NAME}/Work/", "!.gradle/*", ], - expected_present=[ - "Work", - "Work/ProjectA", - "Work/ProjectA/keep.txt", - "Work/ProjectB", - "Work/ProjectB/latest_report.docx", + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Work", ], - expected_absent=[ - "Work/ProjectA/.gradle", - "Work/ProjectA/.gradle/state.bin", - "Backup", - "Blender", - "Documents", - "Secret_data", - "Random", + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Work", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", + f"{FIXTURE_ROOT_NAME}/Backup", ], ), SyncListScenario( scenario_id="SL-0006", description="file-specific include inside named directory", sync_list=[ - "Documents/latest_report.docx", + f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", ], - expected_present=[ - "Documents", - "Documents/latest_report.docx", + allowed_exact=[ + f"{FIXTURE_ROOT_NAME}/Documents", + f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", ], - expected_absent=[ - "Documents/report.pdf", - "Documents/Notes", - "Backup", - "Blender", - "Work", - "Secret_data", - "Random", + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Documents/report.pdf", + f"{FIXTURE_ROOT_NAME}/Backup", ], ), ] \ No newline at end of file From f6d86ed99fb077fe5a6d91c4a69056e6e1ee7711 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 10:10:19 +1100 Subject: [PATCH 018/245] Update tc0002_sync_list_validation.py * Align 'sync_list' rule ordering to documentation --- ci/e2e/testcases/tc0002_sync_list_validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index d4018c9b6..29a8114c4 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -502,8 +502,8 @@ def _build_scenarios(self) -> list[SyncListScenario]: scenario_id="SL-0004", description="include tree with nested exclusion", sync_list=[ - f"/{FIXTURE_ROOT_NAME}/Documents/", f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.config/*", + f"/{FIXTURE_ROOT_NAME}/Documents/", ], allowed_prefixes=[ f"{FIXTURE_ROOT_NAME}/Documents", @@ -523,8 +523,8 @@ def _build_scenarios(self) -> list[SyncListScenario]: scenario_id="SL-0005", description="included tree with hidden directory excluded", sync_list=[ - f"/{FIXTURE_ROOT_NAME}/Work/", "!.gradle/*", + f"/{FIXTURE_ROOT_NAME}/Work/", ], allowed_prefixes=[ f"{FIXTURE_ROOT_NAME}/Work", From db852818c245524eb65d18896dd6bb88a6efab8b Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 10:56:40 +1100 Subject: [PATCH 019/245] Update tc0002_sync_list_validation.py Update Test Case 0002 scenario validation --- .../testcases/tc0002_sync_list_validation.py | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 29a8114c4..0110690a0 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -44,27 +44,18 @@ class SyncListScenario: def expanded_allowed_exact(self) -> set[str]: """ - Expand allowed exact paths to include ancestor directories. + Return only the explicitly allowed exact paths. + + Do not automatically promote ancestor/container paths to required + allowed paths, because sync_list processing may legitimately skip + container directories while still including matching descendants. """ expanded: set[str] = set() for item in self.allowed_exact: path = item.strip("/") - if not path: - continue - - parts = path.split("/") - for idx in range(1, len(parts) + 1): - expanded.add("/".join(parts[:idx])) - - for prefix in self.allowed_prefixes: - path = prefix.strip("/") - if not path: - continue - - parts = path.split("/") - for idx in range(1, len(parts)): - expanded.add("/".join(parts[:idx])) + if path: + expanded.add(path) return expanded @@ -177,6 +168,7 @@ def run(self, context: E2EContext) -> TestResult: context.onedrive_bin, "--sync", "--verbose", + "--verbose", "--resync", "--resync-auth", "--syncdir", @@ -547,14 +539,13 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", ], allowed_exact=[ - f"{FIXTURE_ROOT_NAME}/Documents", f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", ], required_processed=[ f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", ], required_skipped=[ - f"{FIXTURE_ROOT_NAME}/Documents/report.pdf", + f"{FIXTURE_ROOT_NAME}/Documents/Notes", f"{FIXTURE_ROOT_NAME}/Backup", ], ), From c367b1f142db9f6c7a329cc731b338fc02418ccc Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 11:04:47 +1100 Subject: [PATCH 020/245] Update EVENT_PATTERNS to handle debug logging * Update EVENT_PATTERNS to handle debug logging --- .../testcases/tc0002_sync_list_validation.py | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 0110690a0..196129e45 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -103,12 +103,29 @@ class TestCase0002SyncListValidation(E2ETestCase): name = "sync_list validation" description = "Validate sync_list behaviour across a scenario matrix" - EVENT_PATTERNS = [ - ("skip", re.compile(r"^Skipping path - excluded by sync_list config: (.+)$")), - ("include_dir", re.compile(r"^Including path - included by sync_list config: (.+)$")), - ("include_file", re.compile(r"^Including file - included by sync_list config: (.+)$")), - ("upload_file", re.compile(r"^Uploading new file: (.+?) \.\.\.")), - ("create_remote_dir", re.compile(r"^OneDrive Client requested to create this directory online: (.+)$")), + EVENT_PATTERNS = [ + ( + "skip", + re.compile(r"^(?:DEBUG:\s+)?Skipping path - excluded by sync_list config: (.+)$"), + ), + ( + "include_dir", + re.compile(r"^(?:DEBUG:\s+)?Including path - included by sync_list config: (.+)$"), + ), + ( + "include_file", + re.compile(r"^(?:DEBUG:\s+)?Including file - included by sync_list config: (.+)$"), + ), + ( + "upload_file", + re.compile(r"^(?:DEBUG:\s+)?Uploading new file: (.+?) \.\.\."), + ), + ( + "create_remote_dir", + re.compile( + r"^(?:DEBUG:\s+)?OneDrive Client requested to create this directory online: (.+)$" + ), + ), ] def run(self, context: E2EContext) -> TestResult: From c4621df579a8162d4b88621d2458730c2c12ba62 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 11:11:45 +1100 Subject: [PATCH 021/245] Update tc0002_sync_list_validation.py Fix python indent --- ci/e2e/testcases/tc0002_sync_list_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 196129e45..34a9b6e86 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -103,7 +103,7 @@ class TestCase0002SyncListValidation(E2ETestCase): name = "sync_list validation" description = "Validate sync_list behaviour across a scenario matrix" - EVENT_PATTERNS = [ + EVENT_PATTERNS = [ ( "skip", re.compile(r"^(?:DEBUG:\s+)?Skipping path - excluded by sync_list config: (.+)$"), From 47428c0ace6099a3cb98a27a2836301e7886d005 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 12:23:38 +1100 Subject: [PATCH 022/245] Update tc0002_sync_list_validation.py Update test case 0002 --- .../testcases/tc0002_sync_list_validation.py | 73 ++++++++++++------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 34a9b6e86..c5a177de8 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -38,27 +38,10 @@ class SyncListScenario: forbidden_exact: list[str] = field(default_factory=list) forbidden_prefixes: list[str] = field(default_factory=list) - # Evidence we require to prove the scenario really exercised the rule. + # Evidence required to prove the scenario exercised the rule correctly. required_processed: list[str] = field(default_factory=list) required_skipped: list[str] = field(default_factory=list) - def expanded_allowed_exact(self) -> set[str]: - """ - Return only the explicitly allowed exact paths. - - Do not automatically promote ancestor/container paths to required - allowed paths, because sync_list processing may legitimately skip - container directories while still including matching descendants. - """ - expanded: set[str] = set() - - for item in self.allowed_exact: - path = item.strip("/") - if path: - expanded.add(path) - - return expanded - def path_matches_prefix(self, path: str, prefix: str) -> bool: prefix = prefix.strip("/") if not prefix: @@ -66,7 +49,9 @@ def path_matches_prefix(self, path: str, prefix: str) -> bool: return path == prefix or path.startswith(prefix + "/") def is_forbidden(self, path: str) -> bool: - if path in self.forbidden_exact: + path = path.strip("/") + + if path in [item.strip("/") for item in self.forbidden_exact]: return True for prefix in self.forbidden_prefixes: @@ -76,10 +61,16 @@ def is_forbidden(self, path: str) -> bool: return False def is_allowed_non_skip(self, path: str) -> bool: + """ + Determine whether a path is explicitly allowed to appear in a non-skip + event such as include/upload/create. + """ + path = path.strip("/") + if self.is_forbidden(path): return False - if path in self.expanded_allowed_exact(): + if path in [item.strip("/") for item in self.allowed_exact]: return True for prefix in self.allowed_prefixes: @@ -88,6 +79,32 @@ def is_allowed_non_skip(self, path: str) -> bool: return False + def is_allowed_container(self, path: str) -> bool: + """ + Allow container paths that may legitimately appear in logs even when the + real rule target is a descendant path. + + Examples: + - ZZ_E2E_SYNC_LIST + - ZZ_E2E_SYNC_LIST/Documents + """ + path = path.strip("/") + + if path == FIXTURE_ROOT_NAME: + return True + + for item in self.allowed_exact: + item = item.strip("/") + if item.startswith(path + "/"): + return True + + for prefix in self.allowed_prefixes: + prefix = prefix.strip("/") + if prefix.startswith(path + "/"): + return True + + return False + class TestCase0002SyncListValidation(E2ETestCase): """ @@ -368,11 +385,9 @@ def _validate_scenario( path = event.normalised_path if event.event_type == "skip": - if scenario.is_allowed_non_skip(path): - diffs.append( - f"Allowed path was skipped by sync_list: {path} " - f"(line: {event.line})" - ) + # Do not fail just because a container path was skipped. + # The logs show sync_list may skip parent/container directories + # while still including matching descendants beneath them. continue # Non-skip operation @@ -383,7 +398,7 @@ def _validate_scenario( ) continue - if not scenario.is_allowed_non_skip(path): + if not scenario.is_allowed_non_skip(path) and not scenario.is_allowed_container(path): diffs.append( f"Unexpected path was processed by sync_list: {path} " f"(line: {event.line})" @@ -521,7 +536,8 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", ], required_processed=[ - f"{FIXTURE_ROOT_NAME}/Documents", + f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", ], required_skipped=[ f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", @@ -542,7 +558,8 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", ], required_processed=[ - f"{FIXTURE_ROOT_NAME}/Work", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/keep.txt", + f"{FIXTURE_ROOT_NAME}/Work/ProjectB/latest_report.docx", ], required_skipped=[ f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", From c203440b7340bef37a690c6d88cbdc801c44c1f5 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 12:53:37 +1100 Subject: [PATCH 023/245] Update PR * Update PR --- .github/workflows/e2e-personal.yaml | 4 +- .../testcases/tc0002_sync_list_validation.py | 487 +++++++++++++++++- readme.md | 2 + 3 files changed, 490 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 0f29ca268..1e3a4318b 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -1,4 +1,4 @@ -name: E2E Personal Account Testing (push only) +name: E2E Personal Account Testing on: push: @@ -103,7 +103,7 @@ jobs: | "- Test Case \(.id // "????"): \(.name) — \(.reason // "no reason provided")"' "$f" || true) md="## ${target^} Account Testing\n" - md+="${total} Test Cases Run \n" + md+="**${total}** Test Cases Run \n" md+="**${passed}** Test Cases Passed \n" md+="**${failed}** Test Cases Failed \n\n" diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index c5a177de8..18eeff96a 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -390,7 +390,6 @@ def _validate_scenario( # while still including matching descendants beneath them. continue - # Non-skip operation if scenario.is_forbidden(path): diffs.append( f"Forbidden path was processed by sync_list: {path} " @@ -438,6 +437,60 @@ def _create_fixture_tree(self, root: Path) -> None: f"{FIXTURE_ROOT_NAME}/Secret_data", f"{FIXTURE_ROOT_NAME}/Random", f"{FIXTURE_ROOT_NAME}/Random/Backup", + f"{FIXTURE_ROOT_NAME}/Programming", + f"{FIXTURE_ROOT_NAME}/Programming/Projects", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build/intermediates", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx/tmp", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build/assets", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv/bin", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv/bin", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules/pkg", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/libraries", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/caches", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/src", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/src", ] for rel in dirs: @@ -456,6 +509,34 @@ def _create_fixture_tree(self, root: Path) -> None: f"{FIXTURE_ROOT_NAME}/Work/ProjectB/latest_report.docx": "project b report\n", f"{FIXTURE_ROOT_NAME}/Secret_data/secret.txt": "secret\n", f"{FIXTURE_ROOT_NAME}/Random/Backup/nested-backup.txt": "nested backup\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build/output.apk": "android app1 build\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build/intermediates/classes.dex": "classes dex\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx/tmp/native.o": "native object\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src/main.kt": "fun main() {}\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/build/obj.o": "android app2 build\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/.cxx/state.bin": "android app2 cxx\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/src/app.kt": "class App\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build/bundle.js": "bundle\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build/assets/chunk.js": "chunk\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/src/index.ts": "console.log('site1');\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/build/app.js": "site2 build\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/src/app.ts": "console.log('site2');\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv/bin/python": "venv python\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv/bin/python": "venv python 2\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__/main.pyc": "pyc\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src/main.py": "print('tool1')\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle/cache.bin": "gradle cache\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin/output.class": "class bytes\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java": "class Main {}\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules/pkg/index.js": "module.exports = {};\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/src/index.js": "console.log('node');\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next/cache.dat": "next cache\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/src/page.tsx": "export default function Page() {}\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/libraries/lib.xml": "\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/caches/cache.db": "cache db\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/src/App.kt": "class IdeaApp\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache/tool.cache": "misc cache\n", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/src/readme.txt": "misc src\n", } for rel, content in files.items(): @@ -583,4 +664,408 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Backup", ], ), + SyncListScenario( + scenario_id="SL-0007", + description="rooted include of Programming tree", + sync_list=[ + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src/main.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/src/index.ts", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src/main.py", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Documents", + ], + ), + SyncListScenario( + scenario_id="SL-0008", + description="exclude Android recursive build output and include Programming", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/build/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/build", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src/main.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/src/app.kt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/build", + ], + ), + SyncListScenario( + scenario_id="SL-0009", + description="exclude Android recursive .cxx content and include Programming", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/.cxx/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/.cxx", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src/main.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/src/app.kt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/.cxx", + ], + ), + SyncListScenario( + scenario_id="SL-0010", + description="exclude Web recursive build output and include Programming", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Web/**/build/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/build", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/src/index.ts", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/src/app.ts", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/build", + ], + ), + SyncListScenario( + scenario_id="SL-0011", + description="exclude .gradle anywhere and include Programming", + sync_list=[ + "!.gradle/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + ], + ), + SyncListScenario( + scenario_id="SL-0012", + description="exclude build/kotlin anywhere and include Programming", + sync_list=[ + "!build/kotlin/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + ], + ), + SyncListScenario( + scenario_id="SL-0013", + description="exclude .venv and venv anywhere and include Programming", + sync_list=[ + "!.venv/*", + "!venv/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src/main.py", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", + ], + ), + SyncListScenario( + scenario_id="SL-0014", + description="exclude common cache and vendor directories and include Programming", + sync_list=[ + "!__pycache__/*", + "!node_modules/*", + "!.next/*", + "!.idea/libraries/*", + "!.idea/caches/*", + "!.cache/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/libraries", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/caches", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src/main.py", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/src/index.js", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/src/page.tsx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/src/App.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/src/readme.txt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/libraries", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/caches", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + ], + ), + SyncListScenario( + scenario_id="SL-0015", + description="cyb3rko style complex Programming ruleset", + sync_list=[ + "!build/kotlin/*", + "!.kotlin/*", + "!venv/*", + "!.venv/*", + "!.gradle/*", + "!.idea/libraries/*", + "!.idea/caches/*", + "!.cache/*", + "!__pycache__/*", + "!node_modules/*", + "!.next/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/build/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/.cxx/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Web/**/build/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/libraries", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/caches", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src/main.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/src/app.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/src/index.ts", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/src/app.ts", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src/main.py", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/src/index.js", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/src/page.tsx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/src/App.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/src/readme.txt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/libraries", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/caches", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + ], + ), + SyncListScenario( + scenario_id="SL-0016", + description="massive mixed rule set across Programming Documents and Work", + sync_list=[ + "!build/kotlin/*", + "!.gradle/*", + "!.cache/*", + "!__pycache__/*", + "!node_modules/*", + "!.next/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/build/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/.cxx/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Web/**/build/*", + f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.config/*", + f"!/{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + f"/{FIXTURE_ROOT_NAME}/Documents/", + f"/{FIXTURE_ROOT_NAME}/Work/", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + f"{FIXTURE_ROOT_NAME}/Documents", + f"{FIXTURE_ROOT_NAME}/Work", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/keep.txt", + f"{FIXTURE_ROOT_NAME}/Work/ProjectB/latest_report.docx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src/main.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/src/index.ts", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + ], + ), + SyncListScenario( + scenario_id="SL-0017", + description="stress test kitchen sink rule set with broad include and targeted file include", + sync_list=[ + "!build/kotlin/*", + "!.kotlin/*", + "!venv/*", + "!.venv/*", + "!.gradle/*", + "!.idea/libraries/*", + "!.idea/caches/*", + "!.cache/*", + "!__pycache__/*", + "!node_modules/*", + "!.next/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/build/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/.cxx/*", + f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Web/**/build/*", + f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.config/*", + f"!/{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle/*", + f"/{FIXTURE_ROOT_NAME}/Programming", + f"/{FIXTURE_ROOT_NAME}/Documents/", + f"/{FIXTURE_ROOT_NAME}/Work/", + f"/{FIXTURE_ROOT_NAME}/Backup/", + f"{FIXTURE_ROOT_NAME}/Blender/scene.blend", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming", + f"{FIXTURE_ROOT_NAME}/Documents", + f"{FIXTURE_ROOT_NAME}/Work", + f"{FIXTURE_ROOT_NAME}/Backup", + ], + allowed_exact=[ + f"{FIXTURE_ROOT_NAME}/Blender/scene.blend", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/libraries", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/caches", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", + f"{FIXTURE_ROOT_NAME}/Secret_data", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", + f"{FIXTURE_ROOT_NAME}/Blender/scene.blend", + f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", + f"{FIXTURE_ROOT_NAME}/Work/ProjectB/latest_report.docx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src/main.kt", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/src/index.ts", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src/main.py", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Next/App1/.next", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/libraries", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Idea/App1/.idea/caches", + f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", + f"{FIXTURE_ROOT_NAME}/Secret_data", + ], + ), ] \ No newline at end of file diff --git a/readme.md b/readme.md index ea6dc3e79..1eb165746 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,8 @@ [![Build Docker Images](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/driveone/onedrive)](https://hub.docker.com/r/driveone/onedrive) +[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml) + A fully featured, free, and actively maintained Microsoft OneDrive client that seamlessly supports OneDrive Personal, OneDrive for Business, Microsoft 365 (formerly Office 365), and SharePoint document libraries. Designed for maximum flexibility and reliability, this powerful and highly configurable client works across all major Linux distributions and FreeBSD. It can also be deployed in containerised environments using Docker or Podman. Supporting both one-way and two-way synchronisation modes, the client provides secure and efficient file syncing with Microsoft OneDrive services — tailored to suit both desktop and server environments. From fee83aa5f9323e81c2a2f9923ac3e3d45f097a14 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 13:32:47 +1100 Subject: [PATCH 024/245] Update PR * Update PR --- .../testcases/tc0002_sync_list_validation.py | 66 ++++++++++++++++--- docs/end_to_end_testing.md | 8 +++ readme.md | 4 +- 3 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 docs/end_to_end_testing.md diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 18eeff96a..f69ab3ccf 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import re import shutil from dataclasses import dataclass, field @@ -385,9 +386,6 @@ def _validate_scenario( path = event.normalised_path if event.event_type == "skip": - # Do not fail just because a container path was skipped. - # The logs show sync_list may skip parent/container directories - # while still including matching descendants beneath them. continue if scenario.is_forbidden(path): @@ -419,6 +417,52 @@ def _validate_scenario( return diffs + def _safe_name_fragment(self, value: str) -> str: + return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower() or "root" + + def _dummy_filename_for_dir(self, rel_dir: str) -> str: + """ + Generate a stable, unique filename for a directory. + """ + fragment = self._safe_name_fragment(rel_dir.replace("/", "_")) + extensions = [".bin", ".dat", ".cache", ".blob"] + ext = extensions[len(fragment) % len(extensions)] + return f"zz_e2e_{fragment}{ext}" + + def _write_random_file(self, path: Path, size_bytes: int = 50 * 1024) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(os.urandom(size_bytes)) + + def _ensure_every_directory_has_direct_file( + self, + root: Path, + dirs: list[str], + existing_files: dict[str, str], + ) -> None: + """ + Ensure every created directory has at least one direct file inside it. + + If a directory already has a direct child file defined in existing_files, + do nothing for that directory. Otherwise add a 50 KiB random dummy file. + """ + dirs_set = {d.strip("/") for d in dirs} + + dirs_with_direct_files: set[str] = set() + for rel_file in existing_files.keys(): + rel_file = rel_file.strip("/") + parent = str(Path(rel_file).parent).replace("\\", "/") + if parent == ".": + parent = "" + dirs_with_direct_files.add(parent) + + for rel_dir in sorted(dirs_set): + if rel_dir in dirs_with_direct_files: + continue + + dummy_name = self._dummy_filename_for_dir(rel_dir) + dummy_path = root / rel_dir / dummy_name + self._write_random_file(dummy_path) + def _create_fixture_tree(self, root: Path) -> None: reset_directory(root) @@ -544,6 +588,8 @@ def _create_fixture_tree(self, root: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") + self._ensure_every_directory_has_direct_file(root, dirs, files) + def _build_scenarios(self) -> list[SyncListScenario]: return [ SyncListScenario( @@ -552,12 +598,12 @@ def _build_scenarios(self) -> list[SyncListScenario]: sync_list=[ f"/{FIXTURE_ROOT_NAME}/Backup/", ], - allowed_exact=[ + allowed_prefixes=[ f"{FIXTURE_ROOT_NAME}/Backup", - f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", ], required_processed=[ f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", ], required_skipped=[ f"{FIXTURE_ROOT_NAME}/Blender", @@ -570,12 +616,12 @@ def _build_scenarios(self) -> list[SyncListScenario]: sync_list=[ f"/{FIXTURE_ROOT_NAME}/Blender", ], - allowed_exact=[ + allowed_prefixes=[ f"{FIXTURE_ROOT_NAME}/Blender", - f"{FIXTURE_ROOT_NAME}/Blender/scene.blend", ], required_processed=[ f"{FIXTURE_ROOT_NAME}/Blender", + f"{FIXTURE_ROOT_NAME}/Blender/scene.blend", ], required_skipped=[ f"{FIXTURE_ROOT_NAME}/Backup", @@ -588,15 +634,15 @@ def _build_scenarios(self) -> list[SyncListScenario]: sync_list=[ "Backup", ], - allowed_exact=[ + allowed_prefixes=[ f"{FIXTURE_ROOT_NAME}/Backup", - f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", f"{FIXTURE_ROOT_NAME}/Random/Backup", - f"{FIXTURE_ROOT_NAME}/Random/Backup/nested-backup.txt", ], required_processed=[ f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", f"{FIXTURE_ROOT_NAME}/Random/Backup", + f"{FIXTURE_ROOT_NAME}/Random/Backup/nested-backup.txt", ], required_skipped=[ f"{FIXTURE_ROOT_NAME}/Blender", diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md new file mode 100644 index 000000000..ad4e8991f --- /dev/null +++ b/docs/end_to_end_testing.md @@ -0,0 +1,8 @@ +# End to End Testing of OneDrive Client for Linux + +Placeholder document that will detail all test cases and coverage + +| Test Case | Description | Details | +|---|---|---| +| 0001 | Basic Resync | - validate that the E2E framework can invoke the client\n- validate that the configured environment is sufficient to run a basic sync\n- provide a simple baseline smoke test before more advanced E2E scenarios | +| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.\n\n The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.\n\nThis test covers exclusions, inclusions, wildcard and globbing for paths and files | \ No newline at end of file diff --git a/readme.md b/readme.md index 1eb165746..c13b0953a 100644 --- a/readme.md +++ b/readme.md @@ -1,12 +1,12 @@ # OneDrive Client for Linux [![Version](https://img.shields.io/github/v/release/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) [![Release Date](https://img.shields.io/github/release-date/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) + [![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) +[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![Build Docker Images](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/driveone/onedrive)](https://hub.docker.com/r/driveone/onedrive) -[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml) - A fully featured, free, and actively maintained Microsoft OneDrive client that seamlessly supports OneDrive Personal, OneDrive for Business, Microsoft 365 (formerly Office 365), and SharePoint document libraries. Designed for maximum flexibility and reliability, this powerful and highly configurable client works across all major Linux distributions and FreeBSD. It can also be deployed in containerised environments using Docker or Podman. Supporting both one-way and two-way synchronisation modes, the client provides secure and efficient file syncing with Microsoft OneDrive services — tailored to suit both desktop and server environments. From 3522c0f64b90013394a9ae8071390d9d9402370c Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 13:50:58 +1100 Subject: [PATCH 025/245] Update PR * Update PR --- ci/e2e/testcases/tc0002_sync_list_validation.py | 2 +- docs/end_to_end_testing.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index f69ab3ccf..7fabd522b 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -902,7 +902,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: ), SyncListScenario( scenario_id="SL-0015", - description="cyb3rko style complex Programming ruleset", + description="complex style Programming ruleset", sync_list=[ "!build/kotlin/*", "!.kotlin/*", diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index ad4e8991f..494db0461 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -3,6 +3,6 @@ Placeholder document that will detail all test cases and coverage | Test Case | Description | Details | -|---|---|---| -| 0001 | Basic Resync | - validate that the E2E framework can invoke the client\n- validate that the configured environment is sufficient to run a basic sync\n- provide a simple baseline smoke test before more advanced E2E scenarios | -| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.\n\n The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.\n\nThis test covers exclusions, inclusions, wildcard and globbing for paths and files | \ No newline at end of file +|:---|:---|:---| +| 0001 | Basic Resync | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | +| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
\ No newline at end of file From a4339f9b9c4779c2e085c47b3980360ca02cb2be Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 13:57:48 +1100 Subject: [PATCH 026/245] Update allow.txt * Update spelling words --- .github/actions/spelling/allow.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index ea7db91da..27139bd4e 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -444,6 +444,7 @@ rsnapshot rsv rtud rul +ruleset runstatedir runsvdir Ruppe From ff8ebc1ceb8afa7d77eac951804bfd3ccb9e63ed Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 16:43:20 +1100 Subject: [PATCH 027/245] Add further 'sync_list' test case coverage * Add further 'sync_list' test case coverage --- .../testcases/tc0002_sync_list_validation.py | 879 ++++++++++++++---- 1 file changed, 675 insertions(+), 204 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 7fabd522b..6baf32425 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -15,6 +15,7 @@ FIXTURE_ROOT_NAME = "ZZ_E2E_SYNC_LIST" +CONFIG_FILE_NAME = "config" @dataclass @@ -31,18 +32,27 @@ class SyncListScenario: description: str sync_list: list[str] - # Paths explicitly allowed for non-skip operations. + # Standard policy validation rules allowed_exact: list[str] = field(default_factory=list) allowed_prefixes: list[str] = field(default_factory=list) - - # Paths explicitly forbidden for non-skip operations. forbidden_exact: list[str] = field(default_factory=list) forbidden_prefixes: list[str] = field(default_factory=list) - - # Evidence required to prove the scenario exercised the rule correctly. required_processed: list[str] = field(default_factory=list) required_skipped: list[str] = field(default_factory=list) + # Execution mode + execution_mode: str = "single" + + # Phase-specific config overrides + phase1_config_overrides: list[str] = field(default_factory=list) + phase2_config_overrides: list[str] = field(default_factory=list) + + # Cleanup regression expectations + expected_present_after: list[str] = field(default_factory=list) + expected_absent_after: list[str] = field(default_factory=list) + required_removed: list[str] = field(default_factory=list) + forbidden_removed: list[str] = field(default_factory=list) + def path_matches_prefix(self, path: str, prefix: str) -> bool: prefix = prefix.strip("/") if not prefix: @@ -62,10 +72,6 @@ def is_forbidden(self, path: str) -> bool: return False def is_allowed_non_skip(self, path: str) -> bool: - """ - Determine whether a path is explicitly allowed to appear in a non-skip - event such as include/upload/create. - """ path = path.strip("/") if self.is_forbidden(path): @@ -81,14 +87,6 @@ def is_allowed_non_skip(self, path: str) -> bool: return False def is_allowed_container(self, path: str) -> bool: - """ - Allow container paths that may legitimately appear in logs even when the - real rule target is a descendant path. - - Examples: - - ZZ_E2E_SYNC_LIST - - ZZ_E2E_SYNC_LIST/Documents - """ path = path.strip("/") if path == FIXTURE_ROOT_NAME: @@ -112,9 +110,11 @@ class TestCase0002SyncListValidation(E2ETestCase): Test Case 0002: sync_list validation This validates sync_list as a policy-conformance test. + The Issue #3655 scenarios additionally reproduce the reported lifecycle: - The test is considered successful when all observed sync operations - involving the fixture tree match the active sync_list rules. + 1. Seed the entire fixture tree remotely with no sync_list restrictions. + 2. Re-run with download_only + cleanup_local_files + issue-style sync_list. + 3. Validate both debug log decisions and final local filesystem state. """ case_id = "0002" @@ -146,6 +146,17 @@ class TestCase0002SyncListValidation(E2ETestCase): ), ] + REMOVAL_PATTERNS = [ + ( + "remove_local_file", + re.compile(r"^(?:DEBUG:\s+)?Removing local file: (.+)$"), + ), + ( + "remove_local_dir", + re.compile(r"^(?:DEBUG:\s+)?Removing local directory: (.+)$"), + ), + ] + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / f"tc{self.case_id}" case_log_dir = context.logs_dir / f"tc{self.case_id}" @@ -186,93 +197,57 @@ def run(self, context: E2EContext) -> TestResult: f"Scenario {scenario.scenario_id} bootstrapped config dir: {config_dir}" ) - # Seed the local sync directory from the canonical fixture. shutil.copytree(fixture_root, sync_root, dirs_exist_ok=True) + config_path = config_dir / CONFIG_FILE_NAME + base_config_text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + sync_list_path = config_dir / "sync_list" - stdout_file = scenario_log_dir / "stdout.log" - stderr_file = scenario_log_dir / "stderr.log" metadata_file = scenario_dir / "metadata.txt" - events_file = scenario_dir / "events.json" actual_manifest_file = scenario_dir / "actual_manifest.txt" diff_file = scenario_dir / "diff.txt" - write_text_file(sync_list_path, "\n".join(scenario.sync_list) + "\n") - - command = [ - context.onedrive_bin, - "--sync", - "--verbose", - "--verbose", - "--resync", - "--resync-auth", - "--syncdir", - str(sync_root), - "--confdir", - str(config_dir), - ] - - result = run_command(command, cwd=context.repo_root) - - write_text_file(stdout_file, result.stdout) - write_text_file(stderr_file, result.stderr) - metadata_lines = [ f"scenario_id={scenario.scenario_id}", f"description={scenario.description}", - f"command={command_to_string(command)}", - f"returncode={result.returncode}", f"config_dir={config_dir}", f"refresh_token_path={copied_refresh_token}", + f"execution_mode={scenario.execution_mode}", ] - write_text_file(metadata_file, "\n".join(metadata_lines) + "\n") - all_artifacts.extend( - [ - str(sync_list_path), - str(stdout_file), - str(stderr_file), - str(metadata_file), - ] - ) - - if result.returncode != 0: - failure_message = ( - f"{scenario.scenario_id}: onedrive exited with non-zero status " - f"{result.returncode}" + all_artifacts.extend([str(sync_list_path), str(metadata_file), str(actual_manifest_file)]) + + if scenario.execution_mode == "cleanup_regression": + diffs, artifacts, extra_metadata = self._run_cleanup_regression_scenario( + context=context, + scenario=scenario, + sync_root=sync_root, + config_dir=config_dir, + config_path=config_path, + base_config_text=base_config_text, + sync_list_path=sync_list_path, + scenario_dir=scenario_dir, + scenario_log_dir=scenario_log_dir, ) - failures.append(failure_message) - context.log(f"Scenario {scenario.scenario_id} FAILED: {failure_message}") - continue - - events = self._parse_events(result.stdout) - fixture_events = [ - event for event in events if self._is_fixture_path(event.normalised_path) - ] - - write_text_file( - events_file, - json.dumps( - [ - { - "event_type": event.event_type, - "raw_path": event.raw_path, - "normalised_path": event.normalised_path, - "line": event.line, - } - for event in fixture_events - ], - indent=2, + else: + diffs, artifacts, extra_metadata = self._run_standard_scenario( + context=context, + scenario=scenario, + sync_root=sync_root, + config_dir=config_dir, + config_path=config_path, + base_config_text=base_config_text, + sync_list_path=sync_list_path, + scenario_dir=scenario_dir, + scenario_log_dir=scenario_log_dir, ) - + "\n", - ) - all_artifacts.append(str(events_file)) + + all_artifacts.extend(artifacts) + metadata_lines.extend(extra_metadata) + write_text_file(metadata_file, "\n".join(metadata_lines) + "\n") actual_manifest = build_manifest(sync_root) write_manifest(actual_manifest_file, actual_manifest) - all_artifacts.append(str(actual_manifest_file)) - - diffs = self._validate_scenario(scenario, fixture_events) if diffs: write_text_file(diff_file, "\n".join(diffs) + "\n") @@ -310,10 +285,230 @@ def run(self, context: E2EContext) -> TestResult: details=details, ) + def _run_standard_scenario( + self, + context: E2EContext, + scenario: SyncListScenario, + sync_root: Path, + config_dir: Path, + config_path: Path, + base_config_text: str, + sync_list_path: Path, + scenario_dir: Path, + scenario_log_dir: Path, + ) -> tuple[list[str], list[str], list[str]]: + artifacts: list[str] = [] + metadata: list[str] = [] + + self._write_config_with_overrides(config_path, base_config_text, scenario.phase2_config_overrides) + self._write_sync_list(sync_list_path, scenario.sync_list) + + stdout_file = scenario_log_dir / "stdout.log" + stderr_file = scenario_log_dir / "stderr.log" + events_file = scenario_dir / "events.json" + + command = self._build_sync_command(context, sync_root, config_dir) + result = run_command(command, cwd=context.repo_root) + + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + artifacts.extend([str(stdout_file), str(stderr_file), str(events_file)]) + metadata.extend( + [ + f"command={command_to_string(command)}", + f"returncode={result.returncode}", + ] + ) + + if result.returncode != 0: + return [ + f"onedrive exited with non-zero status {result.returncode}" + ], artifacts, metadata + + events = self._parse_events(result.stdout) + fixture_events = [event for event in events if self._is_fixture_path(event.normalised_path)] + + write_text_file( + events_file, + json.dumps( + [ + { + "event_type": event.event_type, + "raw_path": event.raw_path, + "normalised_path": event.normalised_path, + "line": event.line, + } + for event in fixture_events + ], + indent=2, + ) + + "\n", + ) + + diffs = self._validate_scenario(scenario, fixture_events) + return diffs, artifacts, metadata + + def _run_cleanup_regression_scenario( + self, + context: E2EContext, + scenario: SyncListScenario, + sync_root: Path, + config_dir: Path, + config_path: Path, + base_config_text: str, + sync_list_path: Path, + scenario_dir: Path, + scenario_log_dir: Path, + ) -> tuple[list[str], list[str], list[str]]: + artifacts: list[str] = [] + metadata: list[str] = [] + diffs: list[str] = [] + + phase1_stdout = scenario_log_dir / "phase1_stdout.log" + phase1_stderr = scenario_log_dir / "phase1_stderr.log" + phase1_manifest = scenario_dir / "phase1_manifest.txt" + phase2_stdout = scenario_log_dir / "phase2_stdout.log" + phase2_stderr = scenario_log_dir / "phase2_stderr.log" + phase2_events_file = scenario_dir / "phase2_events.json" + phase2_removals_file = scenario_dir / "phase2_removals.json" + pre_cleanup_manifest = scenario_dir / "pre_cleanup_manifest.txt" + post_cleanup_manifest = scenario_dir / "post_cleanup_manifest.txt" + + artifacts.extend( + [ + str(phase1_stdout), + str(phase1_stderr), + str(phase1_manifest), + str(phase2_stdout), + str(phase2_stderr), + str(phase2_events_file), + str(phase2_removals_file), + str(pre_cleanup_manifest), + str(post_cleanup_manifest), + ] + ) + + # Phase 1: unrestricted seed to create the full remote dataset. + self._write_config_with_overrides(config_path, base_config_text, scenario.phase1_config_overrides) + if sync_list_path.exists(): + sync_list_path.unlink() + + phase1_command = self._build_sync_command(context, sync_root, config_dir) + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + metadata.extend( + [ + f"phase1_command={command_to_string(phase1_command)}", + f"phase1_returncode={phase1_result.returncode}", + ] + ) + + if phase1_result.returncode != 0: + diffs.append(f"Phase 1 unrestricted seed failed with status {phase1_result.returncode}") + return diffs, artifacts, metadata + + write_manifest(phase1_manifest, build_manifest(sync_root)) + write_manifest(pre_cleanup_manifest, build_manifest(sync_root)) + + # Phase 2: issue-style restrictive cleanup. + self._write_config_with_overrides(config_path, base_config_text, scenario.phase2_config_overrides) + self._write_sync_list(sync_list_path, scenario.sync_list) + + phase2_command = self._build_sync_command(context, sync_root, config_dir) + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + metadata.extend( + [ + f"phase2_command={command_to_string(phase2_command)}", + f"phase2_returncode={phase2_result.returncode}", + ] + ) + + if phase2_result.returncode != 0: + diffs.append(f"Phase 2 cleanup validation failed with status {phase2_result.returncode}") + return diffs, artifacts, metadata + + phase2_events = self._parse_events(phase2_result.stdout) + fixture_events = [event for event in phase2_events if self._is_fixture_path(event.normalised_path)] + removals = self._parse_removals(phase2_result.stdout) + fixture_removals = [event for event in removals if self._is_fixture_path(event.normalised_path)] + + write_text_file( + phase2_events_file, + json.dumps( + [ + { + "event_type": event.event_type, + "raw_path": event.raw_path, + "normalised_path": event.normalised_path, + "line": event.line, + } + for event in fixture_events + ], + indent=2, + ) + + "\n", + ) + write_text_file( + phase2_removals_file, + json.dumps( + [ + { + "event_type": event.event_type, + "raw_path": event.raw_path, + "normalised_path": event.normalised_path, + "line": event.line, + } + for event in fixture_removals + ], + indent=2, + ) + + "\n", + ) + write_manifest(post_cleanup_manifest, build_manifest(sync_root)) + + diffs.extend(self._validate_scenario(scenario, fixture_events)) + diffs.extend(self._validate_cleanup_expectations(scenario, sync_root, fixture_removals)) + + return diffs, artifacts, metadata + + def _build_sync_command(self, context: E2EContext, sync_root: Path, config_dir: Path) -> list[str]: + return [ + context.onedrive_bin, + "--sync", + "--verbose", + "--verbose", + "--resync", + "--resync-auth", + "--syncdir", + str(sync_root), + "--confdir", + str(config_dir), + ] + + def _write_config_with_overrides( + self, + config_path: Path, + base_config_text: str, + overrides: list[str], + ) -> None: + text = base_config_text.rstrip("\n") + if overrides: + text += "\n\n# tc0002 scenario overrides\n" + "\n".join(overrides) + if text and not text.endswith("\n"): + text += "\n" + write_text_file(config_path, text) + + def _write_sync_list(self, sync_list_path: Path, sync_list: list[str]) -> None: + write_text_file(sync_list_path, "\n".join(sync_list) + "\n") + def _normalise_log_path(self, raw_path: str) -> str: path = raw_path.strip() if path.startswith("./"): path = path[2:] + path = path.replace("\\", "/") path = path.rstrip("/") return path @@ -343,6 +538,31 @@ def _parse_events(self, stdout: str) -> list[ParsedEvent]: return events + def _parse_removals(self, stdout: str) -> list[ParsedEvent]: + removals: list[ParsedEvent] = [] + + for line in stdout.splitlines(): + stripped = line.strip() + + for event_type, pattern in self.REMOVAL_PATTERNS: + match = pattern.match(stripped) + if not match: + continue + + raw_path = match.group(1).strip() + normalised_path = self._normalise_log_path(raw_path) + removals.append( + ParsedEvent( + event_type=event_type, + raw_path=raw_path, + normalised_path=normalised_path, + line=stripped, + ) + ) + break + + return removals + def _is_fixture_path(self, path: str) -> bool: return path == FIXTURE_ROOT_NAME or path.startswith(FIXTURE_ROOT_NAME + "/") @@ -417,13 +637,38 @@ def _validate_scenario( return diffs + def _validate_cleanup_expectations( + self, + scenario: SyncListScenario, + sync_root: Path, + removals: list[ParsedEvent], + ) -> list[str]: + diffs: list[str] = [] + + for rel_path in scenario.expected_present_after: + if not (sync_root / rel_path).exists(): + diffs.append(f"Expected path missing after cleanup: {rel_path}") + + for rel_path in scenario.expected_absent_after: + if (sync_root / rel_path).exists(): + diffs.append(f"Excluded path still exists after cleanup: {rel_path}") + + for rel_path in scenario.required_removed: + matches = self._find_matching_events(removals, rel_path) + if not matches: + diffs.append(f"Expected local removal not observed for: {rel_path}") + + for rel_path in scenario.forbidden_removed: + matches = self._find_matching_events(removals, rel_path) + if matches: + diffs.append(f"Unexpected local removal observed for: {rel_path}") + + return diffs + def _safe_name_fragment(self, value: str) -> str: return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower() or "root" def _dummy_filename_for_dir(self, rel_dir: str) -> str: - """ - Generate a stable, unique filename for a directory. - """ fragment = self._safe_name_fragment(rel_dir.replace("/", "_")) extensions = [".bin", ".dat", ".cache", ".blob"] ext = extensions[len(fragment) % len(extensions)] @@ -439,12 +684,6 @@ def _ensure_every_directory_has_direct_file( dirs: list[str], existing_files: dict[str, str], ) -> None: - """ - Ensure every created directory has at least one direct file inside it. - - If a directory already has a direct child file defined in existing_files, - do nothing for that directory. Otherwise add a 50 KiB random dummy file. - """ dirs_set = {d.strip("/") for d in dirs} dirs_with_direct_files: set[str] = set() @@ -471,8 +710,11 @@ def _create_fixture_tree(self, root: Path) -> None: f"{FIXTURE_ROOT_NAME}/Backup", f"{FIXTURE_ROOT_NAME}/Blender", f"{FIXTURE_ROOT_NAME}/Documents", + f"{FIXTURE_ROOT_NAME}/Documents/.Libraries", + f"{FIXTURE_ROOT_NAME}/Documents/.Templates", f"{FIXTURE_ROOT_NAME}/Documents/Notes", f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", f"{FIXTURE_ROOT_NAME}/Documents/Notes/temp123", f"{FIXTURE_ROOT_NAME}/Work", f"{FIXTURE_ROOT_NAME}/Work/ProjectA", @@ -481,6 +723,18 @@ def _create_fixture_tree(self, root: Path) -> None: f"{FIXTURE_ROOT_NAME}/Secret_data", f"{FIXTURE_ROOT_NAME}/Random", f"{FIXTURE_ROOT_NAME}/Random/Backup", + f"{FIXTURE_ROOT_NAME}/Wallpapers", + f"{FIXTURE_ROOT_NAME}/0.1.Backups", + f"{FIXTURE_ROOT_NAME}/1.0.Resources", + f"{FIXTURE_ROOT_NAME}/Projects", + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Audio/Samples", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + f"{FIXTURE_ROOT_NAME}/Projects/Video/Renders", + f"{FIXTURE_ROOT_NAME}/Projects/Code", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source", f"{FIXTURE_ROOT_NAME}/Programming", f"{FIXTURE_ROOT_NAME}/Programming/Projects", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android", @@ -545,14 +799,26 @@ def _create_fixture_tree(self, root: Path) -> None: f"{FIXTURE_ROOT_NAME}/Blender/scene.blend": "blend-scene\n", f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx": "latest report\n", f"{FIXTURE_ROOT_NAME}/Documents/report.pdf": "report pdf\n", + f"{FIXTURE_ROOT_NAME}/Documents/.Libraries/library-index.txt": "libraries\n", + f"{FIXTURE_ROOT_NAME}/Documents/.Templates/base-template.dotx": "template\n", f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt": "keep\n", f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config/app.json": '{"ok": true}\n', + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash/old.txt": "old\n", f"{FIXTURE_ROOT_NAME}/Documents/Notes/temp123/ignored.txt": "ignored\n", f"{FIXTURE_ROOT_NAME}/Work/ProjectA/keep.txt": "project a\n", f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle/state.bin": "gradle\n", f"{FIXTURE_ROOT_NAME}/Work/ProjectB/latest_report.docx": "project b report\n", f"{FIXTURE_ROOT_NAME}/Secret_data/secret.txt": "secret\n", f"{FIXTURE_ROOT_NAME}/Random/Backup/nested-backup.txt": "nested backup\n", + f"{FIXTURE_ROOT_NAME}/Wallpapers/wallpaper1.jpg": "wallpaper\n", + f"{FIXTURE_ROOT_NAME}/0.1.Backups/archive1.bin": "archive\n", + f"{FIXTURE_ROOT_NAME}/1.0.Resources/resource1.dat": "resource\n", + f"{FIXTURE_ROOT_NAME}/Projects/Audio/song.wav": "audio\n", + f"{FIXTURE_ROOT_NAME}/Projects/Audio/Samples/sample.wav": "sample\n", + f"{FIXTURE_ROOT_NAME}/Projects/Video/movie.mp4": "video\n", + f"{FIXTURE_ROOT_NAME}/Projects/Video/Renders/render.mov": "render\n", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav": "export wav\n", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt": "source\n", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build/output.apk": "android app1 build\n", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build/intermediates/classes.dex": "classes dex\n", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx/tmp/native.o": "native object\n", @@ -595,12 +861,8 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0001", description="root directory include with trailing slash", - sync_list=[ - f"/{FIXTURE_ROOT_NAME}/Backup/", - ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Backup", - ], + sync_list=[f"/{FIXTURE_ROOT_NAME}/Backup/"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Backup"], required_processed=[ f"{FIXTURE_ROOT_NAME}/Backup", f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", @@ -613,12 +875,8 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0002", description="root include without trailing slash", - sync_list=[ - f"/{FIXTURE_ROOT_NAME}/Blender", - ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Blender", - ], + sync_list=[f"/{FIXTURE_ROOT_NAME}/Blender"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Blender"], required_processed=[ f"{FIXTURE_ROOT_NAME}/Blender", f"{FIXTURE_ROOT_NAME}/Blender/scene.blend", @@ -631,9 +889,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0003", description="non-root include by name", - sync_list=[ - "Backup", - ], + sync_list=["Backup"], allowed_prefixes=[ f"{FIXTURE_ROOT_NAME}/Backup", f"{FIXTURE_ROOT_NAME}/Random/Backup", @@ -656,12 +912,8 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.config/*", f"/{FIXTURE_ROOT_NAME}/Documents/", ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Documents", - ], - forbidden_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", - ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Documents"], + forbidden_prefixes=[f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config"], required_processed=[ f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", @@ -674,16 +926,9 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0005", description="included tree with hidden directory excluded", - sync_list=[ - "!.gradle/*", - f"/{FIXTURE_ROOT_NAME}/Work/", - ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Work", - ], - forbidden_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle", - ], + sync_list=["!.gradle/*", f"/{FIXTURE_ROOT_NAME}/Work/"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Work"], + forbidden_prefixes=[f"{FIXTURE_ROOT_NAME}/Work/ProjectA/.gradle"], required_processed=[ f"{FIXTURE_ROOT_NAME}/Work/ProjectA/keep.txt", f"{FIXTURE_ROOT_NAME}/Work/ProjectB/latest_report.docx", @@ -696,15 +941,9 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0006", description="file-specific include inside named directory", - sync_list=[ - f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", - ], - allowed_exact=[ - f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", - ], - required_processed=[ - f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx", - ], + sync_list=[f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx"], + allowed_exact=[f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx"], + required_processed=[f"{FIXTURE_ROOT_NAME}/Documents/latest_report.docx"], required_skipped=[ f"{FIXTURE_ROOT_NAME}/Documents/Notes", f"{FIXTURE_ROOT_NAME}/Backup", @@ -713,12 +952,8 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0007", description="rooted include of Programming tree", - sync_list=[ - f"/{FIXTURE_ROOT_NAME}/Programming", - ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], + sync_list=[f"/{FIXTURE_ROOT_NAME}/Programming"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], required_processed=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/src/main.kt", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/src/index.ts", @@ -736,9 +971,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/build/*", f"/{FIXTURE_ROOT_NAME}/Programming", ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/build", @@ -759,9 +992,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Android/**/.cxx/*", f"/{FIXTURE_ROOT_NAME}/Programming", ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/.cxx", @@ -782,9 +1013,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Web/**/build/*", f"/{FIXTURE_ROOT_NAME}/Programming", ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site1/build", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Web/Site2/build", @@ -801,61 +1030,31 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0011", description="exclude .gradle anywhere and include Programming", - sync_list=[ - "!.gradle/*", - f"/{FIXTURE_ROOT_NAME}/Programming", - ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], - forbidden_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", - ], - required_processed=[ - f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java", - ], - required_skipped=[ - f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle", - ], + sync_list=["!.gradle/*", f"/{FIXTURE_ROOT_NAME}/Programming"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], + forbidden_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle"], + required_processed=[f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java"], + required_skipped=[f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/.gradle"], ), SyncListScenario( scenario_id="SL-0012", description="exclude build/kotlin anywhere and include Programming", - sync_list=[ - "!build/kotlin/*", - f"/{FIXTURE_ROOT_NAME}/Programming", - ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], - forbidden_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", - ], - required_processed=[ - f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java", - ], - required_skipped=[ - f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin", - ], + sync_list=["!build/kotlin/*", f"/{FIXTURE_ROOT_NAME}/Programming"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], + forbidden_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin"], + required_processed=[f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/src/Main.java"], + required_skipped=[f"{FIXTURE_ROOT_NAME}/Programming/Projects/Java/Project1/build/kotlin"], ), SyncListScenario( scenario_id="SL-0013", description="exclude .venv and venv anywhere and include Programming", - sync_list=[ - "!.venv/*", - "!venv/*", - f"/{FIXTURE_ROOT_NAME}/Programming", - ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], + sync_list=["!.venv/*", "!venv/*", f"/{FIXTURE_ROOT_NAME}/Programming"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", ], - required_processed=[ - f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src/main.py", - ], + required_processed=[f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/src/main.py"], required_skipped=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/.venv", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/venv", @@ -873,9 +1072,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: "!.cache/*", f"/{FIXTURE_ROOT_NAME}/Programming", ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Node/App1/node_modules", @@ -920,9 +1117,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"!/{FIXTURE_ROOT_NAME}/Programming/Projects/Web/**/build/*", f"/{FIXTURE_ROOT_NAME}/Programming", ], - allowed_prefixes=[ - f"{FIXTURE_ROOT_NAME}/Programming", - ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Programming"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App2/build", @@ -1061,9 +1256,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Work", f"{FIXTURE_ROOT_NAME}/Backup", ], - allowed_exact=[ - f"{FIXTURE_ROOT_NAME}/Blender/scene.blend", - ], + allowed_exact=[f"{FIXTURE_ROOT_NAME}/Blender/scene.blend"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx", @@ -1114,4 +1307,282 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Secret_data", ], ), - ] \ No newline at end of file + SyncListScenario( + scenario_id="SL-0018", + description="Issue #3655 exact trailing slash configuration with cleanup validation", + execution_mode="cleanup_regression", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.config/", + f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.trash/", + f"!/{FIXTURE_ROOT_NAME}/Projects/Audio/", + f"!/{FIXTURE_ROOT_NAME}/Projects/Video/", + f"/{FIXTURE_ROOT_NAME}/Projects/", + f"/{FIXTURE_ROOT_NAME}/Documents/.Libraries/", + f"/{FIXTURE_ROOT_NAME}/Documents/.Templates/", + f"/{FIXTURE_ROOT_NAME}/Documents/Notes/", + f"/{FIXTURE_ROOT_NAME}/Wallpapers/", + f"/{FIXTURE_ROOT_NAME}/0.1.Backups/", + f"/{FIXTURE_ROOT_NAME}/1.0.Resources/", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Projects", + f"{FIXTURE_ROOT_NAME}/Documents/.Libraries", + f"{FIXTURE_ROOT_NAME}/Documents/.Templates", + f"{FIXTURE_ROOT_NAME}/Documents/Notes", + f"{FIXTURE_ROOT_NAME}/Wallpapers", + f"{FIXTURE_ROOT_NAME}/0.1.Backups", + f"{FIXTURE_ROOT_NAME}/1.0.Resources", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + f"{FIXTURE_ROOT_NAME}/Documents/.Libraries/library-index.txt", + f"{FIXTURE_ROOT_NAME}/Documents/.Templates/base-template.dotx", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", + f"{FIXTURE_ROOT_NAME}/Wallpapers/wallpaper1.jpg", + f"{FIXTURE_ROOT_NAME}/0.1.Backups/archive1.bin", + f"{FIXTURE_ROOT_NAME}/1.0.Resources/resource1.dat", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + phase1_config_overrides=[ + 'download_only = "false"', + 'cleanup_local_files = "false"', + ], + phase2_config_overrides=[ + 'download_only = "true"', + 'cleanup_local_files = "true"', + ], + expected_present_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + f"{FIXTURE_ROOT_NAME}/Documents/.Libraries/library-index.txt", + f"{FIXTURE_ROOT_NAME}/Documents/.Templates/base-template.dotx", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", + f"{FIXTURE_ROOT_NAME}/Wallpapers/wallpaper1.jpg", + f"{FIXTURE_ROOT_NAME}/0.1.Backups/archive1.bin", + f"{FIXTURE_ROOT_NAME}/1.0.Resources/resource1.dat", + ], + expected_absent_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Audio/song.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + f"{FIXTURE_ROOT_NAME}/Projects/Video/movie.mp4", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config/app.json", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash/old.txt", + ], + required_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + ], + forbidden_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", + f"{FIXTURE_ROOT_NAME}/Wallpapers/wallpaper1.jpg", + ], + ), + SyncListScenario( + scenario_id="SL-0019", + description="Issue #3655 no trailing slash workaround with cleanup validation", + execution_mode="cleanup_regression", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + f"!/{FIXTURE_ROOT_NAME}/Projects/Audio", + f"!/{FIXTURE_ROOT_NAME}/Projects/Video", + f"/{FIXTURE_ROOT_NAME}/Projects", + f"/{FIXTURE_ROOT_NAME}/Documents/.Libraries", + f"/{FIXTURE_ROOT_NAME}/Documents/.Templates", + f"/{FIXTURE_ROOT_NAME}/Documents/Notes", + f"/{FIXTURE_ROOT_NAME}/Wallpapers", + f"/{FIXTURE_ROOT_NAME}/0.1.Backups", + f"/{FIXTURE_ROOT_NAME}/1.0.Resources", + ], + allowed_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Projects", + f"{FIXTURE_ROOT_NAME}/Documents/.Libraries", + f"{FIXTURE_ROOT_NAME}/Documents/.Templates", + f"{FIXTURE_ROOT_NAME}/Documents/Notes", + f"{FIXTURE_ROOT_NAME}/Wallpapers", + f"{FIXTURE_ROOT_NAME}/0.1.Backups", + f"{FIXTURE_ROOT_NAME}/1.0.Resources", + ], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + f"{FIXTURE_ROOT_NAME}/Documents/.Libraries/library-index.txt", + f"{FIXTURE_ROOT_NAME}/Documents/.Templates/base-template.dotx", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", + f"{FIXTURE_ROOT_NAME}/Wallpapers/wallpaper1.jpg", + f"{FIXTURE_ROOT_NAME}/0.1.Backups/archive1.bin", + f"{FIXTURE_ROOT_NAME}/1.0.Resources/resource1.dat", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + phase1_config_overrides=[ + 'download_only = "false"', + 'cleanup_local_files = "false"', + ], + phase2_config_overrides=[ + 'download_only = "true"', + 'cleanup_local_files = "true"', + ], + expected_present_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + f"{FIXTURE_ROOT_NAME}/Documents/.Libraries/library-index.txt", + f"{FIXTURE_ROOT_NAME}/Documents/.Templates/base-template.dotx", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", + f"{FIXTURE_ROOT_NAME}/Wallpapers/wallpaper1.jpg", + f"{FIXTURE_ROOT_NAME}/0.1.Backups/archive1.bin", + f"{FIXTURE_ROOT_NAME}/1.0.Resources/resource1.dat", + ], + expected_absent_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Audio/song.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + f"{FIXTURE_ROOT_NAME}/Projects/Video/movie.mp4", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config/app.json", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash/old.txt", + ], + required_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/.trash", + ], + forbidden_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + f"{FIXTURE_ROOT_NAME}/Documents/Notes/keep.txt", + f"{FIXTURE_ROOT_NAME}/Wallpapers/wallpaper1.jpg", + ], + ), + SyncListScenario( + scenario_id="SL-0020", + description="Focused trailing slash Projects regression for sibling path survival", + execution_mode="cleanup_regression", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Projects/Audio/", + f"!/{FIXTURE_ROOT_NAME}/Projects/Video/", + f"/{FIXTURE_ROOT_NAME}/Projects/", + ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + phase1_config_overrides=[ + 'download_only = "false"', + 'cleanup_local_files = "false"', + ], + phase2_config_overrides=[ + 'download_only = "true"', + 'cleanup_local_files = "true"', + ], + expected_present_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + expected_absent_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + forbidden_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + ], + ), + SyncListScenario( + scenario_id="SL-0021", + description="Focused no trailing slash Projects regression for sibling path survival", + execution_mode="cleanup_regression", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Projects/Audio", + f"!/{FIXTURE_ROOT_NAME}/Projects/Video", + f"/{FIXTURE_ROOT_NAME}/Projects", + ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + phase1_config_overrides=[ + 'download_only = "false"', + 'cleanup_local_files = "false"', + ], + phase2_config_overrides=[ + 'download_only = "true"', + 'cleanup_local_files = "true"', + ], + expected_present_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + expected_absent_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + forbidden_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + ], + ), + ] From 5a06159a287e87b8492cd6fe660bc9509cf4f1cc Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 8 Mar 2026 21:10:08 +1100 Subject: [PATCH 028/245] Add 'config' file to ensure 'safeBackup' files are not generated * Add 'config' file to ensure 'safeBackup' files are not generated --- .../testcases/tc0002_sync_list_validation.py | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 6baf32425..709fcce31 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -193,15 +193,23 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(sync_root) copied_refresh_token = context.bootstrap_config_dir(config_dir) + + config_path = config_dir / CONFIG_FILE_NAME + existing_config_text = ( + config_path.read_text(encoding="utf-8") if config_path.exists() else "" + ) + base_config_text = self._build_base_config_text(existing_config_text) + write_text_file(config_path, base_config_text) + context.log( f"Scenario {scenario.scenario_id} bootstrapped config dir: {config_dir}" ) + context.log( + f"Scenario {scenario.scenario_id} wrote tc0002 base config: {config_path}" + ) shutil.copytree(fixture_root, sync_root, dirs_exist_ok=True) - config_path = config_dir / CONFIG_FILE_NAME - base_config_text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" - sync_list_path = config_dir / "sync_list" metadata_file = scenario_dir / "metadata.txt" actual_manifest_file = scenario_dir / "actual_manifest.txt" @@ -211,11 +219,19 @@ def run(self, context: E2EContext) -> TestResult: f"scenario_id={scenario.scenario_id}", f"description={scenario.description}", f"config_dir={config_dir}", + f"config_path={config_path}", f"refresh_token_path={copied_refresh_token}", f"execution_mode={scenario.execution_mode}", ] - all_artifacts.extend([str(sync_list_path), str(metadata_file), str(actual_manifest_file)]) + all_artifacts.extend( + [ + str(config_path), + str(sync_list_path), + str(metadata_file), + str(actual_manifest_file), + ] + ) if scenario.execution_mode == "cleanup_regression": diffs, artifacts, extra_metadata = self._run_cleanup_regression_scenario( @@ -285,6 +301,25 @@ def run(self, context: E2EContext) -> TestResult: details=details, ) + def _build_base_config_text(self, existing_config_text: str = "") -> str: + sections: list[str] = [] + + existing_config_text = existing_config_text.strip("\n") + if existing_config_text.strip(): + sections.append(existing_config_text) + + sections.append( + "\n".join( + [ + "# tc0002 base config", + 'bypass_data_preservation = "true"', + 'skip_file = "~*|.~*|*.tmp|*.swp|*.partial|*-safeBackup-*"', + ] + ) + ) + + return "\n\n".join(sections).rstrip("\n") + "\n" + def _run_standard_scenario( self, context: E2EContext, @@ -1585,4 +1620,4 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", ], ), - ] + ] \ No newline at end of file From 0e04e2742bc6daf005a4504af3f627a3e0422879 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 9 Mar 2026 06:45:22 +1100 Subject: [PATCH 029/245] Update SL-0003 * Update SL-0003 --- ci/e2e/testcases/tc0002_sync_list_validation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 709fcce31..ae8910059 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -928,12 +928,15 @@ def _build_scenarios(self) -> list[SyncListScenario]: allowed_prefixes=[ f"{FIXTURE_ROOT_NAME}/Backup", f"{FIXTURE_ROOT_NAME}/Random/Backup", + f"{FIXTURE_ROOT_NAME}/0.1.Backups", ], required_processed=[ f"{FIXTURE_ROOT_NAME}/Backup", f"{FIXTURE_ROOT_NAME}/Backup/root-backup.txt", f"{FIXTURE_ROOT_NAME}/Random/Backup", f"{FIXTURE_ROOT_NAME}/Random/Backup/nested-backup.txt", + f"{FIXTURE_ROOT_NAME}/0.1.Backups", + f"{FIXTURE_ROOT_NAME}/0.1.Backups/archive1.bin", ], required_skipped=[ f"{FIXTURE_ROOT_NAME}/Blender", From f14d62ea18db63dbc4248757d810635c28567a22 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 9 Mar 2026 07:06:31 +1100 Subject: [PATCH 030/245] Update PR * Update end_to_end_testing.md with additional 'sync_list' scenarios * Add end_to_end_testing.md to Makefile.in --- Makefile.in | 2 +- docs/end_to_end_testing.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile.in b/Makefile.in index 67391e1a7..cc5f7fa4a 100644 --- a/Makefile.in +++ b/Makefile.in @@ -58,7 +58,7 @@ endif system_unit_files = contrib/systemd/onedrive@.service user_unit_files = contrib/systemd/onedrive.service -DOCFILES = readme.md config LICENSE changelog.md docs/advanced-usage.md docs/application-config-options.md docs/application-security.md docs/business-shared-items.md docs/client-architecture.md docs/contributing.md docs/docker.md docs/install.md docs/national-cloud-deployments.md docs/podman.md docs/privacy-policy.md docs/sharepoint-libraries.md docs/terms-of-service.md docs/ubuntu-package-install.md docs/usage.md docs/known-issues.md docs/webhooks.md +DOCFILES = readme.md config LICENSE changelog.md docs/advanced-usage.md docs/application-config-options.md docs/application-security.md docs/business-shared-items.md docs/client-architecture.md docs/contributing.md docs/docker.md docs/install.md docs/national-cloud-deployments.md docs/podman.md docs/privacy-policy.md docs/sharepoint-libraries.md docs/terms-of-service.md docs/ubuntu-package-install.md docs/usage.md docs/known-issues.md docs/webhooks.md docs/end_to_end_testing.md ifneq ("$(wildcard /etc/redhat-release)","") RHEL = $(shell cat /etc/redhat-release | grep -E "(Red Hat Enterprise Linux|CentOS|AlmaLinux)" | wc -l) diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 494db0461..d558689b6 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -3,6 +3,6 @@ Placeholder document that will detail all test cases and coverage | Test Case | Description | Details | -|:---|:---|:---| +|:---------|:---|:---| | 0001 | Basic Resync | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | -| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
\ No newline at end of file +| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include

- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
| \ No newline at end of file From 071db82ea09fa81326b2e135d006fbc61551cf13 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 9 Mar 2026 07:38:56 +1100 Subject: [PATCH 031/245] Update end_to_end_testing.md * Update doc --- docs/end_to_end_testing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index d558689b6..ca58c8729 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -2,7 +2,7 @@ Placeholder document that will detail all test cases and coverage -| Test Case | Description | Details | -|:---------|:---|:---| +|
Test Case
| Description | Details | +|:---|:---|:---| | 0001 | Basic Resync | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | -| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include

- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
| \ No newline at end of file +| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
| \ No newline at end of file From 03683a9243307879e8c0d5434ef7a06d23a20f32 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 9 Mar 2026 08:21:04 +1100 Subject: [PATCH 032/245] Update PR * Update PR --- docs/end_to_end_testing.md | 2 +- src/sync.d | 104 +++++++++++++++++++++++++++---------- 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index ca58c8729..18b68c79f 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -2,7 +2,7 @@ Placeholder document that will detail all test cases and coverage -|
Test Case
| Description | Details | +| Test Case | Description | Details | |:---|:---|:---| | 0001 | Basic Resync | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | | 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
| \ No newline at end of file diff --git a/src/sync.d b/src/sync.d index 5a1aece10..c0f943a59 100644 --- a/src/sync.d +++ b/src/sync.d @@ -7871,49 +7871,101 @@ class SyncEngine { // Resolve 'Directory not empty' error when deleting local files try { auto directoryEntries = dirEntries(path, SpanMode.depth, false); + bool pathShouldBeRemoved; + foreach (DirEntry child; directoryEntries) { // set for error logging currentPath = child.name; - - // what sort of child is this? - if (isDir(child.name)) { - addLogEntry("Removing local directory: " ~ child.name); + + // reset pathShouldBeRemoved + pathShouldBeRemoved = true; + + // Issue #3655: Deletion of local data where 'sync_list' is expecting this to be kept + // If we are in a --download-only --cleanup-local-files + using a 'sync_list' the expectation here is that matches to 'sync_list' inclusion are kept + // If we get to this point, we have already validated '--download-only --cleanup-local-files' so this is now just about 'sync_list' inclusion + + // Is 'sync_list' configured? + if (syncListConfigured) { + // Should this path be removed? + // selectiveSync.isPathExcludedViaSyncList() returns 'true' if the path is excluded, 'false' if the path is to be included + pathShouldBeRemoved = selectiveSync.isPathExcludedViaSyncList(child.name); + } + + // What action should be taken? + if (pathShouldBeRemoved) { + // Path should be removed + // what sort of child is this? + if (isDir(child.name)) { + addLogEntry("Removing local directory: " ~ child.name); + } else { + addLogEntry("Removing local file: " ~ child.name); + } + + // Are we in a --dry-run scenario? + if (!dryRun) { + // No --dry-run ... process local delete + if (exists(child)) { + try { + attrIsDir(child.linkAttributes) ? rmdir(child.name) : safeRemove(child.name); + } catch (FileException e) { + // display the error message + displayFileSystemErrorMessage(e.msg, thisFunctionName, currentPath); + } + } + } } else { - addLogEntry("Removing local file: " ~ child.name); + // Path should be retained + // what sort of child is this? + if (isDir(child.name)) { + addLogEntry("Local directory should be retained due to 'sync_list' inclusion: " ~ child.name); + } else { + addLogEntry("Local file should be retained due to 'sync_list' inclusion: " ~ child.name); + } } + + + + + } + // Clear directoryEntries + object.destroy(directoryEntries); + + bool parentalPathShouldBeRemoved = true; + + // Is 'sync_list' configured? + if (syncListConfigured) { + parentalPathShouldBeRemoved = selectiveSync.isPathExcludedViaSyncList(path); + } + + // What action should be taken? + if (parentalPathShouldBeRemoved) { + + // Remove the parental path now that it is empty of children + addLogEntry("Removing local directory: " ~ path); // are we in a --dry-run scenario? if (!dryRun) { // No --dry-run ... process local delete - if (exists(child)) { + if (exists(path)) { + try { - attrIsDir(child.linkAttributes) ? rmdir(child.name) : safeRemove(child.name); + rmdirRecurse(path); } catch (FileException e) { // display the error message - displayFileSystemErrorMessage(e.msg, thisFunctionName, currentPath); + displayFileSystemErrorMessage(e.msg, thisFunctionName, path); } + } } - } - // Clear directoryEntries - object.destroy(directoryEntries); + } else { + // Path needs to be retained + addLogEntry("Local directory should be retained due to 'sync_list' inclusion: " ~ path); + + - // Remove the path now that it is empty of children - addLogEntry("Removing local directory: " ~ path); - // are we in a --dry-run scenario? - if (!dryRun) { - // No --dry-run ... process local delete - if (exists(path)) { - - try { - rmdirRecurse(path); - } catch (FileException e) { - // display the error message - displayFileSystemErrorMessage(e.msg, thisFunctionName, path); - } - - } } + + } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, thisFunctionName, currentPath); From 1fb070067596728835094752083fa1f9f033d7a5 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 07:41:07 +1100 Subject: [PATCH 033/245] Update PR: Add code changes for SL-0018 testing * Add code changes tested locally to test TC-0002 to validate SL-0018 --- src/clientSideFiltering.d | 12 +- src/sync.d | 256 +++++++++++++++++++++++++++++--------- src/util.d | 6 + 3 files changed, 215 insertions(+), 59 deletions(-) diff --git a/src/clientSideFiltering.d b/src/clientSideFiltering.d index c36ce9230..4e06145a6 100644 --- a/src/clientSideFiltering.d +++ b/src/clientSideFiltering.d @@ -240,6 +240,10 @@ class ClientSideFiltering { // Match against 'sync_list' only bool isPathExcludedViaSyncList(string path) { // Are there 'sync_list' rules to process? + + //addLogEntry("isPathExcludedViaSyncList Input Path = " ~ path); + + if (count(syncListRules) > 0) { // Perform 'sync_list' rule testing on the given path return isPathExcluded(path); @@ -766,10 +770,16 @@ class ClientSideFiltering { } // If any of these exclude match items is true, then finalResult has to be flagged as true - if ((exclude) || (excludeExactMatch) || (excludeParentMatched) || (excludeAnywhereMatched) || (excludeWildcardMatched)) { + //if ((exclude) || (excludeExactMatch) || (excludeParentMatched) || (excludeAnywhereMatched) || (excludeWildcardMatched)) { + // finalResult = true; + //} + + // Only force exclusion if an exclusion rule actually matched this path + if (excludeExactMatch || excludeParentMatched || excludeAnywhereMatched || excludeWildcardMatched) { finalResult = true; } + // Final Result if (finalResult) { if (debugLogging) {addLogEntry("Evaluation against 'sync_list' final result: EXCLUDED as no rule included path", ["debug"]);} diff --git a/src/sync.d b/src/sync.d index c0f943a59..2441fbcde 100644 --- a/src/sync.d +++ b/src/sync.d @@ -118,6 +118,8 @@ class SyncEngine { string[] fileUploadFailures; // List of path names changed online, but not changed locally when using --dry-run string[] pathsRenamed; + // List of path names retained when using --download-only --cleanup-local-files + using a 'sync_list' + string[] pathsRetained; // List of paths that were a POSIX case-insensitive match, thus could not be created online string[] posixViolationPaths; // List of local paths, that, when using the OneDrive Business Shared Folders feature, then disabling it, folder still exists locally and online @@ -1047,6 +1049,7 @@ class SyncEngine { interruptedDownloadFiles = []; pathsToCreateOnline = []; databaseItemsToDeleteOnline = []; + pathsRetained = []; // Perform Garbage Collection on this destroyed curl engine GC.collect(); @@ -7865,121 +7868,188 @@ class SyncEngine { if (debugLogging) {addLogEntry("Adding path to create online (directory inclusion): " ~ path, ["debug"]);} addPathToCreateOnline(path); } else { + // we need to clean up this directory - addLogEntry("Removing local directory as --download-only & --cleanup-local-files configured"); + addLogEntry("Attempting removal of local directory as --download-only & --cleanup-local-files configured"); + // Remove any children of this path if they still exist // Resolve 'Directory not empty' error when deleting local files try { - auto directoryEntries = dirEntries(path, SpanMode.depth, false); - bool pathShouldBeRemoved; + //auto directoryEntries = dirEntries(path, SpanMode.depth, false); + // the cleanup code should only operate on the immediate children of the current directory + auto directoryEntries = dirEntries(path, SpanMode.shallow); + foreach (DirEntry child; directoryEntries) { - // set for error logging - currentPath = child.name; - - // reset pathShouldBeRemoved - pathShouldBeRemoved = true; - - // Issue #3655: Deletion of local data where 'sync_list' is expecting this to be kept - // If we are in a --download-only --cleanup-local-files + using a 'sync_list' the expectation here is that matches to 'sync_list' inclusion are kept - // If we get to this point, we have already validated '--download-only --cleanup-local-files' so this is now just about 'sync_list' inclusion - - // Is 'sync_list' configured? - if (syncListConfigured) { - // Should this path be removed? - // selectiveSync.isPathExcludedViaSyncList() returns 'true' if the path is excluded, 'false' if the path is to be included - pathShouldBeRemoved = selectiveSync.isPathExcludedViaSyncList(child.name); + // Normalise the child path once and use it consistently everywhere + string normalisedChildPath = ensureStartsWithDotSlash(buildNormalizedPath(child.name)); + + // Default action is to remove unless a retention condition is met + bool pathShouldBeRemoved = true; + + // 1. If this path was already retained earlier, never delete it + if (canFind(pathsRetained, normalisedChildPath)) { + pathShouldBeRemoved = false; + addLogEntry("Path already present in pathsRetained - retain path: " ~ normalisedChildPath); } - + + // 2. If not already retained, evaluate via sync_list + if (pathShouldBeRemoved && syncListConfigured) { + // selectiveSync.isPathExcludedViaSyncList() returns: + // true = excluded by sync_list + // false = included / must be retained + if (!selectiveSync.isPathExcludedViaSyncList(child.name)) { + pathShouldBeRemoved = false; + addLogEntry("Path retained due to 'sync_list' inclusion: " ~ normalisedChildPath); + } + } + + // 3. Final authoritative defensive check: + // if the path exists in the item database, it must be retained + //if (pathShouldBeRemoved && pathFoundInDatabase(normalisedChildPath)) { + // pathShouldBeRemoved = false; + // addLogEntry("Path found in database - retain path: " ~ normalisedChildPath); + //} + // What action should be taken? if (pathShouldBeRemoved) { // Path should be removed - // what sort of child is this? if (isDir(child.name)) { - addLogEntry("Removing local directory: " ~ child.name); + addLogEntry("Attempting removal of local directory: " ~ normalisedChildPath); } else { - addLogEntry("Removing local file: " ~ child.name); + addLogEntry("Attempting removal of local file: " ~ normalisedChildPath); } - + // Are we in a --dry-run scenario? if (!dryRun) { // No --dry-run ... process local delete - if (exists(child)) { + if (exists(child.name)) { try { - attrIsDir(child.linkAttributes) ? rmdir(child.name) : safeRemove(child.name); + if (attrIsDir(child.linkAttributes)) { + rmdir(child.name); + // Log removal + addLogEntry("Removed local directory: " ~ normalisedChildPath); + + + + } else { + safeRemove(child.name); + // Log removal + addLogEntry("Removed local file: " ~ normalisedChildPath); + + } } catch (FileException e) { - // display the error message - displayFileSystemErrorMessage(e.msg, thisFunctionName, currentPath); + displayFileSystemErrorMessage(e.msg, thisFunctionName, normalisedChildPath); } } } } else { // Path should be retained - // what sort of child is this? if (isDir(child.name)) { addLogEntry("Local directory should be retained due to 'sync_list' inclusion: " ~ child.name); } else { addLogEntry("Local file should be retained due to 'sync_list' inclusion: " ~ child.name); } + + // Add this path to the retention list if not already present + if (!canFind(pathsRetained, normalisedChildPath)) { + pathsRetained ~= normalisedChildPath; + } + + // Child retained, do not perform any further delete logic for this child + continue; } - - - - - } + // Clear directoryEntries object.destroy(directoryEntries); - + + // Determine whether the parent path itself should be removed bool parentalPathShouldBeRemoved = true; - - // Is 'sync_list' configured? + string normalisedParentPath = ensureStartsWithDotSlash(buildNormalizedPath(path)); + string parentPrefix = normalisedParentPath ~ "/"; + + // 1. sync_list evaluation for the parent path itself if (syncListConfigured) { - parentalPathShouldBeRemoved = selectiveSync.isPathExcludedViaSyncList(path); + // selectiveSync.isPathExcludedViaSyncList() returns: + // true = excluded by sync_list + // false = included / must be retained + if (!selectiveSync.isPathExcludedViaSyncList(path)) { + parentalPathShouldBeRemoved = false; + addLogEntry("Parent path retained due to 'sync_list' inclusion: " ~ path); + } } - + + // 2. If parent path exists in the database, it must be retained + if (parentalPathShouldBeRemoved && pathFoundInDatabase(normalisedParentPath)) { + parentalPathShouldBeRemoved = false; + addLogEntry("Parent path found in database - retain path: " ~ normalisedParentPath); + } + + // 3. If any retained path is this parent or is beneath this parent, retain the parent + if (parentalPathShouldBeRemoved) { + foreach (retainedPath; pathsRetained) { + if ((retainedPath == normalisedParentPath) || retainedPath.startsWith(parentPrefix)) { + parentalPathShouldBeRemoved = false; + addLogEntry("Parent path retained because child path is retained: " ~ retainedPath); + break; + } + } + } + // What action should be taken? if (parentalPathShouldBeRemoved) { - // Remove the parental path now that it is empty of children - addLogEntry("Removing local directory: " ~ path); + addLogEntry("Attempting removal of local directory: " ~ path); + // are we in a --dry-run scenario? if (!dryRun) { // No --dry-run ... process local delete if (exists(path)) { - try { rmdirRecurse(path); + addLogEntry("Removed local directory: " ~ path); + } catch (FileException e) { - // display the error message displayFileSystemErrorMessage(e.msg, thisFunctionName, path); } - } } } else { // Path needs to be retained - addLogEntry("Local directory should be retained due to 'sync_list' inclusion: " ~ path); - - - + addLogEntry("Local parent directory should be retained due to 'sync_list' inclusion: " ~ path); + + // Add the parent path to the retention list if not already present + if (!canFind(pathsRetained, normalisedParentPath)) { + pathsRetained ~= normalisedParentPath; + } } - - + } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, thisFunctionName, currentPath); - + // Display function processing time if configured to do so if (appConfig.getValueBool("display_processing_time") && debugLogging) { // Combine module name & running Function displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey); } - + // return as there was an error return; } + + + + + + + + } + + // Cleanup pathsRetained + //pathsRetained = []; } // Do we actually traverse this path? @@ -8061,14 +8131,71 @@ class SyncEngine { } } else { - // we need to clean up this file - addLogEntry("Removing local file as --download-only & --cleanup-local-files configured"); - // are we in a --dry-run scenario? - addLogEntry("Removing local file: " ~ path); - if (!dryRun) { - // No --dry-run ... process local file delete - safeRemove(path); + + // Normalise the file path once and use it consistently everywhere + string normalisedFilePath = ensureStartsWithDotSlash(buildNormalizedPath(path)); + + // Default action is to remove unless a retention condition is met + bool pathShouldBeRemoved = true; + + // 1. If this path was already retained earlier, never delete it + if (canFind(pathsRetained, normalisedFilePath)) { + pathShouldBeRemoved = false; + addLogEntry("Path already present in pathsRetained - retain path: " ~ normalisedFilePath); + } + + // 2. If not already retained, evaluate via sync_list + if (pathShouldBeRemoved && syncListConfigured) { + // selectiveSync.isPathExcludedViaSyncList() returns: + // true = excluded by sync_list + // false = included / must be retained + if (!selectiveSync.isPathExcludedViaSyncList(path)) { + pathShouldBeRemoved = false; + addLogEntry("Path retained due to 'sync_list' inclusion: " ~ normalisedFilePath); + } } + + // 3. Final authoritative defensive check: + // if the path exists in the item database, it must be retained + //if (pathShouldBeRemoved && pathFoundInDatabase(normalisedFilePath)) { + // pathShouldBeRemoved = false; + // addLogEntry("Path found in database - retain path: " ~ normalisedFilePath); + //} + + + // What action should be taken? + if (pathShouldBeRemoved) { + + // we need to clean up this file + addLogEntry("Attempting removal of local file as --download-only & --cleanup-local-files configured"); + // are we in a --dry-run scenario? + addLogEntry("Attempting removal of local file: " ~ normalisedFilePath); + if (!dryRun) { + // No --dry-run ... process local file delete + safeRemove(path); + } + + + } else { + // Path should be retained + addLogEntry("Local file should be retained due to 'sync_list' inclusion: " ~ normalisedFilePath); + + // Add this path to the retention list if not already present + if (!canFind(pathsRetained, normalisedFilePath)) { + pathsRetained ~= normalisedFilePath; + } + + + + } + + + + + + + + } } } else { @@ -8252,6 +8379,17 @@ class SyncEngine { displayFunctionProcessingStart(thisFunctionName, logKey); } + // Normalise input IF required + if (!startsWith(searchPath, "./")) { + + if (searchPath != ".") { + + addLogEntry("searchPath does not start with './' ... searchPath needs normalising"); + searchPath = ensureStartsWithDotSlash(buildNormalizedPath(searchPath)); + } + + } + // Check if this path in the database Item databaseItem; if (debugLogging) {addLogEntry("Search DB for this path: " ~ searchPath, ["debug"]);} @@ -8266,6 +8404,7 @@ class SyncEngine { displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey); } + if (debugLogging) {addLogEntry("Path found in database - early exit", ["debug"]);} return true; // Early exit on finding the path in the DB } } @@ -8276,6 +8415,7 @@ class SyncEngine { displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey); } + if (debugLogging) {addLogEntry("Path not found in database after exhausing all driveId entries: " ~ searchPath, ["debug"]);} return false; // Return false if path is not found in any drive } diff --git a/src/util.d b/src/util.d index 2e653a833..37159c8c4 100644 --- a/src/util.d +++ b/src/util.d @@ -2373,3 +2373,9 @@ string regexEscape(string s) { void markLocalWrite() { lastLocalWrite = MonoTime.currTime(); } + +bool hasPrefix(string[] pathsRetained, string prefix) { + return pathsRetained.any!(p => p.startsWith(prefix)); +} + + From 0c98f88455c0d5350e8d3d859852197b65c309d0 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 09:27:51 +1100 Subject: [PATCH 034/245] Update PR * Fix typo * Remove unused function * Update tc0002_sync_list_validation.py to handle new application output based based on fixing SL-0018 scenario --- .../testcases/tc0002_sync_list_validation.py | 105 ++++++++++++++++-- src/sync.d | 2 +- src/util.d | 6 - 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index ae8910059..b0ecbb7c4 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -121,6 +121,7 @@ class TestCase0002SyncListValidation(E2ETestCase): name = "sync_list validation" description = "Validate sync_list behaviour across a scenario matrix" + EVENT_PATTERNS = [ ( "skip", @@ -134,6 +135,32 @@ class TestCase0002SyncListValidation(E2ETestCase): "include_file", re.compile(r"^(?:DEBUG:\s+)?Including file - included by sync_list config: (.+)$"), ), + ( + "retain_path", + re.compile(r"^(?:DEBUG:\s+)?Path retained due to 'sync_list' inclusion: (.+)$"), + ), + ( + "retain_existing", + re.compile(r"^(?:DEBUG:\s+)?Path already present in pathsRetained - retain path: (.+)$"), + ), + ( + "retain_local_file", + re.compile( + r"^(?:DEBUG:\s+)?Local file should be retained due to 'sync_list' inclusion: (.+)$" + ), + ), + ( + "retain_local_dir", + re.compile( + r"^(?:DEBUG:\s+)?Local directory should be retained due to 'sync_list' inclusion: (.+)$" + ), + ), + ( + "retain_local_parent_dir", + re.compile( + r"^(?:DEBUG:\s+)?Local parent directory should be retained due to 'sync_list' inclusion: (.+)$" + ), + ), ( "upload_file", re.compile(r"^(?:DEBUG:\s+)?Uploading new file: (.+?) \.\.\."), @@ -155,6 +182,26 @@ class TestCase0002SyncListValidation(E2ETestCase): "remove_local_dir", re.compile(r"^(?:DEBUG:\s+)?Removing local directory: (.+)$"), ), + ( + "attempt_remove_local_file", + re.compile(r"^(?:DEBUG:\s+)?Attempting removal of local file: (.+)$"), + ), + ( + "attempt_remove_local_dir", + re.compile(r"^(?:DEBUG:\s+)?Attempting removal of local directory: (.+)$"), + ), + ( + "removed_local_file", + re.compile(r"^(?:DEBUG:\s+)?Removed local file: (.+)$"), + ), + ( + "removed_local_dir", + re.compile(r"^(?:DEBUG:\s+)?Removed local directory: (.+)$"), + ), + ( + "remove_parent_local_dir", + re.compile(r"^(?:DEBUG:\s+)?Removing parental local directory: (.+)$"), + ), ] def run(self, context: E2EContext) -> TestResult: @@ -505,6 +552,14 @@ def _run_cleanup_regression_scenario( write_manifest(post_cleanup_manifest, build_manifest(sync_root)) diffs.extend(self._validate_scenario(scenario, fixture_events)) + diffs.extend( + self._validate_cleanup_required_skipped( + scenario, + sync_root, + fixture_events, + fixture_removals, + ) + ) diffs.extend(self._validate_cleanup_expectations(scenario, sync_root, fixture_removals)) return diffs, artifacts, metadata @@ -544,6 +599,7 @@ def _normalise_log_path(self, raw_path: str) -> str: if path.startswith("./"): path = path[2:] path = path.replace("\\", "/") + path = re.sub(r"/+", "/", path) path = path.rstrip("/") return path @@ -626,6 +682,7 @@ def _find_matching_events( return matches + def _validate_scenario( self, scenario: SyncListScenario, @@ -663,15 +720,48 @@ def _validate_scenario( f"Expected allowed processing was not observed for: {required}" ) - for required in scenario.required_skipped: - matches = self._find_matching_events(events, required, event_type="skip") - if not matches: - diffs.append( - f"Expected excluded skip was not observed for: {required}" - ) + if scenario.execution_mode != "cleanup_regression": + for required in scenario.required_skipped: + matches = self._find_matching_events(events, required, event_type="skip") + if not matches: + diffs.append( + f"Expected excluded skip was not observed for: {required}" + ) return diffs + + def _cleanup_path_absent(self, sync_root: Path, rel_path: str) -> bool: + return not (sync_root / rel_path).exists() + + + def _validate_cleanup_required_skipped( + self, + scenario: SyncListScenario, + sync_root: Path, + events: list[ParsedEvent], + removals: list[ParsedEvent], + ) -> list[str]: + diffs: list[str] = [] + + for rel_path in scenario.required_skipped: + skip_matches = self._find_matching_events(events, rel_path, event_type="skip") + if skip_matches: + continue + + removal_matches = self._find_matching_events(removals, rel_path) + if removal_matches: + continue + + if self._cleanup_path_absent(sync_root, rel_path): + continue + + diffs.append(f"Expected excluded skip was not observed for: {rel_path}") + + return diffs + + + def _validate_cleanup_expectations( self, scenario: SyncListScenario, @@ -690,7 +780,7 @@ def _validate_cleanup_expectations( for rel_path in scenario.required_removed: matches = self._find_matching_events(removals, rel_path) - if not matches: + if not matches and (sync_root / rel_path).exists(): diffs.append(f"Expected local removal not observed for: {rel_path}") for rel_path in scenario.forbidden_removed: @@ -700,6 +790,7 @@ def _validate_cleanup_expectations( return diffs + def _safe_name_fragment(self, value: str) -> str: return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower() or "root" diff --git a/src/sync.d b/src/sync.d index 2441fbcde..1a5f76d3f 100644 --- a/src/sync.d +++ b/src/sync.d @@ -8415,7 +8415,7 @@ class SyncEngine { displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey); } - if (debugLogging) {addLogEntry("Path not found in database after exhausing all driveId entries: " ~ searchPath, ["debug"]);} + if (debugLogging) {addLogEntry("Path not found in database after exhausting all driveId entries: " ~ searchPath, ["debug"]);} return false; // Return false if path is not found in any drive } diff --git a/src/util.d b/src/util.d index 37159c8c4..2e653a833 100644 --- a/src/util.d +++ b/src/util.d @@ -2373,9 +2373,3 @@ string regexEscape(string s) { void markLocalWrite() { lastLocalWrite = MonoTime.currTime(); } - -bool hasPrefix(string[] pathsRetained, string prefix) { - return pathsRetained.any!(p => p.startsWith(prefix)); -} - - From 429eca85a9ed92eaf57f335cd5c9ee03f802bebe Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 10:06:45 +1100 Subject: [PATCH 035/245] Expand 'sync_list' testing scenarios Add Scenario SL-0022: exact root-file include Add Scenario SL-0023: sync_root_files = true with rooted 'Projects' include Add Scenario SL-0024: cleanup regression with 'sync_root_files = true' Add Scenario SL-0025: prefix-collision safety for 'Projects/Code' Add Scenario SL-0026: mixed rooted subtree include plus exact root-file include --- .../testcases/tc0002_sync_list_validation.py | 143 ++++++++++++++++++ docs/end_to_end_testing.md | 2 +- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index b0ecbb7c4..732211394 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -858,6 +858,9 @@ def _create_fixture_tree(self, root: Path) -> None: f"{FIXTURE_ROOT_NAME}/Projects/Video", f"{FIXTURE_ROOT_NAME}/Projects/Video/Renders", f"{FIXTURE_ROOT_NAME}/Projects/Code", + f"{FIXTURE_ROOT_NAME}/Projects/Code-Archive", + f"{FIXTURE_ROOT_NAME}/Projects/Codecs", + f"{FIXTURE_ROOT_NAME}/Projects/Coder", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source", @@ -945,6 +948,11 @@ def _create_fixture_tree(self, root: Path) -> None: f"{FIXTURE_ROOT_NAME}/Projects/Video/Renders/render.mov": "render\n", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav": "export wav\n", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt": "source\n", + f"{FIXTURE_ROOT_NAME}/Projects/Code-Archive/archive.txt": "archived code\n", + f"{FIXTURE_ROOT_NAME}/Projects/Codecs/readme.txt": "codecs info\n", + f"{FIXTURE_ROOT_NAME}/Projects/Coder/profile.txt": "coder profile\n", + f"{FIXTURE_ROOT_NAME}/README.txt": "fixture readme\n", + f"{FIXTURE_ROOT_NAME}/loose.bin": "fixture loose data\n", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build/output.apk": "android app1 build\n", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/build/intermediates/classes.dex": "classes dex\n", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Android/App1/.cxx/tmp/native.o": "native object\n", @@ -1436,6 +1444,141 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Secret_data", ], ), + SyncListScenario( + scenario_id="SL-0022", + description="root file exact include only", + sync_list=[f"{FIXTURE_ROOT_NAME}/README.txt"], + allowed_exact=[f"{FIXTURE_ROOT_NAME}/README.txt"], + required_processed=[f"{FIXTURE_ROOT_NAME}/README.txt"], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Documents", + ], + ), + SyncListScenario( + scenario_id="SL-0023", + description="sync_root_files true with rooted Projects include and root file processing", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Projects/Audio", + f"!/{FIXTURE_ROOT_NAME}/Projects/Video", + f"/{FIXTURE_ROOT_NAME}/Projects", + ], + allowed_exact=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", + ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + f"{FIXTURE_ROOT_NAME}/Backup", + ], + phase2_config_overrides=['sync_root_files = "true"'], + ), + SyncListScenario( + scenario_id="SL-0024", + description="cleanup regression with sync_root_files true retains root files while pruning excluded Projects subtrees", + execution_mode="cleanup_regression", + sync_list=[ + f"!/{FIXTURE_ROOT_NAME}/Projects/Audio", + f"!/{FIXTURE_ROOT_NAME}/Projects/Video", + f"/{FIXTURE_ROOT_NAME}/Projects", + ], + allowed_exact=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", + ], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], + forbidden_prefixes=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + phase1_config_overrides=[ + 'download_only = "false"', + 'cleanup_local_files = "false"', + 'sync_root_files = "true"', + ], + phase2_config_overrides=[ + 'download_only = "true"', + 'cleanup_local_files = "true"', + 'sync_root_files = "true"', + ], + expected_present_after=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + expected_absent_after=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + required_removed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Audio", + f"{FIXTURE_ROOT_NAME}/Projects/Video", + ], + forbidden_removed=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + ], + ), + SyncListScenario( + scenario_id="SL-0025", + description="prefix collision safety for Projects Code versus similarly named siblings", + sync_list=[f"/{FIXTURE_ROOT_NAME}/Projects/Code"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects/Code"], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Projects/Code-Archive", + f"{FIXTURE_ROOT_NAME}/Projects/Codecs", + f"{FIXTURE_ROOT_NAME}/Projects/Coder", + ], + ), + SyncListScenario( + scenario_id="SL-0026", + description="mixed rooted subtree include plus exact root file include", + sync_list=[ + f"/{FIXTURE_ROOT_NAME}/Projects", + f"{FIXTURE_ROOT_NAME}/README.txt", + ], + allowed_exact=[f"{FIXTURE_ROOT_NAME}/README.txt"], + allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], + required_processed=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", + ], + required_skipped=[ + f"{FIXTURE_ROOT_NAME}/Backup", + f"{FIXTURE_ROOT_NAME}/Documents", + ], + ), SyncListScenario( scenario_id="SL-0018", description="Issue #3655 exact trailing slash configuration with cleanup validation", diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 18b68c79f..d17712658 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -5,4 +5,4 @@ Placeholder document that will detail all test cases and coverage | Test Case | Description | Details | |:---|:---|:---| | 0001 | Basic Resync | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | -| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
| \ No newline at end of file +| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| \ No newline at end of file From 20ff1b945f523d192583df002d4d6aa2d64da5f5 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 12:30:14 +1100 Subject: [PATCH 036/245] Update tc0002_sync_list_validation.py * Fix SL-0023 * Fix SL-0024 --- .../testcases/tc0002_sync_list_validation.py | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 732211394..7f1539032 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -1457,28 +1457,24 @@ def _build_scenarios(self) -> list[SyncListScenario]: ), SyncListScenario( scenario_id="SL-0023", - description="sync_root_files true with rooted Projects include and root file processing", + description="sync_root_files true does not retain non-root sibling files under fixture when only Projects is included", sync_list=[ f"!/{FIXTURE_ROOT_NAME}/Projects/Audio", f"!/{FIXTURE_ROOT_NAME}/Projects/Video", f"/{FIXTURE_ROOT_NAME}/Projects", ], - allowed_exact=[ - f"{FIXTURE_ROOT_NAME}/README.txt", - f"{FIXTURE_ROOT_NAME}/loose.bin", - ], allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Projects/Audio", f"{FIXTURE_ROOT_NAME}/Projects/Video", ], required_processed=[ - f"{FIXTURE_ROOT_NAME}/README.txt", - f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", ], required_skipped=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Audio", f"{FIXTURE_ROOT_NAME}/Projects/Video", f"{FIXTURE_ROOT_NAME}/Backup", @@ -1487,29 +1483,25 @@ def _build_scenarios(self) -> list[SyncListScenario]: ), SyncListScenario( scenario_id="SL-0024", - description="cleanup regression with sync_root_files true retains root files while pruning excluded Projects subtrees", + description="cleanup regression with sync_root_files true prunes non-included sibling files and excluded Projects subtrees", execution_mode="cleanup_regression", sync_list=[ f"!/{FIXTURE_ROOT_NAME}/Projects/Audio", f"!/{FIXTURE_ROOT_NAME}/Projects/Video", f"/{FIXTURE_ROOT_NAME}/Projects", ], - allowed_exact=[ - f"{FIXTURE_ROOT_NAME}/README.txt", - f"{FIXTURE_ROOT_NAME}/loose.bin", - ], allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Projects/Audio", f"{FIXTURE_ROOT_NAME}/Projects/Video", ], required_processed=[ - f"{FIXTURE_ROOT_NAME}/README.txt", - f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", ], required_skipped=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Audio", f"{FIXTURE_ROOT_NAME}/Projects/Video", ], @@ -1524,25 +1516,26 @@ def _build_scenarios(self) -> list[SyncListScenario]: 'sync_root_files = "true"', ], expected_present_after=[ - f"{FIXTURE_ROOT_NAME}/README.txt", - f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", ], expected_absent_after=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Audio", f"{FIXTURE_ROOT_NAME}/Projects/Video", ], required_removed=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Audio", f"{FIXTURE_ROOT_NAME}/Projects/Video", ], forbidden_removed=[ - f"{FIXTURE_ROOT_NAME}/README.txt", - f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", + f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", ], ), SyncListScenario( From f87b1f48398f8639b08cf08d95f52eb5b0205620 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 13:16:48 +1100 Subject: [PATCH 037/245] Update sync.d * Add observability for when 'sync_root_files' override is being used when 'sync_list' is in use --- src/sync.d | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sync.d b/src/sync.d index 1a5f76d3f..51cb8d348 100644 --- a/src/sync.d +++ b/src/sync.d @@ -2541,8 +2541,12 @@ class SyncEngine { if ((isItemFile(onedriveJSONItem)) && (appConfig.getValueBool("sync_root_files")) && (rootName(newItemPath) == "") ) { // This is a file // We are configured to sync all files in the root - // This is a file in the logical root + // This is a file in the logical configured root unwanted = false; + // Log that we are retaining this file and why + if (verboseLogging) { + addLogEntry("Path retained due to 'sync_root_files' override for logical root file: " ~ newItemPath, ["verbose"]); + } } else { // path is unwanted - excluded by 'sync_list' unwanted = true; From 6dd4dba852a0cbcd9d1dabb1606a33d7983b34a4 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 13:37:18 +1100 Subject: [PATCH 038/245] Update tc0002_sync_list_validation.py * Update test case for when 'sync_root_files' override is being used when 'sync_list' is in use --- ci/e2e/testcases/tc0002_sync_list_validation.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 7f1539032..518adfdc3 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -161,6 +161,12 @@ class TestCase0002SyncListValidation(E2ETestCase): r"^(?:DEBUG:\s+)?Local parent directory should be retained due to 'sync_list' inclusion: (.+)$" ), ), + ( + "retain_sync_root_file", + re.compile( + r"^(?:DEBUG:\s+)?Path retained due to 'sync_root_files' override for logical root file: (.+)$" + ), + ), ( "upload_file", re.compile(r"^(?:DEBUG:\s+)?Uploading new file: (.+?) \.\.\."), @@ -1457,24 +1463,28 @@ def _build_scenarios(self) -> list[SyncListScenario]: ), SyncListScenario( scenario_id="SL-0023", - description="sync_root_files true does not retain non-root sibling files under fixture when only Projects is included", + description="sync_root_files true retains logical root files alongside included Projects subtree", sync_list=[ f"!/{FIXTURE_ROOT_NAME}/Projects/Audio", f"!/{FIXTURE_ROOT_NAME}/Projects/Video", f"/{FIXTURE_ROOT_NAME}/Projects", ], + allowed_exact=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", + ], allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], forbidden_prefixes=[ f"{FIXTURE_ROOT_NAME}/Projects/Audio", f"{FIXTURE_ROOT_NAME}/Projects/Video", ], required_processed=[ + f"{FIXTURE_ROOT_NAME}/README.txt", + f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", ], required_skipped=[ - f"{FIXTURE_ROOT_NAME}/README.txt", - f"{FIXTURE_ROOT_NAME}/loose.bin", f"{FIXTURE_ROOT_NAME}/Projects/Audio", f"{FIXTURE_ROOT_NAME}/Projects/Video", f"{FIXTURE_ROOT_NAME}/Backup", From 247646b70f06a32c7d15e90c1ece1b223af0dc67 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 13:59:18 +1100 Subject: [PATCH 039/245] Update tc0002_sync_list_validation.py * Update SL-0023 --- ci/e2e/testcases/tc0002_sync_list_validation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 518adfdc3..3cc73635e 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -1472,6 +1472,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: allowed_exact=[ f"{FIXTURE_ROOT_NAME}/README.txt", f"{FIXTURE_ROOT_NAME}/loose.bin", + f"{FIXTURE_ROOT_NAME}/zz_e2e_zz_e2e_sync_list.bin", ], allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], forbidden_prefixes=[ @@ -1481,6 +1482,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: required_processed=[ f"{FIXTURE_ROOT_NAME}/README.txt", f"{FIXTURE_ROOT_NAME}/loose.bin", + f"{FIXTURE_ROOT_NAME}/zz_e2e_zz_e2e_sync_list.bin", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", ], From 58c95df8489848822a58604e5e108392fc71def5 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 14:16:13 +1100 Subject: [PATCH 040/245] Update PR * Update PR --- ci/e2e/testcases/tc0002_sync_list_validation.py | 1 - docs/end_to_end_testing.md | 2 +- readme.md | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 3cc73635e..c2e3c697c 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -575,7 +575,6 @@ def _build_sync_command(self, context: E2EContext, sync_root: Path, config_dir: context.onedrive_bin, "--sync", "--verbose", - "--verbose", "--resync", "--resync-auth", "--syncdir", diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index d17712658..5ac7c289d 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -1,6 +1,6 @@ # End to End Testing of OneDrive Client for Linux -Placeholder document that will detail all test cases and coverage +[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg?branch=master)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) | Test Case | Description | Details | |:---|:---|:---| diff --git a/readme.md b/readme.md index c13b0953a..c1c6fed18 100644 --- a/readme.md +++ b/readme.md @@ -2,8 +2,8 @@ [![Version](https://img.shields.io/github/v/release/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) [![Release Date](https://img.shields.io/github/release-date/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) -[![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) -[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg?branch=master)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) +[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg?branch=master)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![Build Docker Images](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/driveone/onedrive)](https://hub.docker.com/r/driveone/onedrive) From 0f35c83c52342718b7fa893d7b73ec36e66c699d Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 15:06:02 +1100 Subject: [PATCH 041/245] Remove code fixes from this PR * Remove code fixes from this PR --- src/clientSideFiltering.d | 12 +- src/sync.d | 278 ++++++-------------------------------- 2 files changed, 42 insertions(+), 248 deletions(-) diff --git a/src/clientSideFiltering.d b/src/clientSideFiltering.d index 4e06145a6..c36ce9230 100644 --- a/src/clientSideFiltering.d +++ b/src/clientSideFiltering.d @@ -240,10 +240,6 @@ class ClientSideFiltering { // Match against 'sync_list' only bool isPathExcludedViaSyncList(string path) { // Are there 'sync_list' rules to process? - - //addLogEntry("isPathExcludedViaSyncList Input Path = " ~ path); - - if (count(syncListRules) > 0) { // Perform 'sync_list' rule testing on the given path return isPathExcluded(path); @@ -770,16 +766,10 @@ class ClientSideFiltering { } // If any of these exclude match items is true, then finalResult has to be flagged as true - //if ((exclude) || (excludeExactMatch) || (excludeParentMatched) || (excludeAnywhereMatched) || (excludeWildcardMatched)) { - // finalResult = true; - //} - - // Only force exclusion if an exclusion rule actually matched this path - if (excludeExactMatch || excludeParentMatched || excludeAnywhereMatched || excludeWildcardMatched) { + if ((exclude) || (excludeExactMatch) || (excludeParentMatched) || (excludeAnywhereMatched) || (excludeWildcardMatched)) { finalResult = true; } - // Final Result if (finalResult) { if (debugLogging) {addLogEntry("Evaluation against 'sync_list' final result: EXCLUDED as no rule included path", ["debug"]);} diff --git a/src/sync.d b/src/sync.d index 51cb8d348..5a1aece10 100644 --- a/src/sync.d +++ b/src/sync.d @@ -118,8 +118,6 @@ class SyncEngine { string[] fileUploadFailures; // List of path names changed online, but not changed locally when using --dry-run string[] pathsRenamed; - // List of path names retained when using --download-only --cleanup-local-files + using a 'sync_list' - string[] pathsRetained; // List of paths that were a POSIX case-insensitive match, thus could not be created online string[] posixViolationPaths; // List of local paths, that, when using the OneDrive Business Shared Folders feature, then disabling it, folder still exists locally and online @@ -1049,7 +1047,6 @@ class SyncEngine { interruptedDownloadFiles = []; pathsToCreateOnline = []; databaseItemsToDeleteOnline = []; - pathsRetained = []; // Perform Garbage Collection on this destroyed curl engine GC.collect(); @@ -2541,12 +2538,8 @@ class SyncEngine { if ((isItemFile(onedriveJSONItem)) && (appConfig.getValueBool("sync_root_files")) && (rootName(newItemPath) == "") ) { // This is a file // We are configured to sync all files in the root - // This is a file in the logical configured root + // This is a file in the logical root unwanted = false; - // Log that we are retaining this file and why - if (verboseLogging) { - addLogEntry("Path retained due to 'sync_root_files' override for logical root file: " ~ newItemPath, ["verbose"]); - } } else { // path is unwanted - excluded by 'sync_list' unwanted = true; @@ -7872,188 +7865,69 @@ class SyncEngine { if (debugLogging) {addLogEntry("Adding path to create online (directory inclusion): " ~ path, ["debug"]);} addPathToCreateOnline(path); } else { - // we need to clean up this directory - addLogEntry("Attempting removal of local directory as --download-only & --cleanup-local-files configured"); - + addLogEntry("Removing local directory as --download-only & --cleanup-local-files configured"); // Remove any children of this path if they still exist // Resolve 'Directory not empty' error when deleting local files try { - //auto directoryEntries = dirEntries(path, SpanMode.depth, false); - - // the cleanup code should only operate on the immediate children of the current directory - auto directoryEntries = dirEntries(path, SpanMode.shallow); - + auto directoryEntries = dirEntries(path, SpanMode.depth, false); foreach (DirEntry child; directoryEntries) { - // Normalise the child path once and use it consistently everywhere - string normalisedChildPath = ensureStartsWithDotSlash(buildNormalizedPath(child.name)); - - // Default action is to remove unless a retention condition is met - bool pathShouldBeRemoved = true; - - // 1. If this path was already retained earlier, never delete it - if (canFind(pathsRetained, normalisedChildPath)) { - pathShouldBeRemoved = false; - addLogEntry("Path already present in pathsRetained - retain path: " ~ normalisedChildPath); - } - - // 2. If not already retained, evaluate via sync_list - if (pathShouldBeRemoved && syncListConfigured) { - // selectiveSync.isPathExcludedViaSyncList() returns: - // true = excluded by sync_list - // false = included / must be retained - if (!selectiveSync.isPathExcludedViaSyncList(child.name)) { - pathShouldBeRemoved = false; - addLogEntry("Path retained due to 'sync_list' inclusion: " ~ normalisedChildPath); - } - } - - // 3. Final authoritative defensive check: - // if the path exists in the item database, it must be retained - //if (pathShouldBeRemoved && pathFoundInDatabase(normalisedChildPath)) { - // pathShouldBeRemoved = false; - // addLogEntry("Path found in database - retain path: " ~ normalisedChildPath); - //} - - // What action should be taken? - if (pathShouldBeRemoved) { - // Path should be removed - if (isDir(child.name)) { - addLogEntry("Attempting removal of local directory: " ~ normalisedChildPath); - } else { - addLogEntry("Attempting removal of local file: " ~ normalisedChildPath); - } - - // Are we in a --dry-run scenario? - if (!dryRun) { - // No --dry-run ... process local delete - if (exists(child.name)) { - try { - if (attrIsDir(child.linkAttributes)) { - rmdir(child.name); - // Log removal - addLogEntry("Removed local directory: " ~ normalisedChildPath); - - - - } else { - safeRemove(child.name); - // Log removal - addLogEntry("Removed local file: " ~ normalisedChildPath); - - } - } catch (FileException e) { - displayFileSystemErrorMessage(e.msg, thisFunctionName, normalisedChildPath); - } - } - } + // set for error logging + currentPath = child.name; + + // what sort of child is this? + if (isDir(child.name)) { + addLogEntry("Removing local directory: " ~ child.name); } else { - // Path should be retained - if (isDir(child.name)) { - addLogEntry("Local directory should be retained due to 'sync_list' inclusion: " ~ child.name); - } else { - addLogEntry("Local file should be retained due to 'sync_list' inclusion: " ~ child.name); - } - - // Add this path to the retention list if not already present - if (!canFind(pathsRetained, normalisedChildPath)) { - pathsRetained ~= normalisedChildPath; - } - - // Child retained, do not perform any further delete logic for this child - continue; + addLogEntry("Removing local file: " ~ child.name); } - } - - // Clear directoryEntries - object.destroy(directoryEntries); - - // Determine whether the parent path itself should be removed - bool parentalPathShouldBeRemoved = true; - string normalisedParentPath = ensureStartsWithDotSlash(buildNormalizedPath(path)); - string parentPrefix = normalisedParentPath ~ "/"; - - // 1. sync_list evaluation for the parent path itself - if (syncListConfigured) { - // selectiveSync.isPathExcludedViaSyncList() returns: - // true = excluded by sync_list - // false = included / must be retained - if (!selectiveSync.isPathExcludedViaSyncList(path)) { - parentalPathShouldBeRemoved = false; - addLogEntry("Parent path retained due to 'sync_list' inclusion: " ~ path); - } - } - - // 2. If parent path exists in the database, it must be retained - if (parentalPathShouldBeRemoved && pathFoundInDatabase(normalisedParentPath)) { - parentalPathShouldBeRemoved = false; - addLogEntry("Parent path found in database - retain path: " ~ normalisedParentPath); - } - - // 3. If any retained path is this parent or is beneath this parent, retain the parent - if (parentalPathShouldBeRemoved) { - foreach (retainedPath; pathsRetained) { - if ((retainedPath == normalisedParentPath) || retainedPath.startsWith(parentPrefix)) { - parentalPathShouldBeRemoved = false; - addLogEntry("Parent path retained because child path is retained: " ~ retainedPath); - break; - } - } - } - - // What action should be taken? - if (parentalPathShouldBeRemoved) { - // Remove the parental path now that it is empty of children - addLogEntry("Attempting removal of local directory: " ~ path); // are we in a --dry-run scenario? if (!dryRun) { // No --dry-run ... process local delete - if (exists(path)) { + if (exists(child)) { try { - rmdirRecurse(path); - addLogEntry("Removed local directory: " ~ path); - + attrIsDir(child.linkAttributes) ? rmdir(child.name) : safeRemove(child.name); } catch (FileException e) { - displayFileSystemErrorMessage(e.msg, thisFunctionName, path); + // display the error message + displayFileSystemErrorMessage(e.msg, thisFunctionName, currentPath); } } } - } else { - // Path needs to be retained - addLogEntry("Local parent directory should be retained due to 'sync_list' inclusion: " ~ path); - - // Add the parent path to the retention list if not already present - if (!canFind(pathsRetained, normalisedParentPath)) { - pathsRetained ~= normalisedParentPath; + } + // Clear directoryEntries + object.destroy(directoryEntries); + + // Remove the path now that it is empty of children + addLogEntry("Removing local directory: " ~ path); + // are we in a --dry-run scenario? + if (!dryRun) { + // No --dry-run ... process local delete + if (exists(path)) { + + try { + rmdirRecurse(path); + } catch (FileException e) { + // display the error message + displayFileSystemErrorMessage(e.msg, thisFunctionName, path); + } + } } - } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, thisFunctionName, currentPath); - + // Display function processing time if configured to do so if (appConfig.getValueBool("display_processing_time") && debugLogging) { // Combine module name & running Function displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey); } - + // return as there was an error return; } - - - - - - - - } - - // Cleanup pathsRetained - //pathsRetained = []; } // Do we actually traverse this path? @@ -8135,71 +8009,14 @@ class SyncEngine { } } else { - - // Normalise the file path once and use it consistently everywhere - string normalisedFilePath = ensureStartsWithDotSlash(buildNormalizedPath(path)); - - // Default action is to remove unless a retention condition is met - bool pathShouldBeRemoved = true; - - // 1. If this path was already retained earlier, never delete it - if (canFind(pathsRetained, normalisedFilePath)) { - pathShouldBeRemoved = false; - addLogEntry("Path already present in pathsRetained - retain path: " ~ normalisedFilePath); - } - - // 2. If not already retained, evaluate via sync_list - if (pathShouldBeRemoved && syncListConfigured) { - // selectiveSync.isPathExcludedViaSyncList() returns: - // true = excluded by sync_list - // false = included / must be retained - if (!selectiveSync.isPathExcludedViaSyncList(path)) { - pathShouldBeRemoved = false; - addLogEntry("Path retained due to 'sync_list' inclusion: " ~ normalisedFilePath); - } - } - - // 3. Final authoritative defensive check: - // if the path exists in the item database, it must be retained - //if (pathShouldBeRemoved && pathFoundInDatabase(normalisedFilePath)) { - // pathShouldBeRemoved = false; - // addLogEntry("Path found in database - retain path: " ~ normalisedFilePath); - //} - - - // What action should be taken? - if (pathShouldBeRemoved) { - - // we need to clean up this file - addLogEntry("Attempting removal of local file as --download-only & --cleanup-local-files configured"); - // are we in a --dry-run scenario? - addLogEntry("Attempting removal of local file: " ~ normalisedFilePath); - if (!dryRun) { - // No --dry-run ... process local file delete - safeRemove(path); - } - - - } else { - // Path should be retained - addLogEntry("Local file should be retained due to 'sync_list' inclusion: " ~ normalisedFilePath); - - // Add this path to the retention list if not already present - if (!canFind(pathsRetained, normalisedFilePath)) { - pathsRetained ~= normalisedFilePath; - } - - - + // we need to clean up this file + addLogEntry("Removing local file as --download-only & --cleanup-local-files configured"); + // are we in a --dry-run scenario? + addLogEntry("Removing local file: " ~ path); + if (!dryRun) { + // No --dry-run ... process local file delete + safeRemove(path); } - - - - - - - - } } } else { @@ -8383,17 +8200,6 @@ class SyncEngine { displayFunctionProcessingStart(thisFunctionName, logKey); } - // Normalise input IF required - if (!startsWith(searchPath, "./")) { - - if (searchPath != ".") { - - addLogEntry("searchPath does not start with './' ... searchPath needs normalising"); - searchPath = ensureStartsWithDotSlash(buildNormalizedPath(searchPath)); - } - - } - // Check if this path in the database Item databaseItem; if (debugLogging) {addLogEntry("Search DB for this path: " ~ searchPath, ["debug"]);} @@ -8408,7 +8214,6 @@ class SyncEngine { displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey); } - if (debugLogging) {addLogEntry("Path found in database - early exit", ["debug"]);} return true; // Early exit on finding the path in the DB } } @@ -8419,7 +8224,6 @@ class SyncEngine { displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey); } - if (debugLogging) {addLogEntry("Path not found in database after exhausting all driveId entries: " ~ searchPath, ["debug"]);} return false; // Return false if path is not found in any drive } From fad1c00b3f39eb468271517a290b2ce9ab7eebaa Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 15:41:10 +1100 Subject: [PATCH 042/245] Fix doc badges Fix doc badges --- docs/end_to_end_testing.md | 2 +- readme.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 5ac7c289d..1c13b9a23 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -1,6 +1,6 @@ # End to End Testing of OneDrive Client for Linux -[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg?branch=master)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) | Test Case | Description | Details | |:---|:---|:---| diff --git a/readme.md b/readme.md index c1c6fed18..c13b0953a 100644 --- a/readme.md +++ b/readme.md @@ -2,8 +2,8 @@ [![Version](https://img.shields.io/github/v/release/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) [![Release Date](https://img.shields.io/github/release-date/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) -[![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg?branch=master)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) -[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg?branch=master)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) +[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![Build Docker Images](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/driveone/onedrive)](https://hub.docker.com/r/driveone/onedrive) From c30cd8145fce4d12301b17c76ed53a622fda6bae Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 16:25:25 +1100 Subject: [PATCH 043/245] Add further test cases * Add Test Cases 0003 to 0016 --- ci/e2e/run.py | 28 +++ ci/e2e/testcases/tc0003_dry_run_validation.py | 73 +++++++ .../testcases/tc0004_single_directory_sync.py | 52 +++++ .../testcases/tc0005_force_sync_override.py | 52 +++++ ci/e2e/testcases/tc0006_download_only.py | 51 +++++ ...c0007_download_only_cleanup_local_files.py | 43 ++++ ci/e2e/testcases/tc0008_upload_only.py | 38 ++++ .../tc0009_upload_only_no_remote_delete.py | 48 +++++ .../tc0010_upload_only_remove_source_files.py | 37 ++++ .../testcases/tc0011_skip_file_validation.py | 43 ++++ .../testcases/tc0012_skip_dir_validation.py | 70 ++++++ .../tc0013_skip_dotfiles_validation.py | 41 ++++ .../testcases/tc0014_skip_size_validation.py | 37 ++++ .../tc0015_skip_symlinks_validation.py | 43 ++++ .../tc0016_check_nosync_validation.py | 39 ++++ ci/e2e/testcases/wave1_common.py | 200 ++++++++++++++++++ 16 files changed, 895 insertions(+) create mode 100644 ci/e2e/testcases/tc0003_dry_run_validation.py create mode 100644 ci/e2e/testcases/tc0004_single_directory_sync.py create mode 100644 ci/e2e/testcases/tc0005_force_sync_override.py create mode 100644 ci/e2e/testcases/tc0006_download_only.py create mode 100644 ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py create mode 100644 ci/e2e/testcases/tc0008_upload_only.py create mode 100644 ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py create mode 100644 ci/e2e/testcases/tc0010_upload_only_remove_source_files.py create mode 100644 ci/e2e/testcases/tc0011_skip_file_validation.py create mode 100644 ci/e2e/testcases/tc0012_skip_dir_validation.py create mode 100644 ci/e2e/testcases/tc0013_skip_dotfiles_validation.py create mode 100644 ci/e2e/testcases/tc0014_skip_size_validation.py create mode 100644 ci/e2e/testcases/tc0015_skip_symlinks_validation.py create mode 100644 ci/e2e/testcases/tc0016_check_nosync_validation.py create mode 100644 ci/e2e/testcases/wave1_common.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 17ae9eb82..267141a5b 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -11,6 +11,20 @@ from framework.utils import ensure_directory, write_text_file from testcases.tc0001_basic_resync import TestCase0001BasicResync from testcases.tc0002_sync_list_validation import TestCase0002SyncListValidation +from testcases.tc0003_dry_run_validation import TestCase0003DryRunValidation +from testcases.tc0004_single_directory_sync import TestCase0004SingleDirectorySync +from testcases.tc0005_force_sync_override import TestCase0005ForceSyncOverride +from testcases.tc0006_download_only import TestCase0006DownloadOnly +from testcases.tc0007_download_only_cleanup_local_files import TestCase0007DownloadOnlyCleanupLocalFiles +from testcases.tc0008_upload_only import TestCase0008UploadOnly +from testcases.tc0009_upload_only_no_remote_delete import TestCase0009UploadOnlyNoRemoteDelete +from testcases.tc0010_upload_only_remove_source_files import TestCase0010UploadOnlyRemoveSourceFiles +from testcases.tc0011_skip_file_validation import TestCase0011SkipFileValidation +from testcases.tc0012_skip_dir_validation import TestCase0012SkipDirValidation +from testcases.tc0013_skip_dotfiles_validation import TestCase0013SkipDotfilesValidation +from testcases.tc0014_skip_size_validation import TestCase0014SkipSizeValidation +from testcases.tc0015_skip_symlinks_validation import TestCase0015SkipSymlinksValidation +from testcases.tc0016_check_nosync_validation import TestCase0016CheckNosyncValidation def build_test_suite() -> list: @@ -22,6 +36,20 @@ def build_test_suite() -> list: return [ TestCase0001BasicResync(), TestCase0002SyncListValidation(), + TestCase0003DryRunValidation(), + TestCase0004SingleDirectorySync(), + TestCase0005ForceSyncOverride(), + TestCase0006DownloadOnly(), + TestCase0007DownloadOnlyCleanupLocalFiles(), + TestCase0008UploadOnly(), + TestCase0009UploadOnlyNoRemoteDelete(), + TestCase0010UploadOnlyRemoveSourceFiles(), + TestCase0011SkipFileValidation(), + TestCase0012SkipDirValidation(), + TestCase0013SkipDotfilesValidation(), + TestCase0014SkipSizeValidation(), + TestCase0015SkipSymlinksValidation(), + TestCase0016CheckNosyncValidation(), ] diff --git a/ci/e2e/testcases/tc0003_dry_run_validation.py b/ci/e2e/testcases/tc0003_dry_run_validation.py new file mode 100644 index 000000000..0975ee752 --- /dev/null +++ b/ci/e2e/testcases/tc0003_dry_run_validation.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0003DryRunValidation(Wave1TestCaseBase): + case_id = "0003" + name = "dry-run validation" + description = "Validate that --dry-run performs no local or remote changes" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + + seed_root = case_work_dir / "seed-syncroot" + seed_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(seed_root / root_name / "Remote" / "online.txt", "online baseline\n") + self._create_text_file(seed_root / root_name / "Remote" / "keep.txt", "keep baseline\n") + self._create_binary_file(seed_root / root_name / "Data" / "payload.bin", 64 * 1024) + + seed_config_dir = self._new_config_dir(context, case_work_dir, "seed") + config_path, sync_list_path = self._write_config(seed_config_dir, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_config_dir) + artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) + artifacts.extend(self._write_manifests(seed_root, case_state_dir, "seed_local")) + if seed_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts, {"phase": "seed"}) + + dry_root = case_work_dir / "dryrun-syncroot" + dry_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(dry_root / root_name / "LocalOnly" / "draft.txt", "local only\n") + self._create_text_file(dry_root / root_name / "Remote" / "keep.txt", "locally modified but should not upload\n") + pre_snapshot = self._snapshot_files(dry_root) + artifacts.append(self._write_json_artifact(case_state_dir / "pre_snapshot.json", pre_snapshot)) + + dry_config_dir = self._new_config_dir(context, case_work_dir, "dryrun") + config_path, sync_list_path = self._write_config(dry_config_dir, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + dry_result = self._run_onedrive(context, sync_root=dry_root, config_dir=dry_config_dir, extra_args=["--dry-run"]) + artifacts.extend(self._write_command_artifacts(result=dry_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="dry_run")) + post_snapshot = self._snapshot_files(dry_root) + artifacts.append(self._write_json_artifact(case_state_dir / "post_snapshot.json", post_snapshot)) + + if dry_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Dry-run exited with status {dry_result.returncode}", artifacts, {"phase": "dry-run"}) + if pre_snapshot != post_snapshot: + return TestResult.fail_result(self.case_id, self.name, "Local filesystem changed during --dry-run", artifacts, {"phase": "dry-run"}) + + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + artifacts.extend(self._write_manifests(verify_root, case_state_dir, "verify_remote")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification download failed with status {verify_result.returncode}", artifacts) + + downloaded = set(self._snapshot_files(verify_root).keys()) + expected_present = { + f"{root_name}/Remote", + f"{root_name}/Remote/online.txt", + f"{root_name}/Remote/keep.txt", + f"{root_name}/Data", + f"{root_name}/Data/payload.bin", + } + unexpected_absent = sorted(expected_present - downloaded) + if unexpected_absent: + return TestResult.fail_result(self.case_id, self.name, "Remote baseline changed after --dry-run", artifacts, {"missing": unexpected_absent}) + if f"{root_name}/LocalOnly/draft.txt" in downloaded: + return TestResult.fail_result(self.case_id, self.name, "Local-only file was uploaded during --dry-run", artifacts) + + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0004_single_directory_sync.py b/ci/e2e/testcases/tc0004_single_directory_sync.py new file mode 100644 index 000000000..997925d4b --- /dev/null +++ b/ci/e2e/testcases/tc0004_single_directory_sync.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0004SingleDirectorySync(Wave1TestCaseBase): + case_id = "0004" + name = "single-directory synchronisation" + description = "Validate that only the nominated subtree is synchronised" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + + sync_root = case_work_dir / "syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(sync_root / root_name / "Scoped" / "include.txt", "scoped file\n") + self._create_text_file(sync_root / root_name / "Scoped" / "Nested" / "deep.txt", "nested scoped\n") + self._create_text_file(sync_root / root_name / "Unscoped" / "exclude.txt", "should stay local only\n") + + config_dir = self._new_config_dir(context, case_work_dir, "main") + config_path, sync_list_path = self._write_config(config_dir, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=config_dir, extra_args=["--single-directory", f"{root_name}/Scoped"]) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="single_directory")) + artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"--single-directory sync failed with status {result.returncode}", artifacts) + + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + artifacts.extend(self._write_manifests(verify_root, case_state_dir, "remote_manifest")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + + snapshot = self._snapshot_files(verify_root) + required = { + f"{root_name}/Scoped", + f"{root_name}/Scoped/include.txt", + f"{root_name}/Scoped/Nested", + f"{root_name}/Scoped/Nested/deep.txt", + } + missing = sorted(required - set(snapshot.keys())) + if missing: + return TestResult.fail_result(self.case_id, self.name, "Scoped content was not uploaded as expected", artifacts, {"missing": missing}) + if f"{root_name}/Unscoped/exclude.txt" in snapshot: + return TestResult.fail_result(self.case_id, self.name, "Unscoped content was unexpectedly synchronised", artifacts) + + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0005_force_sync_override.py b/ci/e2e/testcases/tc0005_force_sync_override.py new file mode 100644 index 000000000..65a8bad52 --- /dev/null +++ b/ci/e2e/testcases/tc0005_force_sync_override.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0005ForceSyncOverride(Wave1TestCaseBase): + case_id = "0005" + name = "force-sync override" + description = "Validate that --force-sync overrides skip_dir when using --single-directory" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + + seed_root = case_work_dir / "seed-syncroot" + seed_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(seed_root / root_name / "Blocked" / "blocked.txt", "blocked remote file\n") + seed_conf = self._new_config_dir(context, case_work_dir, "seed") + config_path, sync_list_path = self._write_config(seed_conf, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) + artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) + if seed_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts) + + no_force_root = case_work_dir / "no-force-syncroot" + no_force_root.mkdir(parents=True, exist_ok=True) + no_force_conf = self._new_config_dir(context, case_work_dir, "no-force") + config_path, sync_list_path = self._write_config(no_force_conf, extra_lines=['skip_dir = "Blocked"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + no_force_result = self._run_onedrive(context, sync_root=no_force_root, config_dir=no_force_conf, extra_args=["--download-only", "--single-directory", f"{root_name}/Blocked"]) + artifacts.extend(self._write_command_artifacts(result=no_force_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="no_force")) + if no_force_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Blocked single-directory sync without --force-sync failed with status {no_force_result.returncode}", artifacts) + if (no_force_root / root_name / "Blocked" / "blocked.txt").exists(): + return TestResult.fail_result(self.case_id, self.name, "Blocked content was downloaded without --force-sync", artifacts) + + force_root = case_work_dir / "force-syncroot" + force_root.mkdir(parents=True, exist_ok=True) + force_conf = self._new_config_dir(context, case_work_dir, "force") + config_path, sync_list_path = self._write_config(force_conf, extra_lines=['skip_dir = "Blocked"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + force_result = self._run_onedrive(context, sync_root=force_root, config_dir=force_conf, extra_args=["--download-only", "--single-directory", f"{root_name}/Blocked", "--force-sync"]) + artifacts.extend(self._write_command_artifacts(result=force_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="force")) + artifacts.extend(self._write_manifests(force_root, case_state_dir, "force_manifest")) + if force_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Blocked single-directory sync with --force-sync failed with status {force_result.returncode}", artifacts) + if not (force_root / root_name / "Blocked" / "blocked.txt").exists(): + return TestResult.fail_result(self.case_id, self.name, "Blocked content was not downloaded with --force-sync", artifacts) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0006_download_only.py b/ci/e2e/testcases/tc0006_download_only.py new file mode 100644 index 000000000..5fa68a1fe --- /dev/null +++ b/ci/e2e/testcases/tc0006_download_only.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0006DownloadOnly(Wave1TestCaseBase): + case_id = "0006" + name = "download-only behaviour" + description = "Validate that remote content downloads locally and local-only content is not uploaded" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + seed_root = case_work_dir / "seed-syncroot" + seed_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(seed_root / root_name / "Remote" / "download_me.txt", "remote file\n") + seed_conf = self._new_config_dir(context, case_work_dir, "seed") + config_path, sync_list_path = self._write_config(seed_conf, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) + artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) + if seed_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts) + + sync_root = case_work_dir / "download-syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(sync_root / root_name / "LocalOnly" / "stay_local.txt", "must not upload\n") + conf_dir = self._new_config_dir(context, case_work_dir, "download") + config_path, sync_list_path = self._write_config(conf_dir, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--download-only"]) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="download_only")) + artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"--download-only failed with status {result.returncode}", artifacts) + if not (sync_root / root_name / "Remote" / "download_me.txt").exists(): + return TestResult.fail_result(self.case_id, self.name, "Remote file was not downloaded locally", artifacts) + if not (sync_root / root_name / "LocalOnly" / "stay_local.txt").exists(): + return TestResult.fail_result(self.case_id, self.name, "Local-only file should remain present locally", artifacts) + + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + verify_snapshot = self._snapshot_files(verify_root) + if f"{root_name}/LocalOnly/stay_local.txt" in verify_snapshot: + return TestResult.fail_result(self.case_id, self.name, "Local-only file was uploaded during --download-only", artifacts) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py new file mode 100644 index 000000000..449c8a15f --- /dev/null +++ b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0007DownloadOnlyCleanupLocalFiles(Wave1TestCaseBase): + case_id = "0007" + name = "download-only cleanup-local-files" + description = "Validate that stale local files are removed when cleanup_local_files is enabled" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + seed_root = case_work_dir / "seed-syncroot" + seed_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(seed_root / root_name / "Keep" / "keep.txt", "keep\n") + seed_conf = self._new_config_dir(context, case_work_dir, "seed") + config_path, sync_list_path = self._write_config(seed_conf, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) + artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) + if seed_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts) + + sync_root = case_work_dir / "cleanup-syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(sync_root / root_name / "Keep" / "keep.txt", "local keep placeholder\n") + self._create_text_file(sync_root / root_name / "Obsolete" / "old.txt", "obsolete\n") + conf_dir = self._new_config_dir(context, case_work_dir, "cleanup") + config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['cleanup_local_files = "true"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--download-only"]) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="cleanup_download_only")) + artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Cleanup validation failed with status {result.returncode}", artifacts) + if not (sync_root / root_name / "Keep" / "keep.txt").exists(): + return TestResult.fail_result(self.case_id, self.name, "Expected retained file is missing after cleanup", artifacts) + if (sync_root / root_name / "Obsolete" / "old.txt").exists(): + return TestResult.fail_result(self.case_id, self.name, "Stale local file still exists after cleanup_local_files processing", artifacts) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0008_upload_only.py b/ci/e2e/testcases/tc0008_upload_only.py new file mode 100644 index 000000000..ff5af4e90 --- /dev/null +++ b/ci/e2e/testcases/tc0008_upload_only.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0008UploadOnly(Wave1TestCaseBase): + case_id = "0008" + name = "upload-only behaviour" + description = "Validate that local content is uploaded when using --upload-only" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + sync_root = case_work_dir / "upload-syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(sync_root / root_name / "Upload" / "file.txt", "upload me\n") + self._create_binary_file(sync_root / root_name / "Upload" / "blob.bin", 70 * 1024) + conf_dir = self._new_config_dir(context, case_work_dir, "upload") + config_path, sync_list_path = self._write_config(conf_dir, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"--upload-only failed with status {result.returncode}", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + artifacts.extend(self._write_manifests(verify_root, case_state_dir, "remote_manifest")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + verify_snapshot = self._snapshot_files(verify_root) + expected = {f"{root_name}/Upload/file.txt", f"{root_name}/Upload/blob.bin"} + missing = sorted(expected - set(verify_snapshot.keys())) + if missing: + return TestResult.fail_result(self.case_id, self.name, "Uploaded files were not present remotely", artifacts, {"missing": missing}) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py new file mode 100644 index 000000000..378e56fc4 --- /dev/null +++ b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0009UploadOnlyNoRemoteDelete(Wave1TestCaseBase): + case_id = "0009" + name = "upload-only no-remote-delete" + description = "Validate that remote data is retained when local content is absent and no_remote_delete is enabled" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + seed_root = case_work_dir / "seed-syncroot" + seed_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(seed_root / root_name / "RemoteKeep" / "preserve.txt", "preserve remotely\n") + seed_conf = self._new_config_dir(context, case_work_dir, "seed") + config_path, sync_list_path = self._write_config(seed_conf, sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) + artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) + if seed_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts) + + sync_root = case_work_dir / "upload-syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(sync_root / root_name / "LocalUpload" / "new.txt", "new upload\n") + conf_dir = self._new_config_dir(context, case_work_dir, "upload") + config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['no_remote_delete = "true"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only_no_remote_delete")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"--upload-only --no-remote-delete failed with status {result.returncode}", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + artifacts.extend(self._write_manifests(verify_root, case_state_dir, "remote_manifest")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + verify_snapshot = self._snapshot_files(verify_root) + expected = {f"{root_name}/RemoteKeep/preserve.txt", f"{root_name}/LocalUpload/new.txt"} + missing = sorted(expected - set(verify_snapshot.keys())) + if missing: + return TestResult.fail_result(self.case_id, self.name, "Remote content was deleted or not uploaded as expected", artifacts, {"missing": missing}) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py new file mode 100644 index 000000000..d01805320 --- /dev/null +++ b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0010UploadOnlyRemoveSourceFiles(Wave1TestCaseBase): + case_id = "0010" + name = "upload-only remove-source-files" + description = "Validate that local files are removed after successful upload when remove_source_files is enabled" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + sync_root = case_work_dir / "upload-syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + source_file = sync_root / root_name / "Source" / "upload_and_remove.txt" + self._create_text_file(source_file, "remove after upload\n") + conf_dir = self._new_config_dir(context, case_work_dir, "upload") + config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['remove_source_files = "true"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only_remove_source")) + artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"--upload-only with remove_source_files failed with status {result.returncode}", artifacts) + if source_file.exists(): + return TestResult.fail_result(self.case_id, self.name, "Source file still exists locally after upload", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + if not (verify_root / root_name / "Source" / "upload_and_remove.txt").exists(): + return TestResult.fail_result(self.case_id, self.name, "Uploaded file was not present remotely after local removal", artifacts) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0011_skip_file_validation.py b/ci/e2e/testcases/tc0011_skip_file_validation.py new file mode 100644 index 000000000..756ccb548 --- /dev/null +++ b/ci/e2e/testcases/tc0011_skip_file_validation.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0011SkipFileValidation(Wave1TestCaseBase): + case_id = "0011" + name = "skip_file validation" + description = "Validate that skip_file patterns exclude matching files from synchronisation" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + sync_root = case_work_dir / "syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(sync_root / root_name / "keep.txt", "keep me\n") + self._create_text_file(sync_root / root_name / "ignore.tmp", "temp\n") + self._create_text_file(sync_root / root_name / "editor.swp", "swap\n") + self._create_text_file(sync_root / root_name / "Nested" / "keep.md", "nested keep\n") + conf_dir = self._new_config_dir(context, case_work_dir, "main") + config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['skip_file = "*.tmp|*.swp"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_file")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"skip_file validation failed with status {result.returncode}", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + artifacts.extend(self._write_manifests(verify_root, case_state_dir, "remote_manifest")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + snapshot = self._snapshot_files(verify_root) + expected = {f"{root_name}/keep.txt", f"{root_name}/Nested/keep.md"} + missing = sorted(expected - set(snapshot.keys())) + if missing: + return TestResult.fail_result(self.case_id, self.name, "Expected non-skipped files are missing remotely", artifacts, {"missing": missing}) + present = sorted(path for path in [f"{root_name}/ignore.tmp", f"{root_name}/editor.swp"] if path in snapshot) + if present: + return TestResult.fail_result(self.case_id, self.name, "skip_file patterns did not exclude all matching files", artifacts, {"present": present}) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0012_skip_dir_validation.py b/ci/e2e/testcases/tc0012_skip_dir_validation.py new file mode 100644 index 000000000..08ac6c6b6 --- /dev/null +++ b/ci/e2e/testcases/tc0012_skip_dir_validation.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0012SkipDirValidation(Wave1TestCaseBase): + case_id = "0012" + name = "skip_dir validation" + description = "Validate loose and strict skip_dir matching behaviour" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + failures = [] + + loose_root = case_work_dir / "loose-syncroot" + loose_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(loose_root / root_name / "project" / "build" / "out.bin", "skip me\n") + self._create_text_file(loose_root / root_name / "build" / "root.bin", "skip me too\n") + self._create_text_file(loose_root / root_name / "project" / "src" / "app.txt", "keep me\n") + loose_conf = self._new_config_dir(context, case_work_dir, "loose") + config_path, sync_list_path = self._write_config(loose_conf, extra_lines=['skip_dir = "build"', 'skip_dir_strict_match = "false"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + loose_result = self._run_onedrive(context, sync_root=loose_root, config_dir=loose_conf) + artifacts.extend(self._write_command_artifacts(result=loose_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="loose_match")) + if loose_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Loose skip_dir scenario failed with status {loose_result.returncode}", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "loose_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="loose_verify")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Loose skip_dir verification failed with status {verify_result.returncode}", artifacts) + loose_snapshot = self._snapshot_files(verify_root) + if f"{root_name}/project/src/app.txt" not in loose_snapshot: + failures.append("Loose matching did not retain non-build content") + for forbidden in [f"{root_name}/project/build/out.bin", f"{root_name}/build/root.bin"]: + if forbidden in loose_snapshot: + failures.append(f"Loose matching did not exclude {forbidden}") + + strict_scope = f"{root_name}_STRICT" + strict_root = case_work_dir / "strict-syncroot" + strict_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(strict_root / strict_scope / "project" / "build" / "skip.bin", "skip strict\n") + self._create_text_file(strict_root / strict_scope / "other" / "build" / "keep.bin", "keep strict\n") + self._create_text_file(strict_root / strict_scope / "other" / "src" / "keep.txt", "keep strict txt\n") + strict_conf = self._new_config_dir(context, case_work_dir, "strict") + config_path, sync_list_path = self._write_config(strict_conf, extra_lines=[f'skip_dir = "{strict_scope}/project/build"', 'skip_dir_strict_match = "true"'], sync_list_entries=[f"/{strict_scope}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + strict_result = self._run_onedrive(context, sync_root=strict_root, config_dir=strict_conf) + artifacts.extend(self._write_command_artifacts(result=strict_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="strict_match")) + if strict_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Strict skip_dir scenario failed with status {strict_result.returncode}", artifacts) + strict_verify_root, strict_verify_result, strict_verify_artifacts = self._download_remote_scope(context, case_work_dir, strict_scope, "strict_remote") + artifacts.extend(strict_verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=strict_verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="strict_verify")) + if strict_verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Strict skip_dir verification failed with status {strict_verify_result.returncode}", artifacts) + strict_snapshot = self._snapshot_files(strict_verify_root) + if f"{strict_scope}/project/build/skip.bin" in strict_snapshot: + failures.append("Strict matching did not exclude the targeted full path") + for required in [f"{strict_scope}/other/build/keep.bin", f"{strict_scope}/other/src/keep.txt"]: + if required not in strict_snapshot: + failures.append(f"Strict matching excluded unexpected content: {required}") + artifacts.extend(self._write_manifests(verify_root, case_state_dir, "loose_manifest")) + artifacts.extend(self._write_manifests(strict_verify_root, case_state_dir, "strict_manifest")) + if failures: + return TestResult.fail_result(self.case_id, self.name, "; ".join(failures), artifacts, {"failure_count": len(failures)}) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name, "strict_scope": strict_scope}) diff --git a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py new file mode 100644 index 000000000..36730cecf --- /dev/null +++ b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0013SkipDotfilesValidation(Wave1TestCaseBase): + case_id = "0013" + name = "skip_dotfiles validation" + description = "Validate that dotfiles and dot-directories are excluded when skip_dotfiles is enabled" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + sync_root = case_work_dir / "syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(sync_root / root_name / ".hidden.txt", "hidden\n") + self._create_text_file(sync_root / root_name / ".dotdir" / "inside.txt", "inside dotdir\n") + self._create_text_file(sync_root / root_name / "visible.txt", "visible\n") + self._create_text_file(sync_root / root_name / "normal" / "keep.md", "normal keep\n") + conf_dir = self._new_config_dir(context, case_work_dir, "main") + config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['skip_dotfiles = "true"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_dotfiles")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"skip_dotfiles validation failed with status {result.returncode}", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + snapshot = self._snapshot_files(verify_root) + for required in [f"{root_name}/visible.txt", f"{root_name}/normal/keep.md"]: + if required not in snapshot: + return TestResult.fail_result(self.case_id, self.name, f"Expected visible content missing remotely: {required}", artifacts) + for forbidden in [f"{root_name}/.hidden.txt", f"{root_name}/.dotdir/inside.txt"]: + if forbidden in snapshot: + return TestResult.fail_result(self.case_id, self.name, f"Dotfile content was unexpectedly synchronised: {forbidden}", artifacts) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0014_skip_size_validation.py b/ci/e2e/testcases/tc0014_skip_size_validation.py new file mode 100644 index 000000000..c28bef8f4 --- /dev/null +++ b/ci/e2e/testcases/tc0014_skip_size_validation.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0014SkipSizeValidation(Wave1TestCaseBase): + case_id = "0014" + name = "skip_size validation" + description = "Validate that files above the configured size threshold are excluded from synchronisation" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + sync_root = case_work_dir / "syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_binary_file(sync_root / root_name / "small.bin", 128 * 1024) + self._create_binary_file(sync_root / root_name / "large.bin", 2 * 1024 * 1024) + conf_dir = self._new_config_dir(context, case_work_dir, "main") + config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['skip_size = "1"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_size")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"skip_size validation failed with status {result.returncode}", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + snapshot = self._snapshot_files(verify_root) + if f"{root_name}/small.bin" not in snapshot: + return TestResult.fail_result(self.case_id, self.name, "Small file is missing remotely", artifacts) + if f"{root_name}/large.bin" in snapshot: + return TestResult.fail_result(self.case_id, self.name, "Large file exceeded skip_size threshold but was synchronised", artifacts) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py new file mode 100644 index 000000000..201548b14 --- /dev/null +++ b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0015SkipSymlinksValidation(Wave1TestCaseBase): + case_id = "0015" + name = "skip_symlinks validation" + description = "Validate that symbolic links are excluded when skip_symlinks is enabled" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + sync_root = case_work_dir / "syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + target_file = sync_root / root_name / "real.txt" + self._create_text_file(target_file, "real content\n") + symlink_path = sync_root / root_name / "real-link.txt" + symlink_path.parent.mkdir(parents=True, exist_ok=True) + os.symlink("real.txt", symlink_path) + conf_dir = self._new_config_dir(context, case_work_dir, "main") + config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['skip_symlinks = "true"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + artifacts.append(self._write_json_artifact(case_state_dir / "local_snapshot_pre.json", self._snapshot_files(sync_root))) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_symlinks")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"skip_symlinks validation failed with status {result.returncode}", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + snapshot = self._snapshot_files(verify_root) + if f"{root_name}/real.txt" not in snapshot: + return TestResult.fail_result(self.case_id, self.name, "Real file is missing remotely", artifacts) + if f"{root_name}/real-link.txt" in snapshot: + return TestResult.fail_result(self.case_id, self.name, "Symbolic link was unexpectedly synchronised", artifacts) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/tc0016_check_nosync_validation.py b/ci/e2e/testcases/tc0016_check_nosync_validation.py new file mode 100644 index 000000000..d0397ebf1 --- /dev/null +++ b/ci/e2e/testcases/tc0016_check_nosync_validation.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from framework.result import TestResult +from testcases.wave1_common import Wave1TestCaseBase + + +class TestCase0016CheckNosyncValidation(Wave1TestCaseBase): + case_id = "0016" + name = "check_nosync validation" + description = "Validate that local directories containing .nosync are excluded when check_nosync is enabled" + + def run(self, context): + case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + root_name = self._root_name(context) + artifacts = [] + sync_root = case_work_dir / "syncroot" + sync_root.mkdir(parents=True, exist_ok=True) + self._create_text_file(sync_root / root_name / "Blocked" / ".nosync", "marker\n") + self._create_text_file(sync_root / root_name / "Blocked" / "blocked.txt", "blocked\n") + self._create_text_file(sync_root / root_name / "Allowed" / "allowed.txt", "allowed\n") + conf_dir = self._new_config_dir(context, case_work_dir, "main") + config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['check_nosync = "true"'], sync_list_entries=[f"/{root_name}"]) + artifacts.extend([str(config_path), str(sync_list_path)]) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) + artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="check_nosync")) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"check_nosync validation failed with status {result.returncode}", artifacts) + verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") + artifacts.extend(verify_artifacts) + artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) + snapshot = self._snapshot_files(verify_root) + if f"{root_name}/Allowed/allowed.txt" not in snapshot: + return TestResult.fail_result(self.case_id, self.name, "Allowed content is missing remotely", artifacts) + for forbidden in [f"{root_name}/Blocked/blocked.txt", f"{root_name}/Blocked/.nosync"]: + if forbidden in snapshot: + return TestResult.fail_result(self.case_id, self.name, f".nosync-protected content was unexpectedly synchronised: {forbidden}", artifacts) + return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) diff --git a/ci/e2e/testcases/wave1_common.py b/ci/e2e/testcases/wave1_common.py new file mode 100644 index 000000000..99f10a940 --- /dev/null +++ b/ci/e2e/testcases/wave1_common.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +from pathlib import Path +from typing import Iterable + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.utils import ( + command_to_string, + reset_directory, + run_command, + write_text_file, +) + +CONFIG_FILE_NAME = "config" +SYNC_LIST_FILE_NAME = "sync_list" + + +class Wave1TestCaseBase(E2ETestCase): + """ + Shared helper base for Wave 1 E2E test cases. + """ + + def _safe_run_id(self, context: E2EContext) -> str: + value = re.sub(r"[^A-Za-z0-9]+", "_", context.run_id).strip("_").lower() + return value or "run" + + def _root_name(self, context: E2EContext) -> str: + return f"ZZ_E2E_TC{self.case_id}_{self._safe_run_id(context)}" + + def _initialise_case_dirs(self, context: E2EContext) -> tuple[Path, Path, Path]: + case_work_dir = context.work_root / f"tc{self.case_id}" + case_log_dir = context.logs_dir / f"tc{self.case_id}" + case_state_dir = context.state_dir / f"tc{self.case_id}" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(case_state_dir) + return case_work_dir, case_log_dir, case_state_dir + + def _new_config_dir(self, context: E2EContext, case_work_dir: Path, name: str) -> Path: + config_dir = case_work_dir / f"conf-{name}" + reset_directory(config_dir) + context.bootstrap_config_dir(config_dir) + return config_dir + + def _write_config( + self, + config_dir: Path, + *, + extra_lines: Iterable[str] | None = None, + sync_list_entries: Iterable[str] | None = None, + ) -> tuple[Path, Path | None]: + config_path = config_dir / CONFIG_FILE_NAME + sync_list_path: Path | None = None + + lines = [ + f"# tc{self.case_id} generated config", + 'bypass_data_preservation = "true"', + 'monitor_interval = "5"', + ] + if extra_lines: + lines.extend(list(extra_lines)) + write_text_file(config_path, "\n".join(lines) + "\n") + + if sync_list_entries is not None: + sync_list_path = config_dir / SYNC_LIST_FILE_NAME + write_text_file(sync_list_path, "\n".join(sync_list_entries) + "\n") + + return config_path, sync_list_path + + def _run_onedrive( + self, + context: E2EContext, + *, + sync_root: Path, + config_dir: Path, + extra_args: list[str] | None = None, + use_resync: bool = True, + use_resync_auth: bool = True, + ): + command = [context.onedrive_bin, "--sync", "--verbose"] + if use_resync: + command.append("--resync") + if use_resync_auth: + command.append("--resync-auth") + command.extend(["--syncdir", str(sync_root), "--confdir", str(config_dir)]) + if extra_args: + command.extend(extra_args) + + context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") + return run_command(command, cwd=context.repo_root) + + def _write_command_artifacts( + self, + *, + result, + log_dir: Path, + state_dir: Path, + phase_name: str, + extra_metadata: dict[str, str | int | bool] | None = None, + ) -> list[str]: + stdout_file = log_dir / f"{phase_name}_stdout.log" + stderr_file = log_dir / f"{phase_name}_stderr.log" + metadata_file = state_dir / f"{phase_name}_metadata.txt" + + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + + metadata = { + "phase": phase_name, + "command": command_to_string(result.command), + "returncode": result.returncode, + } + if extra_metadata: + metadata.update(extra_metadata) + + lines = [f"{key}={value}" for key, value in metadata.items()] + write_text_file(metadata_file, "\n".join(lines) + "\n") + + return [str(stdout_file), str(stderr_file), str(metadata_file)] + + def _write_manifests(self, root: Path, state_dir: Path, prefix: str) -> list[str]: + manifest_file = state_dir / f"{prefix}_manifest.txt" + write_manifest(manifest_file, build_manifest(root)) + return [str(manifest_file)] + + def _write_json_artifact(self, path: Path, payload: object) -> str: + write_text_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n") + return str(path) + + def _create_text_file(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def _create_binary_file(self, path: Path, size_bytes: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + chunk = os.urandom(min(size_bytes, 1024 * 1024)) + with path.open("wb") as fp: + remaining = size_bytes + while remaining > 0: + to_write = chunk[: min(len(chunk), remaining)] + fp.write(to_write) + remaining -= len(to_write) + + def _snapshot_files(self, root: Path) -> dict[str, str]: + result: dict[str, str] = {} + if not root.exists(): + return result + + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_symlink(): + result[rel] = f"symlink->{os.readlink(path)}" + continue + if path.is_dir(): + result[rel] = "dir" + continue + hasher = hashlib.sha256() + with path.open("rb") as fp: + while True: + chunk = fp.read(8192) + if not chunk: + break + hasher.update(chunk) + result[rel] = hasher.hexdigest() + return result + + def _download_remote_scope( + self, + context: E2EContext, + case_work_dir: Path, + scope_root: str, + name: str, + *, + extra_config_lines: Iterable[str] | None = None, + extra_args: list[str] | None = None, + ) -> tuple[Path, object, list[str]]: + verify_root = case_work_dir / f"verify-{name}" + reset_directory(verify_root) + config_dir = self._new_config_dir(context, case_work_dir, f"verify-{name}") + config_path, sync_list_path = self._write_config( + config_dir, + extra_lines=extra_config_lines, + sync_list_entries=[f"/{scope_root}"], + ) + result = self._run_onedrive( + context, + sync_root=verify_root, + config_dir=config_dir, + extra_args=["--download-only"] + (extra_args or []), + ) + artifacts = [str(config_path)] + if sync_list_path: + artifacts.append(str(sync_list_path)) + return verify_root, result, artifacts From 4ceec373e96708ded2006674e8b76c563e8e2645 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 17:32:35 +1100 Subject: [PATCH 044/245] Update Test Cases 0003 to 0016 * Update Test Cases 0003 to 0016 --- ci/e2e/framework/utils.py | 2 ++ ci/e2e/testcases/tc0003_dry_run_validation.py | 8 +++--- .../testcases/tc0004_single_directory_sync.py | 4 +-- .../testcases/tc0005_force_sync_override.py | 20 +++++++++----- ci/e2e/testcases/tc0006_download_only.py | 8 +++--- ...c0007_download_only_cleanup_local_files.py | 15 +++++++---- ci/e2e/testcases/tc0008_upload_only.py | 4 +-- .../tc0009_upload_only_no_remote_delete.py | 8 +++--- .../tc0010_upload_only_remove_source_files.py | 4 +-- .../testcases/tc0011_skip_file_validation.py | 4 +-- .../testcases/tc0012_skip_dir_validation.py | 8 +++--- .../tc0013_skip_dotfiles_validation.py | 6 ++--- .../testcases/tc0014_skip_size_validation.py | 6 ++--- .../tc0015_skip_symlinks_validation.py | 4 +-- .../tc0016_check_nosync_validation.py | 4 +-- ci/e2e/testcases/wave1_common.py | 26 +++++++------------ 16 files changed, 69 insertions(+), 62 deletions(-) diff --git a/ci/e2e/framework/utils.py b/ci/e2e/framework/utils.py index 52e72d787..66f2976f0 100644 --- a/ci/e2e/framework/utils.py +++ b/ci/e2e/framework/utils.py @@ -49,6 +49,7 @@ def run_command( command: list[str], cwd: Path | None = None, env: dict[str, str] | None = None, + input_text: str | None = None, ) -> CommandResult: merged_env = os.environ.copy() if env: @@ -64,6 +65,7 @@ def run_command( encoding="utf-8", errors="replace", check=False, + input=input_text, ) return CommandResult( diff --git a/ci/e2e/testcases/tc0003_dry_run_validation.py b/ci/e2e/testcases/tc0003_dry_run_validation.py index 0975ee752..c5187709c 100644 --- a/ci/e2e/testcases/tc0003_dry_run_validation.py +++ b/ci/e2e/testcases/tc0003_dry_run_validation.py @@ -21,8 +21,8 @@ def run(self, context): self._create_binary_file(seed_root / root_name / "Data" / "payload.bin", 64 * 1024) seed_config_dir = self._new_config_dir(context, case_work_dir, "seed") - config_path, sync_list_path = self._write_config(seed_config_dir, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(seed_config_dir) + artifacts.append(str(config_path)) seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_config_dir) artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) artifacts.extend(self._write_manifests(seed_root, case_state_dir, "seed_local")) @@ -37,8 +37,8 @@ def run(self, context): artifacts.append(self._write_json_artifact(case_state_dir / "pre_snapshot.json", pre_snapshot)) dry_config_dir = self._new_config_dir(context, case_work_dir, "dryrun") - config_path, sync_list_path = self._write_config(dry_config_dir, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(dry_config_dir) + artifacts.append(str(config_path)) dry_result = self._run_onedrive(context, sync_root=dry_root, config_dir=dry_config_dir, extra_args=["--dry-run"]) artifacts.extend(self._write_command_artifacts(result=dry_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="dry_run")) post_snapshot = self._snapshot_files(dry_root) diff --git a/ci/e2e/testcases/tc0004_single_directory_sync.py b/ci/e2e/testcases/tc0004_single_directory_sync.py index 997925d4b..24d498f70 100644 --- a/ci/e2e/testcases/tc0004_single_directory_sync.py +++ b/ci/e2e/testcases/tc0004_single_directory_sync.py @@ -21,8 +21,8 @@ def run(self, context): self._create_text_file(sync_root / root_name / "Unscoped" / "exclude.txt", "should stay local only\n") config_dir = self._new_config_dir(context, case_work_dir, "main") - config_path, sync_list_path = self._write_config(config_dir, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(config_dir) + artifacts.append(str(config_path)) result = self._run_onedrive(context, sync_root=sync_root, config_dir=config_dir, extra_args=["--single-directory", f"{root_name}/Scoped"]) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="single_directory")) artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) diff --git a/ci/e2e/testcases/tc0005_force_sync_override.py b/ci/e2e/testcases/tc0005_force_sync_override.py index 65a8bad52..6aa77f3e9 100644 --- a/ci/e2e/testcases/tc0005_force_sync_override.py +++ b/ci/e2e/testcases/tc0005_force_sync_override.py @@ -18,8 +18,8 @@ def run(self, context): seed_root.mkdir(parents=True, exist_ok=True) self._create_text_file(seed_root / root_name / "Blocked" / "blocked.txt", "blocked remote file\n") seed_conf = self._new_config_dir(context, case_work_dir, "seed") - config_path, sync_list_path = self._write_config(seed_conf, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(seed_conf) + artifacts.append(str(config_path)) seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) if seed_result.returncode != 0: @@ -28,8 +28,8 @@ def run(self, context): no_force_root = case_work_dir / "no-force-syncroot" no_force_root.mkdir(parents=True, exist_ok=True) no_force_conf = self._new_config_dir(context, case_work_dir, "no-force") - config_path, sync_list_path = self._write_config(no_force_conf, extra_lines=['skip_dir = "Blocked"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(no_force_conf, extra_lines=['skip_dir = "Blocked"']) + artifacts.append(str(config_path)) no_force_result = self._run_onedrive(context, sync_root=no_force_root, config_dir=no_force_conf, extra_args=["--download-only", "--single-directory", f"{root_name}/Blocked"]) artifacts.extend(self._write_command_artifacts(result=no_force_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="no_force")) if no_force_result.returncode != 0: @@ -40,9 +40,15 @@ def run(self, context): force_root = case_work_dir / "force-syncroot" force_root.mkdir(parents=True, exist_ok=True) force_conf = self._new_config_dir(context, case_work_dir, "force") - config_path, sync_list_path = self._write_config(force_conf, extra_lines=['skip_dir = "Blocked"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) - force_result = self._run_onedrive(context, sync_root=force_root, config_dir=force_conf, extra_args=["--download-only", "--single-directory", f"{root_name}/Blocked", "--force-sync"]) + config_path = self._write_config(force_conf, extra_lines=['skip_dir = "Blocked"']) + artifacts.append(str(config_path)) + force_result = self._run_onedrive( + context, + sync_root=force_root, + config_dir=force_conf, + extra_args=["--download-only", "--single-directory", f"{root_name}/Blocked", "--force-sync"], + input_text="Y\n", + ) artifacts.extend(self._write_command_artifacts(result=force_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="force")) artifacts.extend(self._write_manifests(force_root, case_state_dir, "force_manifest")) if force_result.returncode != 0: diff --git a/ci/e2e/testcases/tc0006_download_only.py b/ci/e2e/testcases/tc0006_download_only.py index 5fa68a1fe..c51d0fd2e 100644 --- a/ci/e2e/testcases/tc0006_download_only.py +++ b/ci/e2e/testcases/tc0006_download_only.py @@ -17,8 +17,8 @@ def run(self, context): seed_root.mkdir(parents=True, exist_ok=True) self._create_text_file(seed_root / root_name / "Remote" / "download_me.txt", "remote file\n") seed_conf = self._new_config_dir(context, case_work_dir, "seed") - config_path, sync_list_path = self._write_config(seed_conf, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(seed_conf) + artifacts.append(str(config_path)) seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) if seed_result.returncode != 0: @@ -28,8 +28,8 @@ def run(self, context): sync_root.mkdir(parents=True, exist_ok=True) self._create_text_file(sync_root / root_name / "LocalOnly" / "stay_local.txt", "must not upload\n") conf_dir = self._new_config_dir(context, case_work_dir, "download") - config_path, sync_list_path = self._write_config(conf_dir, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(conf_dir) + artifacts.append(str(config_path)) result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--download-only"]) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="download_only")) artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) diff --git a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py index 449c8a15f..9afc64d8b 100644 --- a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py +++ b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py @@ -17,8 +17,8 @@ def run(self, context): seed_root.mkdir(parents=True, exist_ok=True) self._create_text_file(seed_root / root_name / "Keep" / "keep.txt", "keep\n") seed_conf = self._new_config_dir(context, case_work_dir, "seed") - config_path, sync_list_path = self._write_config(seed_conf, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(seed_conf) + artifacts.append(str(config_path)) seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) if seed_result.returncode != 0: @@ -29,9 +29,14 @@ def run(self, context): self._create_text_file(sync_root / root_name / "Keep" / "keep.txt", "local keep placeholder\n") self._create_text_file(sync_root / root_name / "Obsolete" / "old.txt", "obsolete\n") conf_dir = self._new_config_dir(context, case_work_dir, "cleanup") - config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['cleanup_local_files = "true"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--download-only"]) + config_path = self._write_config(conf_dir, extra_lines=['cleanup_local_files = "true"']) + artifacts.append(str(config_path)) + result = self._run_onedrive( + context, + sync_root=sync_root, + config_dir=conf_dir, + extra_args=["--download-only", "--single-directory", root_name], + ) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="cleanup_download_only")) artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) if result.returncode != 0: diff --git a/ci/e2e/testcases/tc0008_upload_only.py b/ci/e2e/testcases/tc0008_upload_only.py index ff5af4e90..2e5caf822 100644 --- a/ci/e2e/testcases/tc0008_upload_only.py +++ b/ci/e2e/testcases/tc0008_upload_only.py @@ -18,8 +18,8 @@ def run(self, context): self._create_text_file(sync_root / root_name / "Upload" / "file.txt", "upload me\n") self._create_binary_file(sync_root / root_name / "Upload" / "blob.bin", 70 * 1024) conf_dir = self._new_config_dir(context, case_work_dir, "upload") - config_path, sync_list_path = self._write_config(conf_dir, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(conf_dir) + artifacts.append(str(config_path)) result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only")) if result.returncode != 0: diff --git a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py index 378e56fc4..510504f51 100644 --- a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py +++ b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py @@ -17,8 +17,8 @@ def run(self, context): seed_root.mkdir(parents=True, exist_ok=True) self._create_text_file(seed_root / root_name / "RemoteKeep" / "preserve.txt", "preserve remotely\n") seed_conf = self._new_config_dir(context, case_work_dir, "seed") - config_path, sync_list_path = self._write_config(seed_conf, sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(seed_conf) + artifacts.append(str(config_path)) seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) if seed_result.returncode != 0: @@ -28,8 +28,8 @@ def run(self, context): sync_root.mkdir(parents=True, exist_ok=True) self._create_text_file(sync_root / root_name / "LocalUpload" / "new.txt", "new upload\n") conf_dir = self._new_config_dir(context, case_work_dir, "upload") - config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['no_remote_delete = "true"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(conf_dir, extra_lines=['no_remote_delete = "true"']) + artifacts.append(str(config_path)) result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only_no_remote_delete")) if result.returncode != 0: diff --git a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py index d01805320..75c62afe4 100644 --- a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py +++ b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py @@ -18,8 +18,8 @@ def run(self, context): source_file = sync_root / root_name / "Source" / "upload_and_remove.txt" self._create_text_file(source_file, "remove after upload\n") conf_dir = self._new_config_dir(context, case_work_dir, "upload") - config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['remove_source_files = "true"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(conf_dir, extra_lines=['remove_source_files = "true"']) + artifacts.append(str(config_path)) result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only_remove_source")) artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) diff --git a/ci/e2e/testcases/tc0011_skip_file_validation.py b/ci/e2e/testcases/tc0011_skip_file_validation.py index 756ccb548..8e5c16e5f 100644 --- a/ci/e2e/testcases/tc0011_skip_file_validation.py +++ b/ci/e2e/testcases/tc0011_skip_file_validation.py @@ -20,8 +20,8 @@ def run(self, context): self._create_text_file(sync_root / root_name / "editor.swp", "swap\n") self._create_text_file(sync_root / root_name / "Nested" / "keep.md", "nested keep\n") conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['skip_file = "*.tmp|*.swp"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(conf_dir, extra_lines=['skip_file = "*.tmp|*.swp"']) + artifacts.append(str(config_path)) result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_file")) if result.returncode != 0: diff --git a/ci/e2e/testcases/tc0012_skip_dir_validation.py b/ci/e2e/testcases/tc0012_skip_dir_validation.py index 08ac6c6b6..ad521b4ba 100644 --- a/ci/e2e/testcases/tc0012_skip_dir_validation.py +++ b/ci/e2e/testcases/tc0012_skip_dir_validation.py @@ -21,8 +21,8 @@ def run(self, context): self._create_text_file(loose_root / root_name / "build" / "root.bin", "skip me too\n") self._create_text_file(loose_root / root_name / "project" / "src" / "app.txt", "keep me\n") loose_conf = self._new_config_dir(context, case_work_dir, "loose") - config_path, sync_list_path = self._write_config(loose_conf, extra_lines=['skip_dir = "build"', 'skip_dir_strict_match = "false"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(loose_conf, extra_lines=['skip_dir = "build"', 'skip_dir_strict_match = "false"']) + artifacts.append(str(config_path)) loose_result = self._run_onedrive(context, sync_root=loose_root, config_dir=loose_conf) artifacts.extend(self._write_command_artifacts(result=loose_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="loose_match")) if loose_result.returncode != 0: @@ -46,8 +46,8 @@ def run(self, context): self._create_text_file(strict_root / strict_scope / "other" / "build" / "keep.bin", "keep strict\n") self._create_text_file(strict_root / strict_scope / "other" / "src" / "keep.txt", "keep strict txt\n") strict_conf = self._new_config_dir(context, case_work_dir, "strict") - config_path, sync_list_path = self._write_config(strict_conf, extra_lines=[f'skip_dir = "{strict_scope}/project/build"', 'skip_dir_strict_match = "true"'], sync_list_entries=[f"/{strict_scope}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(strict_conf, extra_lines=[f'skip_dir = "{strict_scope}/project/build"', 'skip_dir_strict_match = "true"']) + artifacts.append(str(config_path)) strict_result = self._run_onedrive(context, sync_root=strict_root, config_dir=strict_conf) artifacts.extend(self._write_command_artifacts(result=strict_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="strict_match")) if strict_result.returncode != 0: diff --git a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py index 36730cecf..b0102aff7 100644 --- a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py +++ b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py @@ -20,9 +20,9 @@ def run(self, context): self._create_text_file(sync_root / root_name / "visible.txt", "visible\n") self._create_text_file(sync_root / root_name / "normal" / "keep.md", "normal keep\n") conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['skip_dotfiles = "true"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) + config_path = self._write_config(conf_dir, extra_lines=['skip_dotfiles = "true"']) + artifacts.append(str(config_path)) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--single-directory", root_name]) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_dotfiles")) if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"skip_dotfiles validation failed with status {result.returncode}", artifacts) diff --git a/ci/e2e/testcases/tc0014_skip_size_validation.py b/ci/e2e/testcases/tc0014_skip_size_validation.py index c28bef8f4..760f269c6 100644 --- a/ci/e2e/testcases/tc0014_skip_size_validation.py +++ b/ci/e2e/testcases/tc0014_skip_size_validation.py @@ -18,9 +18,9 @@ def run(self, context): self._create_binary_file(sync_root / root_name / "small.bin", 128 * 1024) self._create_binary_file(sync_root / root_name / "large.bin", 2 * 1024 * 1024) conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['skip_size = "1"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) + config_path = self._write_config(conf_dir, extra_lines=['skip_size = "1"']) + artifacts.append(str(config_path)) + result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--single-directory", root_name]) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_size")) if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"skip_size validation failed with status {result.returncode}", artifacts) diff --git a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py index 201548b14..5d4510ffd 100644 --- a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py +++ b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py @@ -23,8 +23,8 @@ def run(self, context): symlink_path.parent.mkdir(parents=True, exist_ok=True) os.symlink("real.txt", symlink_path) conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['skip_symlinks = "true"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(conf_dir, extra_lines=['skip_symlinks = "true"']) + artifacts.append(str(config_path)) artifacts.append(self._write_json_artifact(case_state_dir / "local_snapshot_pre.json", self._snapshot_files(sync_root))) result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_symlinks")) diff --git a/ci/e2e/testcases/tc0016_check_nosync_validation.py b/ci/e2e/testcases/tc0016_check_nosync_validation.py index d0397ebf1..1164ba881 100644 --- a/ci/e2e/testcases/tc0016_check_nosync_validation.py +++ b/ci/e2e/testcases/tc0016_check_nosync_validation.py @@ -19,8 +19,8 @@ def run(self, context): self._create_text_file(sync_root / root_name / "Blocked" / "blocked.txt", "blocked\n") self._create_text_file(sync_root / root_name / "Allowed" / "allowed.txt", "allowed\n") conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path, sync_list_path = self._write_config(conf_dir, extra_lines=['check_nosync = "true"'], sync_list_entries=[f"/{root_name}"]) - artifacts.extend([str(config_path), str(sync_list_path)]) + config_path = self._write_config(conf_dir, extra_lines=['check_nosync = "true"']) + artifacts.append(str(config_path)) result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="check_nosync")) if result.returncode != 0: diff --git a/ci/e2e/testcases/wave1_common.py b/ci/e2e/testcases/wave1_common.py index 99f10a940..48566360c 100644 --- a/ci/e2e/testcases/wave1_common.py +++ b/ci/e2e/testcases/wave1_common.py @@ -18,12 +18,14 @@ ) CONFIG_FILE_NAME = "config" -SYNC_LIST_FILE_NAME = "sync_list" class Wave1TestCaseBase(E2ETestCase): """ Shared helper base for Wave 1 E2E test cases. + + Important design rule: Wave 1 test cases must not use sync_list. + TC0002 is the sole owner of sync_list validation. """ def _safe_run_id(self, context: E2EContext) -> str: @@ -53,25 +55,19 @@ def _write_config( config_dir: Path, *, extra_lines: Iterable[str] | None = None, - sync_list_entries: Iterable[str] | None = None, - ) -> tuple[Path, Path | None]: + ) -> Path: config_path = config_dir / CONFIG_FILE_NAME - sync_list_path: Path | None = None lines = [ f"# tc{self.case_id} generated config", 'bypass_data_preservation = "true"', - 'monitor_interval = "5"', + 'monitor_interval = 5', ] if extra_lines: lines.extend(list(extra_lines)) write_text_file(config_path, "\n".join(lines) + "\n") - if sync_list_entries is not None: - sync_list_path = config_dir / SYNC_LIST_FILE_NAME - write_text_file(sync_list_path, "\n".join(sync_list_entries) + "\n") - - return config_path, sync_list_path + return config_path def _run_onedrive( self, @@ -82,6 +78,7 @@ def _run_onedrive( extra_args: list[str] | None = None, use_resync: bool = True, use_resync_auth: bool = True, + input_text: str | None = None, ): command = [context.onedrive_bin, "--sync", "--verbose"] if use_resync: @@ -93,7 +90,7 @@ def _run_onedrive( command.extend(extra_args) context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") - return run_command(command, cwd=context.repo_root) + return run_command(command, cwd=context.repo_root, input_text=input_text) def _write_command_artifacts( self, @@ -183,18 +180,15 @@ def _download_remote_scope( verify_root = case_work_dir / f"verify-{name}" reset_directory(verify_root) config_dir = self._new_config_dir(context, case_work_dir, f"verify-{name}") - config_path, sync_list_path = self._write_config( + config_path = self._write_config( config_dir, extra_lines=extra_config_lines, - sync_list_entries=[f"/{scope_root}"], ) result = self._run_onedrive( context, sync_root=verify_root, config_dir=config_dir, - extra_args=["--download-only"] + (extra_args or []), + extra_args=["--download-only", "--single-directory", scope_root] + (extra_args or []), ) artifacts = [str(config_path)] - if sync_list_path: - artifacts.append(str(sync_list_path)) return verify_root, result, artifacts From c71469c79224cee698057ff35f7c763019633ba2 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 13 Mar 2026 18:10:57 +1100 Subject: [PATCH 045/245] Update PR * Update PR --- ci/e2e/testcases/tc0003_dry_run_validation.py | 192 +++++++++++------ .../testcases/tc0004_single_directory_sync.py | 153 ++++++++++---- .../testcases/tc0005_force_sync_override.py | 147 ++++++++----- ci/e2e/testcases/tc0006_download_only.py | 76 +++---- ...c0007_download_only_cleanup_local_files.py | 73 +++---- ci/e2e/testcases/tc0008_upload_only.py | 63 +++--- .../tc0009_upload_only_no_remote_delete.py | 77 ++++--- .../tc0010_upload_only_remove_source_files.py | 63 +++--- .../testcases/tc0011_skip_file_validation.py | 70 +++---- .../testcases/tc0012_skip_dir_validation.py | 125 +++++------ .../tc0013_skip_dotfiles_validation.py | 68 +++--- .../testcases/tc0014_skip_size_validation.py | 64 +++--- .../tc0015_skip_symlinks_validation.py | 65 +++--- .../tc0016_check_nosync_validation.py | 66 +++--- ci/e2e/testcases/wave1_common.py | 194 ------------------ 15 files changed, 751 insertions(+), 745 deletions(-) delete mode 100644 ci/e2e/testcases/wave1_common.py diff --git a/ci/e2e/testcases/tc0003_dry_run_validation.py b/ci/e2e/testcases/tc0003_dry_run_validation.py index c5187709c..6d4a0d8f0 100644 --- a/ci/e2e/testcases/tc0003_dry_run_validation.py +++ b/ci/e2e/testcases/tc0003_dry_run_validation.py @@ -1,73 +1,141 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0003DryRunValidation(Wave1TestCaseBase): +class TestCase0003DryRunValidation(E2ETestCase): case_id = "0003" name = "dry-run validation" - description = "Validate that --dry-run performs no local or remote changes" + description = "Validate that --dry-run performs no changes locally or remotely" + + def _root_name(self, context: E2EContext) -> str: + return f"ZZ_E2E_TC0003_{context.run_id}_{os.getpid()}" + + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0003 config\nbypass_data_preservation = \"true\"\n") + + def _bootstrap_confdir(self, context: E2EContext, confdir: Path) -> Path: + copied_refresh_token = context.bootstrap_config_dir(confdir) + self._write_config(confdir / "config") + return copied_refresh_token - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) + def _create_local_fixture(self, sync_root: Path, root_name: str) -> None: + reset_directory(sync_root) + write_text_file(sync_root / root_name / "Upload" / "file1.txt", "tc0003 file1\n") + write_text_file(sync_root / root_name / "Upload" / "file2.bin", "tc0003 file2\n") + write_text_file(sync_root / root_name / "Notes" / "draft.md", "# tc0003\n") + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0003" + case_log_dir = context.logs_dir / "tc0003" + state_dir = context.state_dir / "tc0003" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + seed_confdir = case_work_dir / "conf-seed" + verify_root = case_work_dir / "verifyroot" + verify_confdir = case_work_dir / "conf-verify" root_name = self._root_name(context) - artifacts = [] - - seed_root = case_work_dir / "seed-syncroot" - seed_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(seed_root / root_name / "Remote" / "online.txt", "online baseline\n") - self._create_text_file(seed_root / root_name / "Remote" / "keep.txt", "keep baseline\n") - self._create_binary_file(seed_root / root_name / "Data" / "payload.bin", 64 * 1024) - - seed_config_dir = self._new_config_dir(context, case_work_dir, "seed") - config_path = self._write_config(seed_config_dir) - artifacts.append(str(config_path)) - seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_config_dir) - artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) - artifacts.extend(self._write_manifests(seed_root, case_state_dir, "seed_local")) - if seed_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts, {"phase": "seed"}) - - dry_root = case_work_dir / "dryrun-syncroot" - dry_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(dry_root / root_name / "LocalOnly" / "draft.txt", "local only\n") - self._create_text_file(dry_root / root_name / "Remote" / "keep.txt", "locally modified but should not upload\n") - pre_snapshot = self._snapshot_files(dry_root) - artifacts.append(self._write_json_artifact(case_state_dir / "pre_snapshot.json", pre_snapshot)) - - dry_config_dir = self._new_config_dir(context, case_work_dir, "dryrun") - config_path = self._write_config(dry_config_dir) - artifacts.append(str(config_path)) - dry_result = self._run_onedrive(context, sync_root=dry_root, config_dir=dry_config_dir, extra_args=["--dry-run"]) - artifacts.extend(self._write_command_artifacts(result=dry_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="dry_run")) - post_snapshot = self._snapshot_files(dry_root) - artifacts.append(self._write_json_artifact(case_state_dir / "post_snapshot.json", post_snapshot)) - - if dry_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Dry-run exited with status {dry_result.returncode}", artifacts, {"phase": "dry-run"}) - if pre_snapshot != post_snapshot: - return TestResult.fail_result(self.case_id, self.name, "Local filesystem changed during --dry-run", artifacts, {"phase": "dry-run"}) - - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - artifacts.extend(self._write_manifests(verify_root, case_state_dir, "verify_remote")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification download failed with status {verify_result.returncode}", artifacts) - - downloaded = set(self._snapshot_files(verify_root).keys()) - expected_present = { - f"{root_name}/Remote", - f"{root_name}/Remote/online.txt", - f"{root_name}/Remote/keep.txt", - f"{root_name}/Data", - f"{root_name}/Data/payload.bin", + self._create_local_fixture(sync_root, root_name) + copied_refresh_token = self._bootstrap_confdir(context, seed_confdir) + self._bootstrap_confdir(context, verify_confdir) + + before_manifest = build_manifest(sync_root) + before_manifest_file = state_dir / "before_manifest.txt" + after_manifest_file = state_dir / "after_manifest.txt" + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + stdout_file = case_log_dir / "seed_stdout.log" + stderr_file = case_log_dir / "seed_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + write_manifest(before_manifest_file, before_manifest) + + command = [ + context.onedrive_bin, + "--sync", + "--verbose", + "--dry-run", + "--resync", + "--resync-auth", + "--syncdir", + str(sync_root), + "--confdir", + str(seed_confdir), + ] + context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + + after_manifest = build_manifest(sync_root) + write_manifest(after_manifest_file, after_manifest) + + verify_command = [ + context.onedrive_bin, + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--syncdir", + str(verify_root), + "--confdir", + str(verify_confdir), + ] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + metadata_lines = [ + f"case_id={self.case_id}", + f"name={self.name}", + f"root_name={root_name}", + f"copied_refresh_token={copied_refresh_token}", + f"command={command_to_string(command)}", + f"returncode={result.returncode}", + f"verify_command={command_to_string(verify_command)}", + f"verify_returncode={verify_result.returncode}", + ] + write_text_file(metadata_file, "\n".join(metadata_lines) + "\n") + + artifacts = [ + str(stdout_file), + str(stderr_file), + str(verify_stdout), + str(verify_stderr), + str(before_manifest_file), + str(after_manifest_file), + str(remote_manifest_file), + str(metadata_file), + ] + details = { + "command": command, + "returncode": result.returncode, + "verify_command": verify_command, + "verify_returncode": verify_result.returncode, + "root_name": root_name, } - unexpected_absent = sorted(expected_present - downloaded) - if unexpected_absent: - return TestResult.fail_result(self.case_id, self.name, "Remote baseline changed after --dry-run", artifacts, {"missing": unexpected_absent}) - if f"{root_name}/LocalOnly/draft.txt" in downloaded: - return TestResult.fail_result(self.case_id, self.name, "Local-only file was uploaded during --dry-run", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if before_manifest != after_manifest: + return TestResult.fail_result(self.case_id, self.name, "Local filesystem changed during --dry-run", artifacts, details) + if any(entry == root_name or entry.startswith(root_name + "/") for entry in remote_manifest): + return TestResult.fail_result(self.case_id, self.name, f"Dry-run unexpectedly synchronised remote content: {root_name}", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0004_single_directory_sync.py b/ci/e2e/testcases/tc0004_single_directory_sync.py index 24d498f70..36432710a 100644 --- a/ci/e2e/testcases/tc0004_single_directory_sync.py +++ b/ci/e2e/testcases/tc0004_single_directory_sync.py @@ -1,52 +1,129 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0004SingleDirectorySync(Wave1TestCaseBase): +class TestCase0004SingleDirectorySync(E2ETestCase): case_id = "0004" name = "single-directory synchronisation" description = "Validate that only the nominated subtree is synchronised" - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0004 config\nbypass_data_preservation = \"true\"\n") + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0004" + case_log_dir = context.logs_dir / "tc0004" + state_dir = context.state_dir / "tc0004" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() sync_root = case_work_dir / "syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(sync_root / root_name / "Scoped" / "include.txt", "scoped file\n") - self._create_text_file(sync_root / root_name / "Scoped" / "Nested" / "deep.txt", "nested scoped\n") - self._create_text_file(sync_root / root_name / "Unscoped" / "exclude.txt", "should stay local only\n") - - config_dir = self._new_config_dir(context, case_work_dir, "main") - config_path = self._write_config(config_dir) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=config_dir, extra_args=["--single-directory", f"{root_name}/Scoped"]) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="single_directory")) - artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"--single-directory sync failed with status {result.returncode}", artifacts) + confdir = case_work_dir / "conf-main" + verify_root = case_work_dir / "verifyroot" + verify_confdir = case_work_dir / "conf-verify" - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - artifacts.extend(self._write_manifests(verify_root, case_state_dir, "remote_manifest")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - - snapshot = self._snapshot_files(verify_root) - required = { - f"{root_name}/Scoped", - f"{root_name}/Scoped/include.txt", - f"{root_name}/Scoped/Nested", - f"{root_name}/Scoped/Nested/deep.txt", + target_dir = f"ZZ_E2E_TC0004_TARGET_{context.run_id}_{os.getpid()}" + other_dir = f"ZZ_E2E_TC0004_OTHER_{context.run_id}_{os.getpid()}" + + write_text_file(sync_root / target_dir / "keep.txt", "target\n") + write_text_file(sync_root / target_dir / "nested" / "inside.md", "nested\n") + write_text_file(sync_root / other_dir / "skip.txt", "other\n") + + context.bootstrap_config_dir(confdir) + self._write_config(confdir / "config") + context.bootstrap_config_dir(verify_confdir) + self._write_config(verify_confdir / "config") + + stdout_file = case_log_dir / "single_directory_stdout.log" + stderr_file = case_log_dir / "single_directory_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + local_manifest_file = state_dir / "local_after_manifest.txt" + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "single_directory_metadata.txt" + + command = [ + context.onedrive_bin, + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + target_dir, + "--syncdir", + str(sync_root), + "--confdir", + str(confdir), + ] + context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + write_manifest(local_manifest_file, build_manifest(sync_root)) + + verify_command = [ + context.onedrive_bin, + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--syncdir", + str(verify_root), + "--confdir", + str(verify_confdir), + ] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + metadata = [ + f"case_id={self.case_id}", + f"target_dir={target_dir}", + f"other_dir={other_dir}", + f"command={command_to_string(command)}", + f"returncode={result.returncode}", + f"verify_command={command_to_string(verify_command)}", + f"verify_returncode={verify_result.returncode}", + ] + write_text_file(metadata_file, "\n".join(metadata) + "\n") + + artifacts = [ + str(stdout_file), + str(stderr_file), + str(verify_stdout), + str(verify_stderr), + str(local_manifest_file), + str(remote_manifest_file), + str(metadata_file), + ] + details = { + "command": command, + "returncode": result.returncode, + "verify_returncode": verify_result.returncode, + "target_dir": target_dir, + "other_dir": other_dir, } - missing = sorted(required - set(snapshot.keys())) - if missing: - return TestResult.fail_result(self.case_id, self.name, "Scoped content was not uploaded as expected", artifacts, {"missing": missing}) - if f"{root_name}/Unscoped/exclude.txt" in snapshot: - return TestResult.fail_result(self.case_id, self.name, "Unscoped content was unexpectedly synchronised", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"--single-directory sync failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if not any(e == target_dir or e.startswith(target_dir + "/") for e in remote_manifest): + return TestResult.fail_result(self.case_id, self.name, f"Target directory was not synchronised: {target_dir}", artifacts, details) + if any(e == other_dir or e.startswith(other_dir + "/") for e in remote_manifest): + return TestResult.fail_result(self.case_id, self.name, f"Non-target directory was unexpectedly synchronised: {other_dir}", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0005_force_sync_override.py b/ci/e2e/testcases/tc0005_force_sync_override.py index 6aa77f3e9..2b6322a18 100644 --- a/ci/e2e/testcases/tc0005_force_sync_override.py +++ b/ci/e2e/testcases/tc0005_force_sync_override.py @@ -1,58 +1,103 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0005ForceSyncOverride(Wave1TestCaseBase): +class TestCase0005ForceSyncOverride(E2ETestCase): case_id = "0005" name = "force-sync override" - description = "Validate that --force-sync overrides skip_dir when using --single-directory" - - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - - seed_root = case_work_dir / "seed-syncroot" - seed_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(seed_root / root_name / "Blocked" / "blocked.txt", "blocked remote file\n") - seed_conf = self._new_config_dir(context, case_work_dir, "seed") - config_path = self._write_config(seed_conf) - artifacts.append(str(config_path)) - seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) - artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) - if seed_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts) - - no_force_root = case_work_dir / "no-force-syncroot" - no_force_root.mkdir(parents=True, exist_ok=True) - no_force_conf = self._new_config_dir(context, case_work_dir, "no-force") - config_path = self._write_config(no_force_conf, extra_lines=['skip_dir = "Blocked"']) - artifacts.append(str(config_path)) - no_force_result = self._run_onedrive(context, sync_root=no_force_root, config_dir=no_force_conf, extra_args=["--download-only", "--single-directory", f"{root_name}/Blocked"]) - artifacts.extend(self._write_command_artifacts(result=no_force_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="no_force")) - if no_force_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Blocked single-directory sync without --force-sync failed with status {no_force_result.returncode}", artifacts) - if (no_force_root / root_name / "Blocked" / "blocked.txt").exists(): - return TestResult.fail_result(self.case_id, self.name, "Blocked content was downloaded without --force-sync", artifacts) - - force_root = case_work_dir / "force-syncroot" - force_root.mkdir(parents=True, exist_ok=True) - force_conf = self._new_config_dir(context, case_work_dir, "force") - config_path = self._write_config(force_conf, extra_lines=['skip_dir = "Blocked"']) - artifacts.append(str(config_path)) - force_result = self._run_onedrive( - context, - sync_root=force_root, - config_dir=force_conf, - extra_args=["--download-only", "--single-directory", f"{root_name}/Blocked", "--force-sync"], - input_text="Y\n", - ) - artifacts.extend(self._write_command_artifacts(result=force_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="force")) - artifacts.extend(self._write_manifests(force_root, case_state_dir, "force_manifest")) - if force_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Blocked single-directory sync with --force-sync failed with status {force_result.returncode}", artifacts) - if not (force_root / root_name / "Blocked" / "blocked.txt").exists(): - return TestResult.fail_result(self.case_id, self.name, "Blocked content was not downloaded with --force-sync", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + description = "Validate that --force-sync overrides skip_dir for blocked single-directory sync" + + def _write_config(self, config_path: Path, blocked_dir: str) -> None: + write_text_file(config_path, f"# tc0005 config\nbypass_data_preservation = \"true\"\nskip_dir = \"{blocked_dir}\"\n") + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0005" + case_log_dir = context.logs_dir / "tc0005" + state_dir = context.state_dir / "tc0005" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + confdir = case_work_dir / "conf-seed" + verify_root = case_work_dir / "verifyroot" + verify_confdir = case_work_dir / "conf-verify" + + blocked_dir = f"ZZ_E2E_TC0005_BLOCKED_{context.run_id}_{os.getpid()}" + write_text_file(sync_root / blocked_dir / "allowed_via_force.txt", "force\n") + + context.bootstrap_config_dir(confdir) + self._write_config(confdir / "config", blocked_dir) + context.bootstrap_config_dir(verify_confdir) + write_text_file(verify_confdir / "config", "# tc0005 verify\nbypass_data_preservation = \"true\"\n") + + stdout_file = case_log_dir / "seed_stdout.log" + stderr_file = case_log_dir / "seed_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "seed_metadata.txt" + + command = [ + context.onedrive_bin, + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + blocked_dir, + "--force-sync", + "--syncdir", + str(sync_root), + "--confdir", + str(confdir), + ] + result = run_command(command, cwd=context.repo_root, input_text="Y\n") + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + + verify_command = [ + context.onedrive_bin, + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--syncdir", + str(verify_root), + "--confdir", + str(verify_confdir), + ] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + write_text_file(metadata_file, "\n".join([ + f"blocked_dir={blocked_dir}", + f"command={command_to_string(command)}", + f"returncode={result.returncode}", + f"verify_returncode={verify_result.returncode}", + ]) + "\n") + + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"command": command, "returncode": result.returncode, "verify_returncode": verify_result.returncode, "blocked_dir": blocked_dir} + + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Blocked single-directory sync with --force-sync failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{blocked_dir}/allowed_via_force.txt" not in remote_manifest: + return TestResult.fail_result(self.case_id, self.name, f"--force-sync did not synchronise blocked path: {blocked_dir}/allowed_via_force.txt", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0006_download_only.py b/ci/e2e/testcases/tc0006_download_only.py index c51d0fd2e..1c3461ff9 100644 --- a/ci/e2e/testcases/tc0006_download_only.py +++ b/ci/e2e/testcases/tc0006_download_only.py @@ -1,51 +1,43 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0006DownloadOnly(Wave1TestCaseBase): +class TestCase0006DownloadOnly(E2ETestCase): case_id = "0006" name = "download-only behaviour" - description = "Validate that remote content downloads locally and local-only content is not uploaded" - - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - seed_root = case_work_dir / "seed-syncroot" - seed_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(seed_root / root_name / "Remote" / "download_me.txt", "remote file\n") - seed_conf = self._new_config_dir(context, case_work_dir, "seed") - config_path = self._write_config(seed_conf) - artifacts.append(str(config_path)) - seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) - artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) - if seed_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts) + description = "Validate that download-only populates local content from remote data" - sync_root = case_work_dir / "download-syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(sync_root / root_name / "LocalOnly" / "stay_local.txt", "must not upload\n") - conf_dir = self._new_config_dir(context, case_work_dir, "download") - config_path = self._write_config(conf_dir) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--download-only"]) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="download_only")) - artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"--download-only failed with status {result.returncode}", artifacts) - if not (sync_root / root_name / "Remote" / "download_me.txt").exists(): - return TestResult.fail_result(self.case_id, self.name, "Remote file was not downloaded locally", artifacts) - if not (sync_root / root_name / "LocalOnly" / "stay_local.txt").exists(): - return TestResult.fail_result(self.case_id, self.name, "Local-only file should remain present locally", artifacts) + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0006 config\nbypass_data_preservation = \"true\"\n") - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - verify_snapshot = self._snapshot_files(verify_root) - if f"{root_name}/LocalOnly/stay_local.txt" in verify_snapshot: - return TestResult.fail_result(self.case_id, self.name, "Local-only file was uploaded during --download-only", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0006"; case_log_dir = context.logs_dir / "tc0006"; state_dir = context.state_dir / "tc0006" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + seed_root = case_work_dir / "seedroot"; seed_conf = case_work_dir / "conf-seed"; download_root = case_work_dir / "downloadroot"; download_conf = case_work_dir / "conf-download"; root_name = f"ZZ_E2E_TC0006_{context.run_id}_{os.getpid()}" + write_text_file(seed_root / root_name / "remote.txt", "remote\n"); write_text_file(seed_root / root_name / "subdir" / "nested.txt", "nested\n") + context.bootstrap_config_dir(seed_conf); self._write_config(seed_conf / "config") + context.bootstrap_config_dir(download_conf); self._write_config(download_conf / "config") + seed_stdout = case_log_dir / "seed_stdout.log"; seed_stderr = case_log_dir / "seed_stderr.log"; dl_stdout = case_log_dir / "download_stdout.log"; dl_stderr = case_log_dir / "download_stderr.log"; local_manifest_file = state_dir / "download_manifest.txt"; metadata_file = state_dir / "seed_metadata.txt" + seed_command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(seed_root), "--confdir", str(seed_conf)] + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout); write_text_file(seed_stderr, seed_result.stderr) + download_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(download_root), "--confdir", str(download_conf)] + download_result = run_command(download_command, cwd=context.repo_root) + write_text_file(dl_stdout, download_result.stdout); write_text_file(dl_stderr, download_result.stderr); local_manifest = build_manifest(download_root); write_manifest(local_manifest_file, local_manifest) + write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"seed_command={command_to_string(seed_command)}", f"seed_returncode={seed_result.returncode}", f"download_command={command_to_string(download_command)}", f"download_returncode={download_result.returncode}"]) + "\n") + artifacts = [str(seed_stdout), str(seed_stderr), str(dl_stdout), str(dl_stderr), str(local_manifest_file), str(metadata_file)] + details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "root_name": root_name} + if seed_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts, details) + if download_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"--download-only failed with status {download_result.returncode}", artifacts, details) + wanted = [root_name, f"{root_name}/remote.txt", f"{root_name}/subdir", f"{root_name}/subdir/nested.txt"] + missing = [w for w in wanted if w not in local_manifest] + if missing: return TestResult.fail_result(self.case_id, self.name, "Downloaded manifest missing expected content: " + ", ".join(missing), artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py index 9afc64d8b..1b51caf55 100644 --- a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py +++ b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py @@ -1,48 +1,43 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0007DownloadOnlyCleanupLocalFiles(Wave1TestCaseBase): +class TestCase0007DownloadOnlyCleanupLocalFiles(E2ETestCase): case_id = "0007" name = "download-only cleanup-local-files" - description = "Validate that stale local files are removed when cleanup_local_files is enabled" + description = "Validate that cleanup_local_files removes stale local content in download-only mode" - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - seed_root = case_work_dir / "seed-syncroot" - seed_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(seed_root / root_name / "Keep" / "keep.txt", "keep\n") - seed_conf = self._new_config_dir(context, case_work_dir, "seed") - config_path = self._write_config(seed_conf) - artifacts.append(str(config_path)) - seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) - artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) - if seed_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts) + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0007 config\nbypass_data_preservation = \"true\"\n") - sync_root = case_work_dir / "cleanup-syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(sync_root / root_name / "Keep" / "keep.txt", "local keep placeholder\n") - self._create_text_file(sync_root / root_name / "Obsolete" / "old.txt", "obsolete\n") - conf_dir = self._new_config_dir(context, case_work_dir, "cleanup") - config_path = self._write_config(conf_dir, extra_lines=['cleanup_local_files = "true"']) - artifacts.append(str(config_path)) - result = self._run_onedrive( - context, - sync_root=sync_root, - config_dir=conf_dir, - extra_args=["--download-only", "--single-directory", root_name], - ) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="cleanup_download_only")) - artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Cleanup validation failed with status {result.returncode}", artifacts) - if not (sync_root / root_name / "Keep" / "keep.txt").exists(): - return TestResult.fail_result(self.case_id, self.name, "Expected retained file is missing after cleanup", artifacts) - if (sync_root / root_name / "Obsolete" / "old.txt").exists(): - return TestResult.fail_result(self.case_id, self.name, "Stale local file still exists after cleanup_local_files processing", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0007"; case_log_dir = context.logs_dir / "tc0007"; state_dir = context.state_dir / "tc0007" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + sync_root = case_work_dir / "syncroot"; seed_conf = case_work_dir / "conf-seed"; cleanup_conf = case_work_dir / "conf-cleanup"; root_name = f"ZZ_E2E_TC0007_{context.run_id}_{os.getpid()}" + write_text_file(sync_root / root_name / "keep.txt", "keep\n") + context.bootstrap_config_dir(seed_conf); self._write_config(seed_conf / "config") + context.bootstrap_config_dir(cleanup_conf); self._write_config(cleanup_conf / "config") + seed_stdout = case_log_dir / "seed_stdout.log"; seed_stderr = case_log_dir / "seed_stderr.log"; cleanup_stdout = case_log_dir / "cleanup_stdout.log"; cleanup_stderr = case_log_dir / "cleanup_stderr.log"; post_manifest_file = state_dir / "post_cleanup_manifest.txt"; metadata_file = state_dir / "seed_metadata.txt" + seed_command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(seed_conf)] + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout); write_text_file(seed_stderr, seed_result.stderr) + stale = sync_root / root_name / "stale-local.txt"; write_text_file(stale, "stale\n") + cleanup_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--cleanup-local-files", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(cleanup_conf)] + cleanup_result = run_command(cleanup_command, cwd=context.repo_root) + write_text_file(cleanup_stdout, cleanup_result.stdout); write_text_file(cleanup_stderr, cleanup_result.stderr); post_manifest = build_manifest(sync_root); write_manifest(post_manifest_file, post_manifest) + write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", f"cleanup_returncode={cleanup_result.returncode}"]) + "\n") + artifacts = [str(seed_stdout), str(seed_stderr), str(cleanup_stdout), str(cleanup_stderr), str(post_manifest_file), str(metadata_file)] + details = {"seed_returncode": seed_result.returncode, "cleanup_returncode": cleanup_result.returncode, "root_name": root_name} + if seed_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts, details) + if cleanup_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"cleanup_local_files processing failed with status {cleanup_result.returncode}", artifacts, details) + if stale.exists() or f"{root_name}/stale-local.txt" in post_manifest: return TestResult.fail_result(self.case_id, self.name, "Stale local file still exists after cleanup_local_files processing", artifacts, details) + if f"{root_name}/keep.txt" not in post_manifest: return TestResult.fail_result(self.case_id, self.name, "Expected remote-backed file missing after cleanup_local_files processing", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0008_upload_only.py b/ci/e2e/testcases/tc0008_upload_only.py index 2e5caf822..d794bbd81 100644 --- a/ci/e2e/testcases/tc0008_upload_only.py +++ b/ci/e2e/testcases/tc0008_upload_only.py @@ -1,38 +1,41 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0008UploadOnly(Wave1TestCaseBase): +class TestCase0008UploadOnly(E2ETestCase): case_id = "0008" name = "upload-only behaviour" - description = "Validate that local content is uploaded when using --upload-only" + description = "Validate that upload-only pushes local content remotely" + + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0008 config\nbypass_data_preservation = \"true\"\n") - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - sync_root = case_work_dir / "upload-syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(sync_root / root_name / "Upload" / "file.txt", "upload me\n") - self._create_binary_file(sync_root / root_name / "Upload" / "blob.bin", 70 * 1024) - conf_dir = self._new_config_dir(context, case_work_dir, "upload") - config_path = self._write_config(conf_dir) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"--upload-only failed with status {result.returncode}", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - artifacts.extend(self._write_manifests(verify_root, case_state_dir, "remote_manifest")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - verify_snapshot = self._snapshot_files(verify_root) - expected = {f"{root_name}/Upload/file.txt", f"{root_name}/Upload/blob.bin"} - missing = sorted(expected - set(verify_snapshot.keys())) - if missing: - return TestResult.fail_result(self.case_id, self.name, "Uploaded files were not present remotely", artifacts, {"missing": missing}) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0008"; case_log_dir = context.logs_dir / "tc0008"; state_dir = context.state_dir / "tc0008" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + upload_root = case_work_dir / "uploadroot"; upload_conf = case_work_dir / "conf-upload"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0008_{context.run_id}_{os.getpid()}" + write_text_file(upload_root / root_name / "upload.txt", "upload only\n") + context.bootstrap_config_dir(upload_conf); self._write_config(upload_conf / "config") + context.bootstrap_config_dir(verify_conf); self._write_config(verify_conf / "config") + stdout_file = case_log_dir / "upload_only_stdout.log"; stderr_file = case_log_dir / "upload_only_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "upload_metadata.txt" + command = [context.onedrive_bin, "--sync", "--verbose", "--upload-only", "--resync", "--resync-auth", "--syncdir", str(upload_root), "--confdir", str(upload_conf)] + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) + verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) + write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"returncode={result.returncode}", f"verify_returncode={verify_result.returncode}"]) + "\n") + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"--upload-only failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{root_name}/upload.txt" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, f"Upload-only did not synchronise expected remote file: {root_name}/upload.txt", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py index 510504f51..4d327bf83 100644 --- a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py +++ b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py @@ -1,48 +1,47 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0009UploadOnlyNoRemoteDelete(Wave1TestCaseBase): +class TestCase0009UploadOnlyNoRemoteDelete(E2ETestCase): case_id = "0009" name = "upload-only no-remote-delete" - description = "Validate that remote data is retained when local content is absent and no_remote_delete is enabled" + description = "Validate that no_remote_delete preserves remote content in upload-only mode" - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - seed_root = case_work_dir / "seed-syncroot" - seed_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(seed_root / root_name / "RemoteKeep" / "preserve.txt", "preserve remotely\n") - seed_conf = self._new_config_dir(context, case_work_dir, "seed") - config_path = self._write_config(seed_conf) - artifacts.append(str(config_path)) - seed_result = self._run_onedrive(context, sync_root=seed_root, config_dir=seed_conf) - artifacts.extend(self._write_command_artifacts(result=seed_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="seed")) - if seed_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts) + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0009 config\nbypass_data_preservation = \"true\"\n") - sync_root = case_work_dir / "upload-syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(sync_root / root_name / "LocalUpload" / "new.txt", "new upload\n") - conf_dir = self._new_config_dir(context, case_work_dir, "upload") - config_path = self._write_config(conf_dir, extra_lines=['no_remote_delete = "true"']) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only_no_remote_delete")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"--upload-only --no-remote-delete failed with status {result.returncode}", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - artifacts.extend(self._write_manifests(verify_root, case_state_dir, "remote_manifest")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - verify_snapshot = self._snapshot_files(verify_root) - expected = {f"{root_name}/RemoteKeep/preserve.txt", f"{root_name}/LocalUpload/new.txt"} - missing = sorted(expected - set(verify_snapshot.keys())) - if missing: - return TestResult.fail_result(self.case_id, self.name, "Remote content was deleted or not uploaded as expected", artifacts, {"missing": missing}) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0009"; case_log_dir = context.logs_dir / "tc0009"; state_dir = context.state_dir / "tc0009" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + sync_root = case_work_dir / "syncroot"; seed_conf = case_work_dir / "conf-seed"; upload_conf = case_work_dir / "conf-upload"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0009_{context.run_id}_{os.getpid()}" + keep_file = sync_root / root_name / "keep.txt"; write_text_file(keep_file, "keep remote\n") + context.bootstrap_config_dir(seed_conf); self._write_config(seed_conf / "config") + context.bootstrap_config_dir(upload_conf); self._write_config(upload_conf / "config") + context.bootstrap_config_dir(verify_conf); self._write_config(verify_conf / "config") + seed_stdout = case_log_dir / "seed_stdout.log"; seed_stderr = case_log_dir / "seed_stderr.log"; upload_stdout = case_log_dir / "upload_only_stdout.log"; upload_stderr = case_log_dir / "upload_only_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "seed_metadata.txt" + seed_command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(seed_conf)] + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout); write_text_file(seed_stderr, seed_result.stderr) + if keep_file.exists(): keep_file.unlink() + upload_command = [context.onedrive_bin, "--sync", "--verbose", "--upload-only", "--no-remote-delete", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(upload_conf)] + upload_result = run_command(upload_command, cwd=context.repo_root) + write_text_file(upload_stdout, upload_result.stdout); write_text_file(upload_stderr, upload_result.stderr) + verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) + write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", f"upload_returncode={upload_result.returncode}", f"verify_returncode={verify_result.returncode}"]) + "\n") + artifacts = [str(seed_stdout), str(seed_stderr), str(upload_stdout), str(upload_stderr), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"seed_returncode": seed_result.returncode, "upload_returncode": upload_result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + if seed_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts, details) + if upload_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"--upload-only --no-remote-delete failed with status {upload_result.returncode}", artifacts, details) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{root_name}/keep.txt" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, f"Remote file was unexpectedly deleted despite --no-remote-delete: {root_name}/keep.txt", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py index 75c62afe4..ba73cd8ab 100644 --- a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py +++ b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py @@ -1,37 +1,42 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0010UploadOnlyRemoveSourceFiles(Wave1TestCaseBase): +class TestCase0010UploadOnlyRemoveSourceFiles(E2ETestCase): case_id = "0010" name = "upload-only remove-source-files" - description = "Validate that local files are removed after successful upload when remove_source_files is enabled" + description = "Validate that remove_source_files removes local files after upload-only succeeds" + + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0010 config\nbypass_data_preservation = \"true\"\n") - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - sync_root = case_work_dir / "upload-syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - source_file = sync_root / root_name / "Source" / "upload_and_remove.txt" - self._create_text_file(source_file, "remove after upload\n") - conf_dir = self._new_config_dir(context, case_work_dir, "upload") - config_path = self._write_config(conf_dir, extra_lines=['remove_source_files = "true"']) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--upload-only"]) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="upload_only_remove_source")) - artifacts.extend(self._write_manifests(sync_root, case_state_dir, "local_after")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"--upload-only with remove_source_files failed with status {result.returncode}", artifacts) - if source_file.exists(): - return TestResult.fail_result(self.case_id, self.name, "Source file still exists locally after upload", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - if not (verify_root / root_name / "Source" / "upload_and_remove.txt").exists(): - return TestResult.fail_result(self.case_id, self.name, "Uploaded file was not present remotely after local removal", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0010"; case_log_dir = context.logs_dir / "tc0010"; state_dir = context.state_dir / "tc0010" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + sync_root = case_work_dir / "syncroot"; upload_conf = case_work_dir / "conf-upload"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0010_{context.run_id}_{os.getpid()}" + source_file = sync_root / root_name / "source.txt"; write_text_file(source_file, "remove after upload\n") + context.bootstrap_config_dir(upload_conf); self._write_config(upload_conf / "config") + context.bootstrap_config_dir(verify_conf); self._write_config(verify_conf / "config") + stdout_file = case_log_dir / "upload_only_remove_source_stdout.log"; stderr_file = case_log_dir / "upload_only_remove_source_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; post_manifest_file = state_dir / "post_upload_manifest.txt"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "upload_metadata.txt" + command = [context.onedrive_bin, "--sync", "--verbose", "--upload-only", "--remove-source-files", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(upload_conf)] + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr); post_manifest = build_manifest(sync_root); write_manifest(post_manifest_file, post_manifest) + verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) + write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"returncode={result.returncode}", f"verify_returncode={verify_result.returncode}"]) + "\n") + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(post_manifest_file), str(remote_manifest_file), str(metadata_file)] + details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"--upload-only with remove_source_files failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if source_file.exists() or f"{root_name}/source.txt" in post_manifest: return TestResult.fail_result(self.case_id, self.name, "Local source file still exists after remove_source_files processing", artifacts, details) + if f"{root_name}/source.txt" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, f"Remote file missing after upload-only remove_source_files: {root_name}/source.txt", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0011_skip_file_validation.py b/ci/e2e/testcases/tc0011_skip_file_validation.py index 8e5c16e5f..7fba218bc 100644 --- a/ci/e2e/testcases/tc0011_skip_file_validation.py +++ b/ci/e2e/testcases/tc0011_skip_file_validation.py @@ -1,43 +1,43 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0011SkipFileValidation(Wave1TestCaseBase): +class TestCase0011SkipFileValidation(E2ETestCase): case_id = "0011" name = "skip_file validation" - description = "Validate that skip_file patterns exclude matching files from synchronisation" + description = "Validate that skip_file patterns prevent matching files from synchronising" + + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0011 config\nbypass_data_preservation = \"true\"\nskip_file = \"*.tmp|*.swp\"\n") - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - sync_root = case_work_dir / "syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(sync_root / root_name / "keep.txt", "keep me\n") - self._create_text_file(sync_root / root_name / "ignore.tmp", "temp\n") - self._create_text_file(sync_root / root_name / "editor.swp", "swap\n") - self._create_text_file(sync_root / root_name / "Nested" / "keep.md", "nested keep\n") - conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path = self._write_config(conf_dir, extra_lines=['skip_file = "*.tmp|*.swp"']) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_file")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"skip_file validation failed with status {result.returncode}", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - artifacts.extend(self._write_manifests(verify_root, case_state_dir, "remote_manifest")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - snapshot = self._snapshot_files(verify_root) - expected = {f"{root_name}/keep.txt", f"{root_name}/Nested/keep.md"} - missing = sorted(expected - set(snapshot.keys())) - if missing: - return TestResult.fail_result(self.case_id, self.name, "Expected non-skipped files are missing remotely", artifacts, {"missing": missing}) - present = sorted(path for path in [f"{root_name}/ignore.tmp", f"{root_name}/editor.swp"] if path in snapshot) - if present: - return TestResult.fail_result(self.case_id, self.name, "skip_file patterns did not exclude all matching files", artifacts, {"present": present}) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0011"; case_log_dir = context.logs_dir / "tc0011"; state_dir = context.state_dir / "tc0011" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0011_{context.run_id}_{os.getpid()}" + write_text_file(sync_root / root_name / "keep.txt", "keep\n"); write_text_file(sync_root / root_name / "skip.tmp", "skip\n"); write_text_file(sync_root / root_name / "editor.swp", "swap\n") + context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") + context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# tc0011 verify\nbypass_data_preservation = \"true\"\n") + stdout_file = case_log_dir / "skip_file_stdout.log"; stderr_file = case_log_dir / "skip_file_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" + command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) + verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) + write_text_file(metadata_file, f"root_name={root_name}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"skip_file validation failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{root_name}/keep.txt" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, f"Expected non-skipped file missing remotely: {root_name}/keep.txt", artifacts, details) + for unwanted in [f"{root_name}/skip.tmp", f"{root_name}/editor.swp"]: + if unwanted in remote_manifest: return TestResult.fail_result(self.case_id, self.name, f"skip_file pattern failed, file was synchronised: {unwanted}", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0012_skip_dir_validation.py b/ci/e2e/testcases/tc0012_skip_dir_validation.py index ad521b4ba..1ecef557a 100644 --- a/ci/e2e/testcases/tc0012_skip_dir_validation.py +++ b/ci/e2e/testcases/tc0012_skip_dir_validation.py @@ -1,70 +1,75 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0012SkipDirValidation(Wave1TestCaseBase): +class TestCase0012SkipDirValidation(E2ETestCase): case_id = "0012" name = "skip_dir validation" - description = "Validate loose and strict skip_dir matching behaviour" + description = "Validate skip_dir loose matching and skip_dir_strict_match behaviour" + + def _write_config(self, config_path: Path, skip_dir_value: str, strict: bool) -> None: + lines = ["# tc0012 config", "bypass_data_preservation = \"true\"", f"skip_dir = \"{skip_dir_value}\""] + if strict: + lines.append("skip_dir_strict_match = \"true\"") + write_text_file(config_path, "\n".join(lines) + "\n") - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - failures = [] + def _run_loose(self, context: E2EContext, case_log_dir: Path, all_artifacts: list[str], failures: list[str]) -> None: + scenario_root = context.work_root / "tc0012" / "loose_match"; scenario_state = context.state_dir / "tc0012" / "loose_match" + reset_directory(scenario_root); reset_directory(scenario_state) + sync_root = scenario_root / "syncroot"; confdir = scenario_root / "conf-loose"; verify_root = scenario_root / "verifyroot"; verify_conf = scenario_root / "conf-verify-loose" + root = f"ZZ_E2E_TC0012_LOOSE_{context.run_id}_{os.getpid()}" + write_text_file(sync_root / root / "Cache" / "top.txt", "skip top\n") + write_text_file(sync_root / root / "App" / "Cache" / "nested.txt", "skip nested\n") + write_text_file(sync_root / root / "Keep" / "ok.txt", "ok\n") + context.bootstrap_config_dir(confdir); self._write_config(confdir / "config", "Cache", False) + context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + stdout_file = case_log_dir / "loose_match_stdout.log"; stderr_file = case_log_dir / "loose_match_stderr.log"; verify_stdout = case_log_dir / "loose_match_verify_stdout.log"; verify_stderr = case_log_dir / "loose_match_verify_stderr.log"; manifest_file = scenario_state / "remote_verify_manifest.txt" + result = run_command([context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)], cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) + verify_result = run_command([context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)], cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); manifest = build_manifest(verify_root); write_manifest(manifest_file, manifest) + all_artifacts.extend([str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(manifest_file)]) + if result.returncode != 0: failures.append(f"Loose skip_dir scenario failed with status {result.returncode}"); return + if verify_result.returncode != 0: failures.append(f"Loose skip_dir verification failed with status {verify_result.returncode}"); return + if f"{root}/Keep/ok.txt" not in manifest: failures.append("Loose skip_dir scenario did not synchronise expected non-skipped content") + for unwanted in [f"{root}/Cache/top.txt", f"{root}/App/Cache/nested.txt"]: + if unwanted in manifest: failures.append(f"Loose skip_dir scenario unexpectedly synchronised skipped directory content: {unwanted}") - loose_root = case_work_dir / "loose-syncroot" - loose_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(loose_root / root_name / "project" / "build" / "out.bin", "skip me\n") - self._create_text_file(loose_root / root_name / "build" / "root.bin", "skip me too\n") - self._create_text_file(loose_root / root_name / "project" / "src" / "app.txt", "keep me\n") - loose_conf = self._new_config_dir(context, case_work_dir, "loose") - config_path = self._write_config(loose_conf, extra_lines=['skip_dir = "build"', 'skip_dir_strict_match = "false"']) - artifacts.append(str(config_path)) - loose_result = self._run_onedrive(context, sync_root=loose_root, config_dir=loose_conf) - artifacts.extend(self._write_command_artifacts(result=loose_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="loose_match")) - if loose_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Loose skip_dir scenario failed with status {loose_result.returncode}", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "loose_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="loose_verify")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Loose skip_dir verification failed with status {verify_result.returncode}", artifacts) - loose_snapshot = self._snapshot_files(verify_root) - if f"{root_name}/project/src/app.txt" not in loose_snapshot: - failures.append("Loose matching did not retain non-build content") - for forbidden in [f"{root_name}/project/build/out.bin", f"{root_name}/build/root.bin"]: - if forbidden in loose_snapshot: - failures.append(f"Loose matching did not exclude {forbidden}") + def _run_strict(self, context: E2EContext, case_log_dir: Path, all_artifacts: list[str], failures: list[str]) -> None: + scenario_root = context.work_root / "tc0012" / "strict_match"; scenario_state = context.state_dir / "tc0012" / "strict_match" + reset_directory(scenario_root); reset_directory(scenario_state) + sync_root = scenario_root / "syncroot"; confdir = scenario_root / "conf-strict"; verify_root = scenario_root / "verifyroot"; verify_conf = scenario_root / "conf-verify-strict" + root = f"ZZ_E2E_TC0012_STRICT_{context.run_id}_{os.getpid()}" + write_text_file(sync_root / root / "Cache" / "top.txt", "top should remain\n") + write_text_file(sync_root / root / "App" / "Cache" / "nested.txt", "nested should skip\n") + write_text_file(sync_root / root / "Keep" / "ok.txt", "ok\n") + context.bootstrap_config_dir(confdir); self._write_config(confdir / "config", f"{root}/App/Cache", True) + context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + stdout_file = case_log_dir / "strict_match_stdout.log"; stderr_file = case_log_dir / "strict_match_stderr.log"; verify_stdout = case_log_dir / "strict_match_verify_stdout.log"; verify_stderr = case_log_dir / "strict_match_verify_stderr.log"; manifest_file = scenario_state / "remote_verify_manifest.txt" + result = run_command([context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)], cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) + verify_result = run_command([context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)], cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); manifest = build_manifest(verify_root); write_manifest(manifest_file, manifest) + all_artifacts.extend([str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(manifest_file)]) + if result.returncode != 0: failures.append(f"Strict skip_dir scenario failed with status {result.returncode}"); return + if verify_result.returncode != 0: failures.append(f"Strict skip_dir verification failed with status {verify_result.returncode}"); return + if f"{root}/Keep/ok.txt" not in manifest: failures.append("Strict skip_dir scenario did not synchronise expected non-skipped content") + if f"{root}/Cache/top.txt" not in manifest: failures.append("Strict skip_dir scenario incorrectly skipped top-level Cache directory") + if f"{root}/App/Cache/nested.txt" in manifest: failures.append("Strict skip_dir scenario unexpectedly synchronised strict-matched directory content") - strict_scope = f"{root_name}_STRICT" - strict_root = case_work_dir / "strict-syncroot" - strict_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(strict_root / strict_scope / "project" / "build" / "skip.bin", "skip strict\n") - self._create_text_file(strict_root / strict_scope / "other" / "build" / "keep.bin", "keep strict\n") - self._create_text_file(strict_root / strict_scope / "other" / "src" / "keep.txt", "keep strict txt\n") - strict_conf = self._new_config_dir(context, case_work_dir, "strict") - config_path = self._write_config(strict_conf, extra_lines=[f'skip_dir = "{strict_scope}/project/build"', 'skip_dir_strict_match = "true"']) - artifacts.append(str(config_path)) - strict_result = self._run_onedrive(context, sync_root=strict_root, config_dir=strict_conf) - artifacts.extend(self._write_command_artifacts(result=strict_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="strict_match")) - if strict_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Strict skip_dir scenario failed with status {strict_result.returncode}", artifacts) - strict_verify_root, strict_verify_result, strict_verify_artifacts = self._download_remote_scope(context, case_work_dir, strict_scope, "strict_remote") - artifacts.extend(strict_verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=strict_verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="strict_verify")) - if strict_verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Strict skip_dir verification failed with status {strict_verify_result.returncode}", artifacts) - strict_snapshot = self._snapshot_files(strict_verify_root) - if f"{strict_scope}/project/build/skip.bin" in strict_snapshot: - failures.append("Strict matching did not exclude the targeted full path") - for required in [f"{strict_scope}/other/build/keep.bin", f"{strict_scope}/other/src/keep.txt"]: - if required not in strict_snapshot: - failures.append(f"Strict matching excluded unexpected content: {required}") - artifacts.extend(self._write_manifests(verify_root, case_state_dir, "loose_manifest")) - artifacts.extend(self._write_manifests(strict_verify_root, case_state_dir, "strict_manifest")) - if failures: - return TestResult.fail_result(self.case_id, self.name, "; ".join(failures), artifacts, {"failure_count": len(failures)}) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name, "strict_scope": strict_scope}) + def run(self, context: E2EContext) -> TestResult: + case_log_dir = context.logs_dir / "tc0012"; reset_directory(case_log_dir); context.ensure_refresh_token_available() + all_artifacts = []; failures = [] + self._run_loose(context, case_log_dir, all_artifacts, failures) + self._run_strict(context, case_log_dir, all_artifacts, failures) + details = {"failures": failures} + if failures: return TestResult.fail_result(self.case_id, self.name, "; ".join(failures), all_artifacts, details) + return TestResult.pass_result(self.case_id, self.name, all_artifacts, details) diff --git a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py index b0102aff7..125096977 100644 --- a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py +++ b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py @@ -1,41 +1,43 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0013SkipDotfilesValidation(Wave1TestCaseBase): +class TestCase0013SkipDotfilesValidation(E2ETestCase): case_id = "0013" name = "skip_dotfiles validation" - description = "Validate that dotfiles and dot-directories are excluded when skip_dotfiles is enabled" + description = "Validate that skip_dotfiles prevents dotfiles and dot-directories from synchronising" + + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0013 config\nbypass_data_preservation = \"true\"\nskip_dotfiles = \"true\"\n") - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - sync_root = case_work_dir / "syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(sync_root / root_name / ".hidden.txt", "hidden\n") - self._create_text_file(sync_root / root_name / ".dotdir" / "inside.txt", "inside dotdir\n") - self._create_text_file(sync_root / root_name / "visible.txt", "visible\n") - self._create_text_file(sync_root / root_name / "normal" / "keep.md", "normal keep\n") - conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path = self._write_config(conf_dir, extra_lines=['skip_dotfiles = "true"']) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--single-directory", root_name]) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_dotfiles")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"skip_dotfiles validation failed with status {result.returncode}", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - snapshot = self._snapshot_files(verify_root) - for required in [f"{root_name}/visible.txt", f"{root_name}/normal/keep.md"]: - if required not in snapshot: - return TestResult.fail_result(self.case_id, self.name, f"Expected visible content missing remotely: {required}", artifacts) - for forbidden in [f"{root_name}/.hidden.txt", f"{root_name}/.dotdir/inside.txt"]: - if forbidden in snapshot: - return TestResult.fail_result(self.case_id, self.name, f"Dotfile content was unexpectedly synchronised: {forbidden}", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0013"; case_log_dir = context.logs_dir / "tc0013"; state_dir = context.state_dir / "tc0013" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0013_{context.run_id}_{os.getpid()}" + write_text_file(sync_root / root_name / "visible.txt", "visible\n"); write_text_file(sync_root / root_name / ".hidden.txt", "hidden\n"); write_text_file(sync_root / root_name / ".dotdir" / "inside.txt", "inside\n") + context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") + context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + stdout_file = case_log_dir / "skip_dotfiles_stdout.log"; stderr_file = case_log_dir / "skip_dotfiles_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" + command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) + verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) + write_text_file(metadata_file, f"root_name={root_name}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"skip_dotfiles validation failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{root_name}/visible.txt" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Visible file missing after skip_dotfiles processing", artifacts, details) + for unwanted in [f"{root_name}/.hidden.txt", f"{root_name}/.dotdir", f"{root_name}/.dotdir/inside.txt"]: + if unwanted in remote_manifest: return TestResult.fail_result(self.case_id, self.name, f"Dotfile content was unexpectedly synchronised: {unwanted}", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0014_skip_size_validation.py b/ci/e2e/testcases/tc0014_skip_size_validation.py index 760f269c6..89c4e231c 100644 --- a/ci/e2e/testcases/tc0014_skip_size_validation.py +++ b/ci/e2e/testcases/tc0014_skip_size_validation.py @@ -1,37 +1,43 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0014SkipSizeValidation(Wave1TestCaseBase): +class TestCase0014SkipSizeValidation(E2ETestCase): case_id = "0014" name = "skip_size validation" - description = "Validate that files above the configured size threshold are excluded from synchronisation" + description = "Validate that skip_size prevents oversized files from synchronising" + + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0014 config\nbypass_data_preservation = \"true\"\nskip_size = \"1\"\n") - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - sync_root = case_work_dir / "syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_binary_file(sync_root / root_name / "small.bin", 128 * 1024) - self._create_binary_file(sync_root / root_name / "large.bin", 2 * 1024 * 1024) - conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path = self._write_config(conf_dir, extra_lines=['skip_size = "1"']) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir, extra_args=["--single-directory", root_name]) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_size")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"skip_size validation failed with status {result.returncode}", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - snapshot = self._snapshot_files(verify_root) - if f"{root_name}/small.bin" not in snapshot: - return TestResult.fail_result(self.case_id, self.name, "Small file is missing remotely", artifacts) - if f"{root_name}/large.bin" in snapshot: - return TestResult.fail_result(self.case_id, self.name, "Large file exceeded skip_size threshold but was synchronised", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0014"; case_log_dir = context.logs_dir / "tc0014"; state_dir = context.state_dir / "tc0014" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0014_{context.run_id}_{os.getpid()}" + write_text_file(sync_root / root_name / "small.bin", "a" * 16384) + big_path = sync_root / root_name / "large.bin"; big_path.parent.mkdir(parents=True, exist_ok=True); big_path.write_bytes(b"B" * (2 * 1024 * 1024)) + context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") + context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + stdout_file = case_log_dir / "skip_size_stdout.log"; stderr_file = case_log_dir / "skip_size_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" + command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) + verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) + write_text_file(metadata_file, f"root_name={root_name}\nlarge_size={big_path.stat().st_size}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name, "large_size": big_path.stat().st_size} + if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"skip_size validation failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{root_name}/small.bin" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Small file missing after skip_size processing", artifacts, details) + if f"{root_name}/large.bin" in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Large file exceeded skip_size threshold but was synchronised", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py index 5d4510ffd..1e8421090 100644 --- a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py +++ b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py @@ -1,43 +1,42 @@ from __future__ import annotations import os +from pathlib import Path +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0015SkipSymlinksValidation(Wave1TestCaseBase): +class TestCase0015SkipSymlinksValidation(E2ETestCase): case_id = "0015" name = "skip_symlinks validation" - description = "Validate that symbolic links are excluded when skip_symlinks is enabled" + description = "Validate that skip_symlinks prevents symbolic links from synchronising" - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - sync_root = case_work_dir / "syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - target_file = sync_root / root_name / "real.txt" - self._create_text_file(target_file, "real content\n") - symlink_path = sync_root / root_name / "real-link.txt" - symlink_path.parent.mkdir(parents=True, exist_ok=True) - os.symlink("real.txt", symlink_path) - conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path = self._write_config(conf_dir, extra_lines=['skip_symlinks = "true"']) - artifacts.append(str(config_path)) - artifacts.append(self._write_json_artifact(case_state_dir / "local_snapshot_pre.json", self._snapshot_files(sync_root))) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="skip_symlinks")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"skip_symlinks validation failed with status {result.returncode}", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - snapshot = self._snapshot_files(verify_root) - if f"{root_name}/real.txt" not in snapshot: - return TestResult.fail_result(self.case_id, self.name, "Real file is missing remotely", artifacts) - if f"{root_name}/real-link.txt" in snapshot: - return TestResult.fail_result(self.case_id, self.name, "Symbolic link was unexpectedly synchronised", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0015 config\nbypass_data_preservation = \"true\"\nskip_symlinks = \"true\"\n") + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0015"; case_log_dir = context.logs_dir / "tc0015"; state_dir = context.state_dir / "tc0015" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0015_{context.run_id}_{os.getpid()}" + target = sync_root / root_name / "real.txt"; write_text_file(target, "real\n"); link = sync_root / root_name / "linked.txt"; link.parent.mkdir(parents=True, exist_ok=True); link.symlink_to(target.name) + context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") + context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + stdout_file = case_log_dir / "skip_symlinks_stdout.log"; stderr_file = case_log_dir / "skip_symlinks_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" + command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) + verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) + write_text_file(metadata_file, f"root_name={root_name}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"skip_symlinks validation failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{root_name}/real.txt" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Regular file missing after skip_symlinks processing", artifacts, details) + if f"{root_name}/linked.txt" in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Symbolic link was unexpectedly synchronised", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0016_check_nosync_validation.py b/ci/e2e/testcases/tc0016_check_nosync_validation.py index 1164ba881..14a9fe222 100644 --- a/ci/e2e/testcases/tc0016_check_nosync_validation.py +++ b/ci/e2e/testcases/tc0016_check_nosync_validation.py @@ -1,39 +1,43 @@ from __future__ import annotations +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from testcases.wave1_common import Wave1TestCaseBase +from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0016CheckNosyncValidation(Wave1TestCaseBase): +class TestCase0016CheckNosyncValidation(E2ETestCase): case_id = "0016" name = "check_nosync validation" - description = "Validate that local directories containing .nosync are excluded when check_nosync is enabled" + description = "Validate that check_nosync prevents directories containing .nosync from synchronising" + + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0016 config\nbypass_data_preservation = \"true\"\ncheck_nosync = \"true\"\n") - def run(self, context): - case_work_dir, case_log_dir, case_state_dir = self._initialise_case_dirs(context) - root_name = self._root_name(context) - artifacts = [] - sync_root = case_work_dir / "syncroot" - sync_root.mkdir(parents=True, exist_ok=True) - self._create_text_file(sync_root / root_name / "Blocked" / ".nosync", "marker\n") - self._create_text_file(sync_root / root_name / "Blocked" / "blocked.txt", "blocked\n") - self._create_text_file(sync_root / root_name / "Allowed" / "allowed.txt", "allowed\n") - conf_dir = self._new_config_dir(context, case_work_dir, "main") - config_path = self._write_config(conf_dir, extra_lines=['check_nosync = "true"']) - artifacts.append(str(config_path)) - result = self._run_onedrive(context, sync_root=sync_root, config_dir=conf_dir) - artifacts.extend(self._write_command_artifacts(result=result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="check_nosync")) - if result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"check_nosync validation failed with status {result.returncode}", artifacts) - verify_root, verify_result, verify_artifacts = self._download_remote_scope(context, case_work_dir, root_name, "verify_remote") - artifacts.extend(verify_artifacts) - artifacts.extend(self._write_command_artifacts(result=verify_result, log_dir=case_log_dir, state_dir=case_state_dir, phase_name="verify_remote")) - if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts) - snapshot = self._snapshot_files(verify_root) - if f"{root_name}/Allowed/allowed.txt" not in snapshot: - return TestResult.fail_result(self.case_id, self.name, "Allowed content is missing remotely", artifacts) - for forbidden in [f"{root_name}/Blocked/blocked.txt", f"{root_name}/Blocked/.nosync"]: - if forbidden in snapshot: - return TestResult.fail_result(self.case_id, self.name, f".nosync-protected content was unexpectedly synchronised: {forbidden}", artifacts) - return TestResult.pass_result(self.case_id, self.name, artifacts, {"root_name": root_name}) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0016"; case_log_dir = context.logs_dir / "tc0016"; state_dir = context.state_dir / "tc0016" + reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() + sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0016_{context.run_id}_{os.getpid()}" + write_text_file(sync_root / root_name / "Allowed" / "ok.txt", "ok\n"); write_text_file(sync_root / root_name / "Blocked" / ".nosync", ""); write_text_file(sync_root / root_name / "Blocked" / "blocked.txt", "blocked\n") + context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") + context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + stdout_file = case_log_dir / "check_nosync_stdout.log"; stderr_file = case_log_dir / "check_nosync_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" + command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) + verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) + write_text_file(metadata_file, f"root_name={root_name}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"check_nosync validation failed with status {result.returncode}", artifacts, details) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{root_name}/Allowed/ok.txt" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Allowed content missing after check_nosync processing", artifacts, details) + for unwanted in [f"{root_name}/Blocked", f"{root_name}/Blocked/.nosync", f"{root_name}/Blocked/blocked.txt"]: + if unwanted in remote_manifest: return TestResult.fail_result(self.case_id, self.name, f".nosync directory content was unexpectedly synchronised: {unwanted}", artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/wave1_common.py b/ci/e2e/testcases/wave1_common.py deleted file mode 100644 index 48566360c..000000000 --- a/ci/e2e/testcases/wave1_common.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -import re -from pathlib import Path -from typing import Iterable - -from framework.base import E2ETestCase -from framework.context import E2EContext -from framework.manifest import build_manifest, write_manifest -from framework.utils import ( - command_to_string, - reset_directory, - run_command, - write_text_file, -) - -CONFIG_FILE_NAME = "config" - - -class Wave1TestCaseBase(E2ETestCase): - """ - Shared helper base for Wave 1 E2E test cases. - - Important design rule: Wave 1 test cases must not use sync_list. - TC0002 is the sole owner of sync_list validation. - """ - - def _safe_run_id(self, context: E2EContext) -> str: - value = re.sub(r"[^A-Za-z0-9]+", "_", context.run_id).strip("_").lower() - return value or "run" - - def _root_name(self, context: E2EContext) -> str: - return f"ZZ_E2E_TC{self.case_id}_{self._safe_run_id(context)}" - - def _initialise_case_dirs(self, context: E2EContext) -> tuple[Path, Path, Path]: - case_work_dir = context.work_root / f"tc{self.case_id}" - case_log_dir = context.logs_dir / f"tc{self.case_id}" - case_state_dir = context.state_dir / f"tc{self.case_id}" - reset_directory(case_work_dir) - reset_directory(case_log_dir) - reset_directory(case_state_dir) - return case_work_dir, case_log_dir, case_state_dir - - def _new_config_dir(self, context: E2EContext, case_work_dir: Path, name: str) -> Path: - config_dir = case_work_dir / f"conf-{name}" - reset_directory(config_dir) - context.bootstrap_config_dir(config_dir) - return config_dir - - def _write_config( - self, - config_dir: Path, - *, - extra_lines: Iterable[str] | None = None, - ) -> Path: - config_path = config_dir / CONFIG_FILE_NAME - - lines = [ - f"# tc{self.case_id} generated config", - 'bypass_data_preservation = "true"', - 'monitor_interval = 5', - ] - if extra_lines: - lines.extend(list(extra_lines)) - write_text_file(config_path, "\n".join(lines) + "\n") - - return config_path - - def _run_onedrive( - self, - context: E2EContext, - *, - sync_root: Path, - config_dir: Path, - extra_args: list[str] | None = None, - use_resync: bool = True, - use_resync_auth: bool = True, - input_text: str | None = None, - ): - command = [context.onedrive_bin, "--sync", "--verbose"] - if use_resync: - command.append("--resync") - if use_resync_auth: - command.append("--resync-auth") - command.extend(["--syncdir", str(sync_root), "--confdir", str(config_dir)]) - if extra_args: - command.extend(extra_args) - - context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") - return run_command(command, cwd=context.repo_root, input_text=input_text) - - def _write_command_artifacts( - self, - *, - result, - log_dir: Path, - state_dir: Path, - phase_name: str, - extra_metadata: dict[str, str | int | bool] | None = None, - ) -> list[str]: - stdout_file = log_dir / f"{phase_name}_stdout.log" - stderr_file = log_dir / f"{phase_name}_stderr.log" - metadata_file = state_dir / f"{phase_name}_metadata.txt" - - write_text_file(stdout_file, result.stdout) - write_text_file(stderr_file, result.stderr) - - metadata = { - "phase": phase_name, - "command": command_to_string(result.command), - "returncode": result.returncode, - } - if extra_metadata: - metadata.update(extra_metadata) - - lines = [f"{key}={value}" for key, value in metadata.items()] - write_text_file(metadata_file, "\n".join(lines) + "\n") - - return [str(stdout_file), str(stderr_file), str(metadata_file)] - - def _write_manifests(self, root: Path, state_dir: Path, prefix: str) -> list[str]: - manifest_file = state_dir / f"{prefix}_manifest.txt" - write_manifest(manifest_file, build_manifest(root)) - return [str(manifest_file)] - - def _write_json_artifact(self, path: Path, payload: object) -> str: - write_text_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n") - return str(path) - - def _create_text_file(self, path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - def _create_binary_file(self, path: Path, size_bytes: int) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - chunk = os.urandom(min(size_bytes, 1024 * 1024)) - with path.open("wb") as fp: - remaining = size_bytes - while remaining > 0: - to_write = chunk[: min(len(chunk), remaining)] - fp.write(to_write) - remaining -= len(to_write) - - def _snapshot_files(self, root: Path) -> dict[str, str]: - result: dict[str, str] = {} - if not root.exists(): - return result - - for path in sorted(root.rglob("*")): - rel = path.relative_to(root).as_posix() - if path.is_symlink(): - result[rel] = f"symlink->{os.readlink(path)}" - continue - if path.is_dir(): - result[rel] = "dir" - continue - hasher = hashlib.sha256() - with path.open("rb") as fp: - while True: - chunk = fp.read(8192) - if not chunk: - break - hasher.update(chunk) - result[rel] = hasher.hexdigest() - return result - - def _download_remote_scope( - self, - context: E2EContext, - case_work_dir: Path, - scope_root: str, - name: str, - *, - extra_config_lines: Iterable[str] | None = None, - extra_args: list[str] | None = None, - ) -> tuple[Path, object, list[str]]: - verify_root = case_work_dir / f"verify-{name}" - reset_directory(verify_root) - config_dir = self._new_config_dir(context, case_work_dir, f"verify-{name}") - config_path = self._write_config( - config_dir, - extra_lines=extra_config_lines, - ) - result = self._run_onedrive( - context, - sync_root=verify_root, - config_dir=config_dir, - extra_args=["--download-only", "--single-directory", scope_root] + (extra_args or []), - ) - artifacts = [str(config_path)] - return verify_root, result, artifacts From 3e363778c792ed397ae84bef66d2ef02d0922bae Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 14 Mar 2026 12:24:46 +1100 Subject: [PATCH 046/245] Update PR - Add Test Cases 0017 to 0024 * Add Test Cases 0017 to 0024 --- ci/e2e/run.py | 16 ++ ci/e2e/testcases/tc0006_download_only.py | 4 +- ...c0007_download_only_cleanup_local_files.py | 4 +- ci/e2e/testcases/tc0008_upload_only.py | 4 +- .../tc0009_upload_only_no_remote_delete.py | 6 +- .../tc0010_upload_only_remove_source_files.py | 4 +- .../testcases/tc0011_skip_file_validation.py | 4 +- .../testcases/tc0012_skip_dir_validation.py | 8 +- .../tc0013_skip_dotfiles_validation.py | 4 +- .../testcases/tc0014_skip_size_validation.py | 23 +- .../tc0015_skip_symlinks_validation.py | 4 +- .../tc0016_check_nosync_validation.py | 4 +- .../tc0017_check_nomount_validation.py | 105 +++++++++ .../tc0018_recycle_bin_validation.py | 213 ++++++++++++++++++ .../tc0019_logging_and_running_config.py | 98 ++++++++ .../tc0020_monitor_mode_validation.py | 144 ++++++++++++ .../tc0021_resumable_transfers_validation.py | 155 +++++++++++++ .../tc0022_local_first_validation.py | 115 ++++++++++ ...023_bypass_data_preservation_validation.py | 101 +++++++++ .../tc0024_big_delete_safeguard_validation.py | 111 +++++++++ 20 files changed, 1095 insertions(+), 32 deletions(-) create mode 100644 ci/e2e/testcases/tc0017_check_nomount_validation.py create mode 100644 ci/e2e/testcases/tc0018_recycle_bin_validation.py create mode 100644 ci/e2e/testcases/tc0019_logging_and_running_config.py create mode 100644 ci/e2e/testcases/tc0020_monitor_mode_validation.py create mode 100644 ci/e2e/testcases/tc0021_resumable_transfers_validation.py create mode 100644 ci/e2e/testcases/tc0022_local_first_validation.py create mode 100644 ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py create mode 100644 ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 267141a5b..dc4398d19 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -25,6 +25,14 @@ from testcases.tc0014_skip_size_validation import TestCase0014SkipSizeValidation from testcases.tc0015_skip_symlinks_validation import TestCase0015SkipSymlinksValidation from testcases.tc0016_check_nosync_validation import TestCase0016CheckNosyncValidation +from testcases.tc0017_check_nomount_validation import TestCase0017CheckNomountValidation +from testcases.tc0018_recycle_bin_validation import TestCase0018RecycleBinValidation +from testcases.tc0019_logging_and_running_config import TestCase0019LoggingAndRunningConfig +from testcases.tc0020_monitor_mode_validation import TestCase0020MonitorModeValidation +from testcases.tc0021_resumable_transfers_validation import TestCase0021ResumableTransfersValidation +from testcases.tc0022_local_first_validation import TestCase0022LocalFirstValidation +from testcases.tc0023_bypass_data_preservation_validation import TestCase0023BypassDataPreservationValidation +from testcases.tc0024_big_delete_safeguard_validation import TestCase0024BigDeleteSafeguardValidation def build_test_suite() -> list: @@ -50,6 +58,14 @@ def build_test_suite() -> list: TestCase0014SkipSizeValidation(), TestCase0015SkipSymlinksValidation(), TestCase0016CheckNosyncValidation(), + TestCase0017CheckNomountValidation(), + TestCase0018RecycleBinValidation(), + TestCase0019LoggingAndRunningConfig(), + TestCase0020MonitorModeValidation(), + TestCase0021ResumableTransfersValidation(), + TestCase0022LocalFirstValidation(), + TestCase0023BypassDataPreservationValidation(), + TestCase0024BigDeleteSafeguardValidation(), ] diff --git a/ci/e2e/testcases/tc0006_download_only.py b/ci/e2e/testcases/tc0006_download_only.py index 1c3461ff9..bfa1672b0 100644 --- a/ci/e2e/testcases/tc0006_download_only.py +++ b/ci/e2e/testcases/tc0006_download_only.py @@ -26,10 +26,10 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(seed_conf); self._write_config(seed_conf / "config") context.bootstrap_config_dir(download_conf); self._write_config(download_conf / "config") seed_stdout = case_log_dir / "seed_stdout.log"; seed_stderr = case_log_dir / "seed_stderr.log"; dl_stdout = case_log_dir / "download_stdout.log"; dl_stderr = case_log_dir / "download_stderr.log"; local_manifest_file = state_dir / "download_manifest.txt"; metadata_file = state_dir / "seed_metadata.txt" - seed_command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(seed_root), "--confdir", str(seed_conf)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(seed_root), "--confdir", str(seed_conf)] seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout); write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(download_root), "--confdir", str(download_conf)] + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(download_root), "--confdir", str(download_conf)] download_result = run_command(download_command, cwd=context.repo_root) write_text_file(dl_stdout, download_result.stdout); write_text_file(dl_stderr, download_result.stderr); local_manifest = build_manifest(download_root); write_manifest(local_manifest_file, local_manifest) write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"seed_command={command_to_string(seed_command)}", f"seed_returncode={seed_result.returncode}", f"download_command={command_to_string(download_command)}", f"download_returncode={download_result.returncode}"]) + "\n") diff --git a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py index 1b51caf55..fa4cded92 100644 --- a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py +++ b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py @@ -26,11 +26,11 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(seed_conf); self._write_config(seed_conf / "config") context.bootstrap_config_dir(cleanup_conf); self._write_config(cleanup_conf / "config") seed_stdout = case_log_dir / "seed_stdout.log"; seed_stderr = case_log_dir / "seed_stderr.log"; cleanup_stdout = case_log_dir / "cleanup_stdout.log"; cleanup_stderr = case_log_dir / "cleanup_stderr.log"; post_manifest_file = state_dir / "post_cleanup_manifest.txt"; metadata_file = state_dir / "seed_metadata.txt" - seed_command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(seed_conf)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(seed_conf)] seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout); write_text_file(seed_stderr, seed_result.stderr) stale = sync_root / root_name / "stale-local.txt"; write_text_file(stale, "stale\n") - cleanup_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--cleanup-local-files", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(cleanup_conf)] + cleanup_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--cleanup-local-files", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(cleanup_conf)] cleanup_result = run_command(cleanup_command, cwd=context.repo_root) write_text_file(cleanup_stdout, cleanup_result.stdout); write_text_file(cleanup_stderr, cleanup_result.stderr); post_manifest = build_manifest(sync_root); write_manifest(post_manifest_file, post_manifest) write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", f"cleanup_returncode={cleanup_result.returncode}"]) + "\n") diff --git a/ci/e2e/testcases/tc0008_upload_only.py b/ci/e2e/testcases/tc0008_upload_only.py index d794bbd81..fe1a1b1bd 100644 --- a/ci/e2e/testcases/tc0008_upload_only.py +++ b/ci/e2e/testcases/tc0008_upload_only.py @@ -26,10 +26,10 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(upload_conf); self._write_config(upload_conf / "config") context.bootstrap_config_dir(verify_conf); self._write_config(verify_conf / "config") stdout_file = case_log_dir / "upload_only_stdout.log"; stderr_file = case_log_dir / "upload_only_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "upload_metadata.txt" - command = [context.onedrive_bin, "--sync", "--verbose", "--upload-only", "--resync", "--resync-auth", "--syncdir", str(upload_root), "--confdir", str(upload_conf)] + command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--upload-only", "--resync", "--resync-auth", "--syncdir", str(upload_root), "--confdir", str(upload_conf)] result = run_command(command, cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) - verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"returncode={result.returncode}", f"verify_returncode={verify_result.returncode}"]) + "\n") diff --git a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py index 4d327bf83..a3f450954 100644 --- a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py +++ b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py @@ -27,14 +27,14 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(upload_conf); self._write_config(upload_conf / "config") context.bootstrap_config_dir(verify_conf); self._write_config(verify_conf / "config") seed_stdout = case_log_dir / "seed_stdout.log"; seed_stderr = case_log_dir / "seed_stderr.log"; upload_stdout = case_log_dir / "upload_only_stdout.log"; upload_stderr = case_log_dir / "upload_only_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "seed_metadata.txt" - seed_command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(seed_conf)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(seed_conf)] seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout); write_text_file(seed_stderr, seed_result.stderr) if keep_file.exists(): keep_file.unlink() - upload_command = [context.onedrive_bin, "--sync", "--verbose", "--upload-only", "--no-remote-delete", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(upload_conf)] + upload_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--upload-only", "--no-remote-delete", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(upload_conf)] upload_result = run_command(upload_command, cwd=context.repo_root) write_text_file(upload_stdout, upload_result.stdout); write_text_file(upload_stderr, upload_result.stderr) - verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", f"upload_returncode={upload_result.returncode}", f"verify_returncode={verify_result.returncode}"]) + "\n") diff --git a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py index ba73cd8ab..905ea9ecb 100644 --- a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py +++ b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py @@ -26,10 +26,10 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(upload_conf); self._write_config(upload_conf / "config") context.bootstrap_config_dir(verify_conf); self._write_config(verify_conf / "config") stdout_file = case_log_dir / "upload_only_remove_source_stdout.log"; stderr_file = case_log_dir / "upload_only_remove_source_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; post_manifest_file = state_dir / "post_upload_manifest.txt"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "upload_metadata.txt" - command = [context.onedrive_bin, "--sync", "--verbose", "--upload-only", "--remove-source-files", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(upload_conf)] + command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--upload-only", "--remove-source-files", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(upload_conf)] result = run_command(command, cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr); post_manifest = build_manifest(sync_root); write_manifest(post_manifest_file, post_manifest) - verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) write_text_file(metadata_file, "\n".join([f"root_name={root_name}", f"returncode={result.returncode}", f"verify_returncode={verify_result.returncode}"]) + "\n") diff --git a/ci/e2e/testcases/tc0011_skip_file_validation.py b/ci/e2e/testcases/tc0011_skip_file_validation.py index 7fba218bc..a7d9dbcbc 100644 --- a/ci/e2e/testcases/tc0011_skip_file_validation.py +++ b/ci/e2e/testcases/tc0011_skip_file_validation.py @@ -26,10 +26,10 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# tc0011 verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "skip_file_stdout.log"; stderr_file = case_log_dir / "skip_file_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" - command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) - verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) write_text_file(metadata_file, f"root_name={root_name}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") diff --git a/ci/e2e/testcases/tc0012_skip_dir_validation.py b/ci/e2e/testcases/tc0012_skip_dir_validation.py index 1ecef557a..45b2533f3 100644 --- a/ci/e2e/testcases/tc0012_skip_dir_validation.py +++ b/ci/e2e/testcases/tc0012_skip_dir_validation.py @@ -32,9 +32,9 @@ def _run_loose(self, context: E2EContext, case_log_dir: Path, all_artifacts: lis context.bootstrap_config_dir(confdir); self._write_config(confdir / "config", "Cache", False) context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "loose_match_stdout.log"; stderr_file = case_log_dir / "loose_match_stderr.log"; verify_stdout = case_log_dir / "loose_match_verify_stdout.log"; verify_stderr = case_log_dir / "loose_match_verify_stderr.log"; manifest_file = scenario_state / "remote_verify_manifest.txt" - result = run_command([context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)], cwd=context.repo_root) + result = run_command([context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)], cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) - verify_result = run_command([context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)], cwd=context.repo_root) + verify_result = run_command([context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)], cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); manifest = build_manifest(verify_root); write_manifest(manifest_file, manifest) all_artifacts.extend([str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(manifest_file)]) if result.returncode != 0: failures.append(f"Loose skip_dir scenario failed with status {result.returncode}"); return @@ -54,9 +54,9 @@ def _run_strict(self, context: E2EContext, case_log_dir: Path, all_artifacts: li context.bootstrap_config_dir(confdir); self._write_config(confdir / "config", f"{root}/App/Cache", True) context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "strict_match_stdout.log"; stderr_file = case_log_dir / "strict_match_stderr.log"; verify_stdout = case_log_dir / "strict_match_verify_stdout.log"; verify_stderr = case_log_dir / "strict_match_verify_stderr.log"; manifest_file = scenario_state / "remote_verify_manifest.txt" - result = run_command([context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)], cwd=context.repo_root) + result = run_command([context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)], cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) - verify_result = run_command([context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)], cwd=context.repo_root) + verify_result = run_command([context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)], cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); manifest = build_manifest(verify_root); write_manifest(manifest_file, manifest) all_artifacts.extend([str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(manifest_file)]) if result.returncode != 0: failures.append(f"Strict skip_dir scenario failed with status {result.returncode}"); return diff --git a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py index 125096977..d716e9af7 100644 --- a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py +++ b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py @@ -26,10 +26,10 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "skip_dotfiles_stdout.log"; stderr_file = case_log_dir / "skip_dotfiles_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" - command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) - verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) write_text_file(metadata_file, f"root_name={root_name}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") diff --git a/ci/e2e/testcases/tc0014_skip_size_validation.py b/ci/e2e/testcases/tc0014_skip_size_validation.py index 89c4e231c..92c04f422 100644 --- a/ci/e2e/testcases/tc0014_skip_size_validation.py +++ b/ci/e2e/testcases/tc0014_skip_size_validation.py @@ -16,28 +16,33 @@ class TestCase0014SkipSizeValidation(E2ETestCase): description = "Validate that skip_size prevents oversized files from synchronising" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0014 config\nbypass_data_preservation = \"true\"\nskip_size = \"1\"\n") + write_text_file(config_path, "# tc0014 config\nbypass_data_preservation = \"true\"\ndebug_logging = \"true\"\nenable_logging = \"true\"\nskip_size = \"1\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0014"; case_log_dir = context.logs_dir / "tc0014"; state_dir = context.state_dir / "tc0014" reset_directory(case_work_dir); reset_directory(case_log_dir); reset_directory(state_dir); context.ensure_refresh_token_available() - sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0014_{context.run_id}_{os.getpid()}" + sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0014_{context.run_id}_{os.getpid()}"; app_log_dir = case_log_dir / "app-logs" write_text_file(sync_root / root_name / "small.bin", "a" * 16384) big_path = sync_root / root_name / "large.bin"; big_path.parent.mkdir(parents=True, exist_ok=True); big_path.write_bytes(b"B" * (2 * 1024 * 1024)) context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") + write_text_file(confdir / "config", (confdir / "config").read_text(encoding="utf-8") + f'log_dir = "{app_log_dir}"\n') context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") - stdout_file = case_log_dir / "skip_size_stdout.log"; stderr_file = case_log_dir / "skip_size_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" - command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + stdout_file = case_log_dir / "skip_size_stdout.log"; stderr_file = case_log_dir / "skip_size_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt"; config_copy = state_dir / "config_used.txt"; verify_config_copy = state_dir / "verify_config_used.txt" + command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) - verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) - write_text_file(metadata_file, f"root_name={root_name}\nlarge_size={big_path.stat().st_size}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") - artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] - details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name, "large_size": big_path.stat().st_size} + write_text_file(config_copy, (confdir / "config").read_text(encoding="utf-8")) + write_text_file(verify_config_copy, (verify_conf / "config").read_text(encoding="utf-8")) + write_text_file(metadata_file, f"root_name={root_name}\nlarge_size={big_path.stat().st_size}\nlarge_size_mb_decimal={big_path.stat().st_size / 1000 / 1000:.3f}\nlarge_size_mib_binary={big_path.stat().st_size / 1024 / 1024:.3f}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file), str(config_copy), str(verify_config_copy)] + if app_log_dir.exists(): + artifacts.append(str(app_log_dir)) + details = {"returncode": result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name, "large_size": big_path.stat().st_size, "large_size_mb_decimal": round(big_path.stat().st_size / 1000 / 1000, 3), "large_size_mib_binary": round(big_path.stat().st_size / 1024 / 1024, 3), "skip_size": 1} if result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"skip_size validation failed with status {result.returncode}", artifacts, details) if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) if f"{root_name}/small.bin" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Small file missing after skip_size processing", artifacts, details) - if f"{root_name}/large.bin" in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Large file exceeded skip_size threshold but was synchronised", artifacts, details) + if f"{root_name}/large.bin" in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Large file exceeded configured skip_size threshold but was synchronised; review display-running-config output and debug logs", artifacts, details) return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py index 1e8421090..a88256ccb 100644 --- a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py +++ b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py @@ -26,10 +26,10 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "skip_symlinks_stdout.log"; stderr_file = case_log_dir / "skip_symlinks_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" - command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) - verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) write_text_file(metadata_file, f"root_name={root_name}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") diff --git a/ci/e2e/testcases/tc0016_check_nosync_validation.py b/ci/e2e/testcases/tc0016_check_nosync_validation.py index 14a9fe222..12cf01361 100644 --- a/ci/e2e/testcases/tc0016_check_nosync_validation.py +++ b/ci/e2e/testcases/tc0016_check_nosync_validation.py @@ -26,10 +26,10 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "check_nosync_stdout.log"; stderr_file = case_log_dir / "check_nosync_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" - command = [context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] + command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) - verify_command = [context.onedrive_bin, "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--syncdir", str(verify_root), "--confdir", str(verify_conf)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout); write_text_file(verify_stderr, verify_result.stderr); remote_manifest = build_manifest(verify_root); write_manifest(remote_manifest_file, remote_manifest) write_text_file(metadata_file, f"root_name={root_name}\nreturncode={result.returncode}\nverify_returncode={verify_result.returncode}\n") diff --git a/ci/e2e/testcases/tc0017_check_nomount_validation.py b/ci/e2e/testcases/tc0017_check_nomount_validation.py new file mode 100644 index 000000000..5da31d2a7 --- /dev/null +++ b/ci/e2e/testcases/tc0017_check_nomount_validation.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0017CheckNomountValidation(E2ETestCase): + case_id = "0017" + name = "check_nomount validation" + description = "Validate that check_nomount aborts synchronisation when .nosync exists in the sync_dir mount point" + + def _write_config(self, config_path: Path) -> None: + write_text_file( + config_path, + "# tc0017 config\n" + 'bypass_data_preservation = "true"\n' + 'check_nomount = "true"\n', + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0017" + case_log_dir = context.logs_dir / "tc0017" + state_dir = context.state_dir / "tc0017" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + confdir = case_work_dir / "conf-main" + root_name = f"ZZ_E2E_TC0017_{context.run_id}_{os.getpid()}" + + write_text_file(sync_root / ".nosync", "") + write_text_file(sync_root / root_name / "should_not_upload.txt", "blocked by check_nomount\n") + + context.bootstrap_config_dir(confdir) + self._write_config(confdir / "config") + + stdout_file = case_log_dir / "check_nomount_stdout.log" + stderr_file = case_log_dir / "check_nomount_stderr.log" + metadata_file = state_dir / "metadata.txt" + + command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--syncdir", + str(sync_root), + "--confdir", + str(confdir), + ] + context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"command={command_to_string(command)}", + f"returncode={result.returncode}", + ] + ) + + "\n", + ) + + artifacts = [str(stdout_file), str(stderr_file), str(metadata_file)] + details = { + "command": command, + "returncode": result.returncode, + "root_name": root_name, + } + + combined_output = (result.stdout + "\n" + result.stderr).lower() + + if result.returncode == 0: + return TestResult.fail_result( + self.case_id, + self.name, + "check_nomount did not abort synchronisation when .nosync existed in the sync_dir mount point", + artifacts, + details, + ) + + if ".nosync file found" not in combined_output and "aborting synchronization process to safeguard data" not in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + "check_nomount did not emit the expected .nosync safeguard message", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py new file mode 100644 index 000000000..a6fc46b2a --- /dev/null +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0018RecycleBinValidation(E2ETestCase): + case_id = "0018" + name = "recycle bin validation" + description = "Validate that online deletions are moved into a FreeDesktop-compliant recycle bin when enabled" + + def _write_seed_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0018 seed config\n" 'bypass_data_preservation = "true"\n') + + def _write_cleanup_config(self, config_path: Path, recycle_bin_path: Path) -> None: + write_text_file( + config_path, + "# tc0018 cleanup config\n" + 'bypass_data_preservation = "true"\n' + 'cleanup_local_files = "true"\n' + 'download_only = "true"\n' + 'use_recycle_bin = "true"\n' + f'recycle_bin_path = "{recycle_bin_path}"\n', + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0018" + case_log_dir = context.logs_dir / "tc0018" + state_dir = context.state_dir / "tc0018" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + conf_seed = case_work_dir / "conf-seed" + conf_cleanup = case_work_dir / "conf-cleanup" + verify_root = case_work_dir / "verifyroot" + conf_verify = case_work_dir / "conf-verify" + recycle_bin_root = case_work_dir / "RecycleBin" + root_name = f"ZZ_E2E_TC0018_{context.run_id}_{os.getpid()}" + + write_text_file(sync_root / root_name / "Keep" / "keep.txt", "keep\n") + write_text_file(sync_root / root_name / "OldData" / "old.txt", "old\n") + + context.bootstrap_config_dir(conf_seed) + self._write_seed_config(conf_seed / "config") + context.bootstrap_config_dir(conf_cleanup) + self._write_cleanup_config(conf_cleanup / "config", recycle_bin_root) + context.bootstrap_config_dir(conf_verify) + self._write_seed_config(conf_verify / "config") + + seed_stdout = case_log_dir / "seed_stdout.log" + seed_stderr = case_log_dir / "seed_stderr.log" + remove_stdout = case_log_dir / "remove_stdout.log" + remove_stderr = case_log_dir / "remove_stderr.log" + cleanup_stdout = case_log_dir / "cleanup_stdout.log" + cleanup_stderr = case_log_dir / "cleanup_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + recycle_manifest_file = state_dir / "recycle_manifest.txt" + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + local_manifest_file = state_dir / "local_manifest_after_cleanup.txt" + metadata_file = state_dir / "metadata.txt" + + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--upload-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(sync_root), + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout) + write_text_file(seed_stderr, seed_result.stderr) + + remove_command = [ + context.onedrive_bin, + "--display-running-config", + "--verbose", + "--remove-directory", + f"{root_name}/OldData", + "--syncdir", + str(sync_root), + "--confdir", + str(conf_seed), + ] + remove_result = run_command(remove_command, cwd=context.repo_root) + write_text_file(remove_stdout, remove_result.stdout) + write_text_file(remove_stderr, remove_result.stderr) + + cleanup_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--cleanup-local-files", + "--single-directory", + root_name, + "--syncdir", + str(sync_root), + "--confdir", + str(conf_cleanup), + ] + cleanup_result = run_command(cleanup_command, cwd=context.repo_root) + write_text_file(cleanup_stdout, cleanup_result.stdout) + write_text_file(cleanup_stderr, cleanup_result.stderr) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(verify_root), + "--confdir", + str(conf_verify), + ] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + + recycle_manifest = build_manifest(recycle_bin_root) + remote_manifest = build_manifest(verify_root) + local_manifest = build_manifest(sync_root) + write_manifest(recycle_manifest_file, recycle_manifest) + write_manifest(remote_manifest_file, remote_manifest) + write_manifest(local_manifest_file, local_manifest) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"seed_returncode={seed_result.returncode}", + f"remove_returncode={remove_result.returncode}", + f"cleanup_returncode={cleanup_result.returncode}", + f"verify_returncode={verify_result.returncode}", + ] + ) + + "\n", + ) + + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(remove_stdout), + str(remove_stderr), + str(cleanup_stdout), + str(cleanup_stderr), + str(verify_stdout), + str(verify_stderr), + str(recycle_manifest_file), + str(remote_manifest_file), + str(local_manifest_file), + str(metadata_file), + ] + details = { + "seed_returncode": seed_result.returncode, + "remove_returncode": remove_result.returncode, + "cleanup_returncode": cleanup_result.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + } + + if seed_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts, details) + if remove_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Online directory removal failed with status {remove_result.returncode}", artifacts, details) + if cleanup_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Recycle bin cleanup sync failed with status {cleanup_result.returncode}", artifacts, details) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + + if (sync_root / root_name / "OldData").exists(): + return TestResult.fail_result(self.case_id, self.name, "OldData still exists locally after online deletion cleanup", artifacts, details) + if not (sync_root / root_name / "Keep" / "keep.txt").is_file(): + return TestResult.fail_result(self.case_id, self.name, "Keep file is missing locally after recycle bin processing", artifacts, details) + + recycle_has_file = any(path.endswith("old.txt") for path in recycle_manifest) + recycle_has_info = any(path.endswith(".trashinfo") for path in recycle_manifest) + if not recycle_has_file: + return TestResult.fail_result(self.case_id, self.name, "Deleted content was not moved into the configured recycle bin", artifacts, details) + if not recycle_has_info: + return TestResult.fail_result(self.case_id, self.name, "Recycle bin metadata .trashinfo file was not created", artifacts, details) + + if f"{root_name}/Keep/keep.txt" not in remote_manifest: + return TestResult.fail_result(self.case_id, self.name, "Keep file is missing online after recycle bin processing", artifacts, details) + if any(entry == f"{root_name}/OldData" or entry.startswith(f"{root_name}/OldData/") for entry in remote_manifest): + return TestResult.fail_result(self.case_id, self.name, "OldData still exists online after explicit online removal", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0019_logging_and_running_config.py b/ci/e2e/testcases/tc0019_logging_and_running_config.py new file mode 100644 index 000000000..5527c42a9 --- /dev/null +++ b/ci/e2e/testcases/tc0019_logging_and_running_config.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0019LoggingAndRunningConfig(E2ETestCase): + case_id = "0019" + name = "logging and running config validation" + description = "Validate custom log_dir output and display-running-config visibility" + + def _write_config(self, config_path: Path, app_log_dir: Path) -> None: + write_text_file( + config_path, + "# tc0019 config\n" + 'bypass_data_preservation = "true"\n' + 'enable_logging = "true"\n' + f'log_dir = "{app_log_dir}"\n', + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0019" + case_log_dir = context.logs_dir / "tc0019" + state_dir = context.state_dir / "tc0019" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + confdir = case_work_dir / "conf-main" + root_name = f"ZZ_E2E_TC0019_{context.run_id}_{os.getpid()}" + app_log_dir = case_log_dir / "app-logs" + + write_text_file(sync_root / root_name / "logging.txt", "log me\n") + + context.bootstrap_config_dir(confdir) + self._write_config(confdir / "config", app_log_dir) + + stdout_file = case_log_dir / "logging_stdout.log" + stderr_file = case_log_dir / "logging_stderr.log" + metadata_file = state_dir / "metadata.txt" + + command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(sync_root), + "--confdir", + str(confdir), + ] + context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + + log_entries = sorted(str(p.relative_to(app_log_dir)) for p in app_log_dir.rglob("*") if p.is_file()) if app_log_dir.exists() else [] + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"returncode={result.returncode}", + ] + [f"log_file={entry}" for entry in log_entries] + ) + "\n", + ) + + artifacts = [str(stdout_file), str(stderr_file), str(metadata_file)] + if app_log_dir.exists(): + artifacts.append(str(app_log_dir)) + details = { + "returncode": result.returncode, + "root_name": root_name, + "log_file_count": len(log_entries), + } + + if result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Logging validation failed with status {result.returncode}", artifacts, details) + if not log_entries: + return TestResult.fail_result(self.case_id, self.name, "No application log files were created in the configured log_dir", artifacts, details) + + stdout_lower = result.stdout.lower() + if "display_running_config" not in stdout_lower and "log_dir" not in stdout_lower: + return TestResult.fail_result(self.case_id, self.name, "display-running-config output did not expose the active runtime configuration", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0020_monitor_mode_validation.py b/ci/e2e/testcases/tc0020_monitor_mode_validation.py new file mode 100644 index 000000000..768d14c53 --- /dev/null +++ b/ci/e2e/testcases/tc0020_monitor_mode_validation.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import os +import signal +import subprocess +import time +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0020MonitorModeValidation(E2ETestCase): + case_id = "0020" + name = "monitor mode validation" + description = "Validate that monitor mode uploads local changes without manually re-running --sync" + + def _write_config(self, config_path: Path, app_log_dir: Path) -> None: + write_text_file( + config_path, + "# tc0020 config\n" + 'bypass_data_preservation = "true"\n' + 'enable_logging = "true"\n' + f'log_dir = "{app_log_dir}"\n' + 'monitor_interval = "5"\n' + 'monitor_fullscan_frequency = "1"\n', + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0020" + case_log_dir = context.logs_dir / "tc0020" + state_dir = context.state_dir / "tc0020" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + confdir = case_work_dir / "conf-main" + verify_root = case_work_dir / "verifyroot" + verify_conf = case_work_dir / "conf-verify" + root_name = f"ZZ_E2E_TC0020_{context.run_id}_{os.getpid()}" + app_log_dir = case_log_dir / "app-logs" + + write_text_file(sync_root / root_name / "baseline.txt", "baseline\n") + + context.bootstrap_config_dir(confdir) + self._write_config(confdir / "config", app_log_dir) + context.bootstrap_config_dir(verify_conf) + write_text_file(verify_conf / "config", "# tc0020 verify\n" 'bypass_data_preservation = "true"\n') + + stdout_file = case_log_dir / "monitor_stdout.log" + stderr_file = case_log_dir / "monitor_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + command = [ + context.onedrive_bin, + "--display-running-config", + "--monitor", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(sync_root), + "--confdir", + str(confdir), + ] + context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") + + with stdout_file.open("w", encoding="utf-8") as stdout_fp, stderr_file.open("w", encoding="utf-8") as stderr_fp: + process = subprocess.Popen( + command, + cwd=str(context.repo_root), + stdout=stdout_fp, + stderr=stderr_fp, + text=True, + ) + time.sleep(8) + write_text_file(sync_root / root_name / "monitor-added.txt", "added while monitor mode was running\n") + time.sleep(12) + process.send_signal(signal.SIGINT) + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=30) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(verify_root), + "--confdir", + str(verify_conf), + ] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"monitor_returncode={process.returncode}", + f"verify_returncode={verify_result.returncode}", + ] + ) + "\n", + ) + + artifacts = [str(stdout_file), str(stderr_file), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + if app_log_dir.exists(): + artifacts.append(str(app_log_dir)) + details = { + "monitor_returncode": process.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + } + + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + + if f"{root_name}/monitor-added.txt" not in remote_manifest: + return TestResult.fail_result(self.case_id, self.name, "Monitor mode did not upload the file created while the process was running", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py new file mode 100644 index 000000000..1de0e2e4d --- /dev/null +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import os +import signal +import subprocess +import time +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0021ResumableTransfersValidation(E2ETestCase): + case_id = "0021" + name = "resumable transfers validation" + description = "Validate interrupted upload recovery for a resumable session upload" + + def _write_config(self, config_path: Path, app_log_dir: Path) -> None: + write_text_file( + config_path, + "# tc0021 config\n" + 'bypass_data_preservation = "true"\n' + 'enable_logging = "true"\n' + f'log_dir = "{app_log_dir}"\n' + 'force_session_upload = "true"\n' + 'rate_limit = "262144"\n', + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0021" + case_log_dir = context.logs_dir / "tc0021" + state_dir = context.state_dir / "tc0021" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + confdir = case_work_dir / "conf-main" + verify_root = case_work_dir / "verifyroot" + verify_conf = case_work_dir / "conf-verify" + root_name = f"ZZ_E2E_TC0021_{context.run_id}_{os.getpid()}" + app_log_dir = case_log_dir / "app-logs" + large_file = sync_root / root_name / "session-large.bin" + large_file.parent.mkdir(parents=True, exist_ok=True) + large_file.write_bytes(b"R" * (5 * 1024 * 1024)) + + context.bootstrap_config_dir(confdir) + self._write_config(confdir / "config", app_log_dir) + context.bootstrap_config_dir(verify_conf) + write_text_file(verify_conf / "config", "# tc0021 verify\n" 'bypass_data_preservation = "true"\n') + + phase1_stdout = case_log_dir / "phase1_stdout.log" + phase1_stderr = case_log_dir / "phase1_stderr.log" + phase2_stdout = case_log_dir / "phase2_stdout.log" + phase2_stderr = case_log_dir / "phase2_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + command = [ + context.onedrive_bin, + "--display-running-config", + "--upload-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(sync_root), + "--confdir", + str(confdir), + ] + context.log(f"Executing Test Case {self.case_id} phase 1: {command_to_string(command)}") + + with phase1_stdout.open("w", encoding="utf-8") as stdout_fp, phase1_stderr.open("w", encoding="utf-8") as stderr_fp: + process = subprocess.Popen( + command, + cwd=str(context.repo_root), + stdout=stdout_fp, + stderr=stderr_fp, + text=True, + ) + time.sleep(5) + process.send_signal(signal.SIGINT) + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=30) + + context.log(f"Executing Test Case {self.case_id} phase 2: {command_to_string(command)}") + phase2_result = run_command(command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(verify_root), + "--confdir", + str(verify_conf), + ] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"phase1_returncode={process.returncode}", + f"phase2_returncode={phase2_result.returncode}", + f"verify_returncode={verify_result.returncode}", + f"large_size={large_file.stat().st_size}", + ] + ) + "\n", + ) + + artifacts = [str(phase1_stdout), str(phase1_stderr), str(phase2_stdout), str(phase2_stderr), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + if app_log_dir.exists(): + artifacts.append(str(app_log_dir)) + details = { + "phase1_returncode": process.returncode, + "phase2_returncode": phase2_result.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + "large_size": large_file.stat().st_size, + } + + if phase2_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Resumable upload recovery phase failed with status {phase2_result.returncode}", artifacts, details) + if verify_result.returncode != 0: + return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + if f"{root_name}/session-large.bin" not in remote_manifest: + return TestResult.fail_result(self.case_id, self.name, "Interrupted resumable upload did not complete successfully on the subsequent run", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0022_local_first_validation.py b/ci/e2e/testcases/tc0022_local_first_validation.py new file mode 100644 index 000000000..fc7638b46 --- /dev/null +++ b/ci/e2e/testcases/tc0022_local_first_validation.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0022LocalFirstValidation(E2ETestCase): + case_id = "0022" + name = "local_first validation" + description = "Validate that local_first treats local content as the source of truth during a conflict" + + def _write_default_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0022 config\n" 'bypass_data_preservation = "true"\n') + + def _write_local_first_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0022 local first config\n" 'bypass_data_preservation = "true"\n' 'local_first = "true"\n') + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0022" + case_log_dir = context.logs_dir / "tc0022" + state_dir = context.state_dir / "tc0022" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + seed_root = case_work_dir / "seedroot" + local_root = case_work_dir / "localroot" + remote_update_root = case_work_dir / "remoteupdateroot" + verify_root = case_work_dir / "verifyroot" + conf_seed = case_work_dir / "conf-seed" + conf_local = case_work_dir / "conf-local" + conf_remote = case_work_dir / "conf-remote" + conf_verify = case_work_dir / "conf-verify" + root_name = f"ZZ_E2E_TC0022_{context.run_id}_{os.getpid()}" + relative_file = f"{root_name}/conflict.txt" + + write_text_file(seed_root / relative_file, "base\n") + write_text_file(remote_update_root / relative_file, "remote wins unless local_first applies\n") + + context.bootstrap_config_dir(conf_seed) + self._write_default_config(conf_seed / "config") + context.bootstrap_config_dir(conf_local) + self._write_local_first_config(conf_local / "config") + context.bootstrap_config_dir(conf_remote) + self._write_default_config(conf_remote / "config") + context.bootstrap_config_dir(conf_verify) + self._write_default_config(conf_verify / "config") + + seed_stdout = case_log_dir / "seed_stdout.log" + seed_stderr = case_log_dir / "seed_stderr.log" + download_stdout = case_log_dir / "download_stdout.log" + download_stderr = case_log_dir / "download_stderr.log" + remote_stdout = case_log_dir / "remote_update_stdout.log" + remote_stderr = case_log_dir / "remote_update_stderr.log" + final_stdout = case_log_dir / "final_sync_stdout.log" + final_stderr = case_log_dir / "final_sync_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + seed_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout) + write_text_file(seed_stderr, seed_result.stderr) + + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + download_result = run_command(download_command, cwd=context.repo_root) + write_text_file(download_stdout, download_result.stdout) + write_text_file(download_stderr, download_result.stderr) + + write_text_file(local_root / relative_file, "local wins because local_first is enabled\n") + + remote_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] + remote_result = run_command(remote_command, cwd=context.repo_root) + write_text_file(remote_stdout, remote_result.stdout) + write_text_file(remote_stderr, remote_result.stderr) + + final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + final_result = run_command(final_command, cwd=context.repo_root) + write_text_file(final_stdout, final_result.stdout) + write_text_file(final_stderr, final_result.stderr) + + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(verify_root), "--confdir", str(conf_verify)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + local_content = (local_root / relative_file).read_text(encoding="utf-8") if (local_root / relative_file).is_file() else "" + remote_content = (verify_root / relative_file).read_text(encoding="utf-8") if (verify_root / relative_file).is_file() else "" + write_text_file(metadata_file, f"case_id={self.case_id}\nroot_name={root_name}\nseed_returncode={seed_result.returncode}\ndownload_returncode={download_result.returncode}\nremote_returncode={remote_result.returncode}\nfinal_returncode={final_result.returncode}\nverify_returncode={verify_result.returncode}\nlocal_content={local_content!r}\nremote_content={remote_content!r}\n") + + artifacts = [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(remote_stdout), str(remote_stderr), str(final_stdout), str(final_stderr), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "remote_returncode": remote_result.returncode, "final_returncode": final_result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + + for label, rc in [("seed", seed_result.returncode), ("download", download_result.returncode), ("remote update", remote_result.returncode), ("final sync", final_result.returncode), ("verify", verify_result.returncode)]: + if rc != 0: + return TestResult.fail_result(self.case_id, self.name, f"{label} phase failed with status {rc}", artifacts, details) + + expected = "local wins because local_first is enabled\n" + if local_content != expected: + return TestResult.fail_result(self.case_id, self.name, "Local content was not retained after conflict resolution with local_first enabled", artifacts, details) + if remote_content != expected: + return TestResult.fail_result(self.case_id, self.name, "Remote content did not converge to the local source-of-truth content when local_first was enabled", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py new file mode 100644 index 000000000..a7b122db3 --- /dev/null +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.result import TestResult +from framework.utils import reset_directory, run_command, write_text_file + + +class TestCase0023BypassDataPreservationValidation(E2ETestCase): + case_id = "0023" + name = "bypass_data_preservation validation" + description = "Validate that bypass_data_preservation overwrites local conflict data instead of creating safeBackup files" + + def _write_default_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0023 config\n" 'bypass_data_preservation = "false"\n') + + def _write_bypass_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0023 bypass config\n" 'bypass_data_preservation = "true"\n') + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0023" + case_log_dir = context.logs_dir / "tc0023" + state_dir = context.state_dir / "tc0023" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + seed_root = case_work_dir / "seedroot" + local_root = case_work_dir / "localroot" + remote_update_root = case_work_dir / "remoteupdateroot" + conf_seed = case_work_dir / "conf-seed" + conf_local = case_work_dir / "conf-local" + conf_remote = case_work_dir / "conf-remote" + root_name = f"ZZ_E2E_TC0023_{context.run_id}_{os.getpid()}" + relative_file = f"{root_name}/conflict.txt" + + write_text_file(seed_root / relative_file, "base\n") + write_text_file(remote_update_root / relative_file, "remote authoritative content\n") + + context.bootstrap_config_dir(conf_seed) + self._write_default_config(conf_seed / "config") + context.bootstrap_config_dir(conf_local) + self._write_bypass_config(conf_local / "config") + context.bootstrap_config_dir(conf_remote) + self._write_default_config(conf_remote / "config") + + seed_stdout = case_log_dir / "seed_stdout.log" + seed_stderr = case_log_dir / "seed_stderr.log" + download_stdout = case_log_dir / "download_stdout.log" + download_stderr = case_log_dir / "download_stderr.log" + remote_stdout = case_log_dir / "remote_update_stdout.log" + remote_stderr = case_log_dir / "remote_update_stderr.log" + final_stdout = case_log_dir / "final_sync_stdout.log" + final_stderr = case_log_dir / "final_sync_stderr.log" + metadata_file = state_dir / "metadata.txt" + + seed_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout) + write_text_file(seed_stderr, seed_result.stderr) + + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + download_result = run_command(download_command, cwd=context.repo_root) + write_text_file(download_stdout, download_result.stdout) + write_text_file(download_stderr, download_result.stderr) + + write_text_file(local_root / relative_file, "local conflicting content\n") + + remote_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] + remote_result = run_command(remote_command, cwd=context.repo_root) + write_text_file(remote_stdout, remote_result.stdout) + write_text_file(remote_stderr, remote_result.stderr) + + final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + final_result = run_command(final_command, cwd=context.repo_root) + write_text_file(final_stdout, final_result.stdout) + write_text_file(final_stderr, final_result.stderr) + + local_file = local_root / relative_file + local_content = local_file.read_text(encoding="utf-8") if local_file.is_file() else "" + safe_backup_files = [p.name for p in local_file.parent.glob("*safeBackup*")] + write_text_file(metadata_file, f"case_id={self.case_id}\nroot_name={root_name}\nseed_returncode={seed_result.returncode}\ndownload_returncode={download_result.returncode}\nremote_returncode={remote_result.returncode}\nfinal_returncode={final_result.returncode}\nlocal_content={local_content!r}\nsafe_backup_files={safe_backup_files!r}\n") + + artifacts = [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(remote_stdout), str(remote_stderr), str(final_stdout), str(final_stderr), str(metadata_file)] + details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "remote_returncode": remote_result.returncode, "final_returncode": final_result.returncode, "root_name": root_name, "safe_backup_count": len(safe_backup_files)} + + for label, rc in [("seed", seed_result.returncode), ("download", download_result.returncode), ("remote update", remote_result.returncode), ("final sync", final_result.returncode)]: + if rc != 0: + return TestResult.fail_result(self.case_id, self.name, f"{label} phase failed with status {rc}", artifacts, details) + + expected = "remote authoritative content\n" + if local_content != expected: + return TestResult.fail_result(self.case_id, self.name, "Local conflict content was not overwritten by the remote version when bypass_data_preservation was enabled", artifacts, details) + if safe_backup_files: + return TestResult.fail_result(self.case_id, self.name, "safeBackup files were created despite bypass_data_preservation being enabled", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py new file mode 100644 index 000000000..dad97e115 --- /dev/null +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import reset_directory, run_command, write_text_file + + +class TestCase0024BigDeleteSafeguardValidation(E2ETestCase): + case_id = "0024" + name = "big delete safeguard validation" + description = "Validate classify_as_big_delete protection and forced acknowledgement via --force" + + def _write_config(self, config_path: Path) -> None: + write_text_file(config_path, "# tc0024 config\n" 'bypass_data_preservation = "true"\n' 'classify_as_big_delete = "3"\n') + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0024" + case_log_dir = context.logs_dir / "tc0024" + state_dir = context.state_dir / "tc0024" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + seed_root = case_work_dir / "seedroot" + local_root = case_work_dir / "localroot" + verify_root = case_work_dir / "verifyroot" + conf_seed = case_work_dir / "conf-seed" + conf_local = case_work_dir / "conf-local" + conf_verify = case_work_dir / "conf-verify" + root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" + + for idx in range(1, 6): + write_text_file(seed_root / root_name / "BigDelete" / f"file{idx}.txt", f"file {idx}\n") + write_text_file(seed_root / root_name / "Keep" / "keep.txt", "keep\n") + + context.bootstrap_config_dir(conf_seed) + self._write_config(conf_seed / "config") + context.bootstrap_config_dir(conf_local) + self._write_config(conf_local / "config") + context.bootstrap_config_dir(conf_verify) + self._write_config(conf_verify / "config") + + seed_stdout = case_log_dir / "seed_stdout.log" + seed_stderr = case_log_dir / "seed_stderr.log" + download_stdout = case_log_dir / "download_stdout.log" + download_stderr = case_log_dir / "download_stderr.log" + blocked_stdout = case_log_dir / "blocked_stdout.log" + blocked_stderr = case_log_dir / "blocked_stderr.log" + forced_stdout = case_log_dir / "forced_stdout.log" + forced_stderr = case_log_dir / "forced_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + seed_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout) + write_text_file(seed_stderr, seed_result.stderr) + + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + download_result = run_command(download_command, cwd=context.repo_root) + write_text_file(download_stdout, download_result.stdout) + write_text_file(download_stderr, download_result.stderr) + + shutil.rmtree(local_root / root_name / "BigDelete") + + blocked_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + blocked_result = run_command(blocked_command, cwd=context.repo_root) + write_text_file(blocked_stdout, blocked_result.stdout) + write_text_file(blocked_stderr, blocked_result.stderr) + + forced_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--force", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + forced_result = run_command(forced_command, cwd=context.repo_root) + write_text_file(forced_stdout, forced_result.stdout) + write_text_file(forced_stderr, forced_result.stderr) + + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(verify_root), "--confdir", str(conf_verify)] + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + blocked_output = (blocked_result.stdout + "\n" + blocked_result.stderr).lower() + write_text_file(metadata_file, f"case_id={self.case_id}\nroot_name={root_name}\nseed_returncode={seed_result.returncode}\ndownload_returncode={download_result.returncode}\nblocked_returncode={blocked_result.returncode}\nforced_returncode={forced_result.returncode}\nverify_returncode={verify_result.returncode}\n") + + artifacts = [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(blocked_stdout), str(blocked_stderr), str(forced_stdout), str(forced_stderr), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "blocked_returncode": blocked_result.returncode, "forced_returncode": forced_result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + + for label, rc in [("seed", seed_result.returncode), ("download", download_result.returncode), ("forced sync", forced_result.returncode), ("verify", verify_result.returncode)]: + if rc != 0: + return TestResult.fail_result(self.case_id, self.name, f"{label} phase failed with status {rc}", artifacts, details) + + if blocked_result.returncode == 0 and "big delete" not in blocked_output: + return TestResult.fail_result(self.case_id, self.name, "Big delete safeguard did not trigger before forced acknowledgement", artifacts, details) + if "big delete" not in blocked_output and "--force" not in blocked_output: + return TestResult.fail_result(self.case_id, self.name, "Blocked sync did not emit a big delete safeguard warning", artifacts, details) + if any(entry == f"{root_name}/BigDelete" or entry.startswith(f"{root_name}/BigDelete/") for entry in remote_manifest): + return TestResult.fail_result(self.case_id, self.name, "BigDelete content still exists online after acknowledged forced delete", artifacts, details) + if f"{root_name}/Keep/keep.txt" not in remote_manifest: + return TestResult.fail_result(self.case_id, self.name, "Keep content disappeared during big delete safeguard processing", artifacts, details) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) From 7815913a9ebd9a2c12d1b51a800e31b79dc5e956 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 14 Mar 2026 17:31:33 +1100 Subject: [PATCH 047/245] Fix test cases * Fix test cases --- ci/e2e/testcases/tc0014_skip_size_validation.py | 2 +- ci/e2e/testcases/tc0018_recycle_bin_validation.py | 1 + .../tc0021_resumable_transfers_validation.py | 1 + ci/e2e/testcases/tc0022_local_first_validation.py | 4 ++-- .../tc0023_bypass_data_preservation_validation.py | 4 ++-- .../tc0024_big_delete_safeguard_validation.py | 14 ++++++++++++-- 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/ci/e2e/testcases/tc0014_skip_size_validation.py b/ci/e2e/testcases/tc0014_skip_size_validation.py index 92c04f422..12c3f3051 100644 --- a/ci/e2e/testcases/tc0014_skip_size_validation.py +++ b/ci/e2e/testcases/tc0014_skip_size_validation.py @@ -16,7 +16,7 @@ class TestCase0014SkipSizeValidation(E2ETestCase): description = "Validate that skip_size prevents oversized files from synchronising" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0014 config\nbypass_data_preservation = \"true\"\ndebug_logging = \"true\"\nenable_logging = \"true\"\nskip_size = \"1\"\n") + write_text_file(config_path, "# tc0014 config\nbypass_data_preservation = \"true\"\nenable_logging = \"true\"\nskip_size = \"1\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0014"; case_log_dir = context.logs_dir / "tc0014"; state_dir = context.state_dir / "tc0014" diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index a6fc46b2a..3da8845b9 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -72,6 +72,7 @@ def run(self, context: E2EContext) -> TestResult: seed_command = [ context.onedrive_bin, "--display-running-config", + "--sync", "--upload-only", "--verbose", "--resync", diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 1de0e2e4d..6aed7d50c 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -65,6 +65,7 @@ def run(self, context: E2EContext) -> TestResult: command = [ context.onedrive_bin, "--display-running-config", + "--sync", "--upload-only", "--verbose", "--resync", diff --git a/ci/e2e/testcases/tc0022_local_first_validation.py b/ci/e2e/testcases/tc0022_local_first_validation.py index fc7638b46..f5e124d5b 100644 --- a/ci/e2e/testcases/tc0022_local_first_validation.py +++ b/ci/e2e/testcases/tc0022_local_first_validation.py @@ -66,7 +66,7 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) @@ -78,7 +78,7 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(local_root / relative_file, "local wins because local_first is enabled\n") - remote_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] + remote_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] remote_result = run_command(remote_command, cwd=context.repo_root) write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py index a7b122db3..5ccc29082 100644 --- a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -58,7 +58,7 @@ def run(self, context: E2EContext) -> TestResult: final_stderr = case_log_dir / "final_sync_stderr.log" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) @@ -70,7 +70,7 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(local_root / relative_file, "local conflicting content\n") - remote_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] + remote_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] remote_result = run_command(remote_command, cwd=context.repo_root) write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index dad97e115..a907ca457 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -60,7 +60,7 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) @@ -70,7 +70,17 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) - shutil.rmtree(local_root / root_name / "BigDelete") + target_delete_path = local_root / root_name / "BigDelete" + if not target_delete_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + "Expected BigDelete path was not downloaded before delete phase", + [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(metadata_file)], + {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "root_name": root_name}, + ) + + shutil.rmtree(target_delete_path) blocked_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] blocked_result = run_command(blocked_command, cwd=context.repo_root) From 29f7e8bd0c5f4425bad3ba64900d13302f248fb9 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 08:18:24 +1100 Subject: [PATCH 048/245] Update Test Cases Update tc0018, tc0022, tc0023 and tc0024 --- .../tc0018_recycle_bin_validation.py | 5 ++- .../tc0022_local_first_validation.py | 16 +++++---- ...023_bypass_data_preservation_validation.py | 13 ++++--- .../tc0024_big_delete_safeguard_validation.py | 35 ++++++++++--------- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index 3da8845b9..2b9e9bfff 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -41,6 +41,7 @@ def run(self, context: E2EContext) -> TestResult: sync_root = case_work_dir / "syncroot" conf_seed = case_work_dir / "conf-seed" conf_cleanup = case_work_dir / "conf-cleanup" + conf_remove = case_work_dir / "conf-remove" verify_root = case_work_dir / "verifyroot" conf_verify = case_work_dir / "conf-verify" recycle_bin_root = case_work_dir / "RecycleBin" @@ -53,6 +54,8 @@ def run(self, context: E2EContext) -> TestResult: self._write_seed_config(conf_seed / "config") context.bootstrap_config_dir(conf_cleanup) self._write_cleanup_config(conf_cleanup / "config", recycle_bin_root) + context.bootstrap_config_dir(conf_remove) + self._write_seed_config(conf_remove / "config") context.bootstrap_config_dir(conf_verify) self._write_seed_config(conf_verify / "config") @@ -82,7 +85,7 @@ def run(self, context: E2EContext) -> TestResult: "--syncdir", str(sync_root), "--confdir", - str(conf_seed), + str(conf_remove), ] context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) diff --git a/ci/e2e/testcases/tc0022_local_first_validation.py b/ci/e2e/testcases/tc0022_local_first_validation.py index f5e124d5b..930a97dcc 100644 --- a/ci/e2e/testcases/tc0022_local_first_validation.py +++ b/ci/e2e/testcases/tc0022_local_first_validation.py @@ -4,8 +4,8 @@ from pathlib import Path from framework.base import E2ETestCase -from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest +from framework.context import E2EContext from framework.result import TestResult from framework.utils import command_to_string, reset_directory, run_command, write_text_file @@ -35,8 +35,9 @@ def run(self, context: E2EContext) -> TestResult: remote_update_root = case_work_dir / "remoteupdateroot" verify_root = case_work_dir / "verifyroot" conf_seed = case_work_dir / "conf-seed" - conf_local = case_work_dir / "conf-local" + conf_download = case_work_dir / "conf-download" conf_remote = case_work_dir / "conf-remote" + conf_localfirst = case_work_dir / "conf-localfirst" conf_verify = case_work_dir / "conf-verify" root_name = f"ZZ_E2E_TC0022_{context.run_id}_{os.getpid()}" relative_file = f"{root_name}/conflict.txt" @@ -46,10 +47,12 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(conf_seed) self._write_default_config(conf_seed / "config") - context.bootstrap_config_dir(conf_local) - self._write_local_first_config(conf_local / "config") + context.bootstrap_config_dir(conf_download) + self._write_default_config(conf_download / "config") context.bootstrap_config_dir(conf_remote) self._write_default_config(conf_remote / "config") + context.bootstrap_config_dir(conf_localfirst) + self._write_local_first_config(conf_localfirst / "config") context.bootstrap_config_dir(conf_verify) self._write_default_config(conf_verify / "config") @@ -67,11 +70,12 @@ def run(self, context: E2EContext) -> TestResult: metadata_file = state_dir / "metadata.txt" seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) @@ -83,7 +87,7 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) - final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_localfirst)] final_result = run_command(final_command, cwd=context.repo_root) write_text_file(final_stdout, final_result.stdout) write_text_file(final_stderr, final_result.stderr) diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py index 5ccc29082..787eb3d8b 100644 --- a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -33,8 +33,9 @@ def run(self, context: E2EContext) -> TestResult: local_root = case_work_dir / "localroot" remote_update_root = case_work_dir / "remoteupdateroot" conf_seed = case_work_dir / "conf-seed" - conf_local = case_work_dir / "conf-local" + conf_download = case_work_dir / "conf-download" conf_remote = case_work_dir / "conf-remote" + conf_bypass = case_work_dir / "conf-bypass" root_name = f"ZZ_E2E_TC0023_{context.run_id}_{os.getpid()}" relative_file = f"{root_name}/conflict.txt" @@ -43,10 +44,12 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(conf_seed) self._write_default_config(conf_seed / "config") - context.bootstrap_config_dir(conf_local) - self._write_bypass_config(conf_local / "config") + context.bootstrap_config_dir(conf_download) + self._write_default_config(conf_download / "config") context.bootstrap_config_dir(conf_remote) self._write_default_config(conf_remote / "config") + context.bootstrap_config_dir(conf_bypass) + self._write_bypass_config(conf_bypass / "config") seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" @@ -63,7 +66,7 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) @@ -75,7 +78,7 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) - final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_bypass)] final_result = run_command(final_command, cwd=context.repo_root) write_text_file(final_stdout, final_result.stdout) write_text_file(final_stderr, final_result.stderr) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index a907ca457..90082299e 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -32,7 +32,9 @@ def run(self, context: E2EContext) -> TestResult: local_root = case_work_dir / "localroot" verify_root = case_work_dir / "verifyroot" conf_seed = case_work_dir / "conf-seed" - conf_local = case_work_dir / "conf-local" + conf_download = case_work_dir / "conf-download" + conf_blocked = case_work_dir / "conf-blocked" + conf_forced = case_work_dir / "conf-forced" conf_verify = case_work_dir / "conf-verify" root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" @@ -42,8 +44,12 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(conf_seed) self._write_config(conf_seed / "config") - context.bootstrap_config_dir(conf_local) - self._write_config(conf_local / "config") + context.bootstrap_config_dir(conf_download) + self._write_config(conf_download / "config") + context.bootstrap_config_dir(conf_blocked) + self._write_config(conf_blocked / "config") + context.bootstrap_config_dir(conf_forced) + self._write_config(conf_forced / "config") context.bootstrap_config_dir(conf_verify) self._write_config(conf_verify / "config") @@ -65,29 +71,26 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) - target_delete_path = local_root / root_name / "BigDelete" - if not target_delete_path.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - "Expected BigDelete path was not downloaded before delete phase", - [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(metadata_file)], - {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "root_name": root_name}, - ) + target = local_root / root_name / "BigDelete" + if not target.exists(): + write_text_file(metadata_file, f"case_id={self.case_id}\nroot_name={root_name}\nseed_returncode={seed_result.returncode}\ndownload_returncode={download_result.returncode}\nblocked_returncode=-1\nforced_returncode=-1\nverify_returncode=-1\n") + artifacts = [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(metadata_file)] + details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "root_name": root_name} + return TestResult.fail_result(self.case_id, self.name, "Expected BigDelete path was not downloaded before delete phase", artifacts, details) - shutil.rmtree(target_delete_path) + shutil.rmtree(target) - blocked_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + blocked_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_blocked)] blocked_result = run_command(blocked_command, cwd=context.repo_root) write_text_file(blocked_stdout, blocked_result.stdout) write_text_file(blocked_stderr, blocked_result.stderr) - forced_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--force", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_local)] + forced_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--force", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_forced)] forced_result = run_command(forced_command, cwd=context.repo_root) write_text_file(forced_stdout, forced_result.stdout) write_text_file(forced_stderr, forced_result.stderr) From c99d80613406e5662d621ac085e81eb08389c71a Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 09:32:40 +1100 Subject: [PATCH 049/245] Just run failing cases Just run failing cases --- ci/e2e/run.py | 40 +++++++++---------- .../tc0018_recycle_bin_validation.py | 4 ++ .../tc0022_local_first_validation.py | 10 ++--- ...023_bypass_data_preservation_validation.py | 8 ++-- .../tc0024_big_delete_safeguard_validation.py | 10 ++--- 5 files changed, 38 insertions(+), 34 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index dc4398d19..5518b25d8 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -42,27 +42,27 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - TestCase0001BasicResync(), - TestCase0002SyncListValidation(), - TestCase0003DryRunValidation(), - TestCase0004SingleDirectorySync(), - TestCase0005ForceSyncOverride(), - TestCase0006DownloadOnly(), - TestCase0007DownloadOnlyCleanupLocalFiles(), - TestCase0008UploadOnly(), - TestCase0009UploadOnlyNoRemoteDelete(), - TestCase0010UploadOnlyRemoveSourceFiles(), - TestCase0011SkipFileValidation(), - TestCase0012SkipDirValidation(), - TestCase0013SkipDotfilesValidation(), - TestCase0014SkipSizeValidation(), - TestCase0015SkipSymlinksValidation(), - TestCase0016CheckNosyncValidation(), - TestCase0017CheckNomountValidation(), + #TestCase0001BasicResync(), + #TestCase0002SyncListValidation(), + #TestCase0003DryRunValidation(), + #TestCase0004SingleDirectorySync(), + #TestCase0005ForceSyncOverride(), + #TestCase0006DownloadOnly(), + #TestCase0007DownloadOnlyCleanupLocalFiles(), + #TestCase0008UploadOnly(), + #TestCase0009UploadOnlyNoRemoteDelete(), + #TestCase0010UploadOnlyRemoveSourceFiles(), + #TestCase0011SkipFileValidation(), + #TestCase0012SkipDirValidation(), + #TestCase0013SkipDotfilesValidation(), + #TestCase0014SkipSizeValidation(), + #TestCase0015SkipSymlinksValidation(), + #TestCase0016CheckNosyncValidation(), + #TestCase0017CheckNomountValidation(), TestCase0018RecycleBinValidation(), - TestCase0019LoggingAndRunningConfig(), - TestCase0020MonitorModeValidation(), - TestCase0021ResumableTransfersValidation(), + #TestCase0019LoggingAndRunningConfig(), + #TestCase0020MonitorModeValidation(), + #TestCase0021ResumableTransfersValidation(), TestCase0022LocalFirstValidation(), TestCase0023BypassDataPreservationValidation(), TestCase0024BigDeleteSafeguardValidation(), diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index 2b9e9bfff..a2ca61d47 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -78,6 +78,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--upload-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -96,6 +97,7 @@ def run(self, context: E2EContext) -> TestResult: context.onedrive_bin, "--display-running-config", "--verbose", + "--verbose", "--remove-directory", f"{root_name}/OldData", "--syncdir", @@ -112,6 +114,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--download-only", "--cleanup-local-files", "--single-directory", @@ -130,6 +133,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--download-only", "--resync", "--resync-auth", diff --git a/ci/e2e/testcases/tc0022_local_first_validation.py b/ci/e2e/testcases/tc0022_local_first_validation.py index 930a97dcc..362ed8407 100644 --- a/ci/e2e/testcases/tc0022_local_first_validation.py +++ b/ci/e2e/testcases/tc0022_local_first_validation.py @@ -69,30 +69,30 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) write_text_file(local_root / relative_file, "local wins because local_first is enabled\n") - remote_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] + remote_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] remote_result = run_command(remote_command, cwd=context.repo_root) write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) - final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_localfirst)] + final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_localfirst)] final_result = run_command(final_command, cwd=context.repo_root) write_text_file(final_stdout, final_result.stdout) write_text_file(final_stderr, final_result.stderr) - verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(verify_root), "--confdir", str(conf_verify)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(verify_root), "--confdir", str(conf_verify)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py index 787eb3d8b..b411f126b 100644 --- a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -61,24 +61,24 @@ def run(self, context: E2EContext) -> TestResult: final_stderr = case_log_dir / "final_sync_stderr.log" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) write_text_file(local_root / relative_file, "local conflicting content\n") - remote_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] + remote_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] remote_result = run_command(remote_command, cwd=context.repo_root) write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) - final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_bypass)] + final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_bypass)] final_result = run_command(final_command, cwd=context.repo_root) write_text_file(final_stdout, final_result.stdout) write_text_file(final_stderr, final_result.stderr) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index 90082299e..dc4bfd08f 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -66,12 +66,12 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] + download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) @@ -85,17 +85,17 @@ def run(self, context: E2EContext) -> TestResult: shutil.rmtree(target) - blocked_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_blocked)] + blocked_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_blocked)] blocked_result = run_command(blocked_command, cwd=context.repo_root) write_text_file(blocked_stdout, blocked_result.stdout) write_text_file(blocked_stderr, blocked_result.stderr) - forced_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--force", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_forced)] + forced_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--force", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_forced)] forced_result = run_command(forced_command, cwd=context.repo_root) write_text_file(forced_stdout, forced_result.stdout) write_text_file(forced_stderr, forced_result.stderr) - verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(verify_root), "--confdir", str(conf_verify)] + verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(verify_root), "--confdir", str(conf_verify)] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) From b6579cecdd36e1c7789831d173223b532c9af29c Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 09:48:52 +1100 Subject: [PATCH 050/245] Update tc0018_recycle_bin_validation.py * Update tc0018 --- ci/e2e/testcases/tc0018_recycle_bin_validation.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index a2ca61d47..e3d1e3823 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -83,8 +83,6 @@ def run(self, context: E2EContext) -> TestResult: "--resync-auth", "--single-directory", root_name, - "--syncdir", - str(sync_root), "--confdir", str(conf_remove), ] @@ -100,8 +98,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--remove-directory", f"{root_name}/OldData", - "--syncdir", - str(sync_root), "--confdir", str(conf_seed), ] @@ -119,8 +115,6 @@ def run(self, context: E2EContext) -> TestResult: "--cleanup-local-files", "--single-directory", root_name, - "--syncdir", - str(sync_root), "--confdir", str(conf_cleanup), ] @@ -139,8 +133,6 @@ def run(self, context: E2EContext) -> TestResult: "--resync-auth", "--single-directory", root_name, - "--syncdir", - str(verify_root), "--confdir", str(conf_verify), ] From 8e957249728a544aed6c46bef28b923c163883ab Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 10:14:23 +1100 Subject: [PATCH 051/245] Update tc0018_recycle_bin_validation.py * Fix tc0018 --- .../tc0018_recycle_bin_validation.py | 132 +++++++++++++++--- 1 file changed, 112 insertions(+), 20 deletions(-) diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index e3d1e3823..c1c063d73 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -15,13 +15,19 @@ class TestCase0018RecycleBinValidation(E2ETestCase): name = "recycle bin validation" description = "Validate that online deletions are moved into a FreeDesktop-compliant recycle bin when enabled" - def _write_seed_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0018 seed config\n" 'bypass_data_preservation = "true"\n') + def _write_seed_config(self, config_path: Path, sync_dir: Path) -> None: + write_text_file( + config_path, + "# tc0018 seed config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n', + ) - def _write_cleanup_config(self, config_path: Path, recycle_bin_path: Path) -> None: + def _write_cleanup_config(self, config_path: Path, sync_dir: Path, recycle_bin_path: Path) -> None: write_text_file( config_path, "# tc0018 cleanup config\n" + f'sync_dir = "{sync_dir}"\n' 'bypass_data_preservation = "true"\n' 'cleanup_local_files = "true"\n' 'download_only = "true"\n' @@ -33,6 +39,7 @@ def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0018" case_log_dir = context.logs_dir / "tc0018" state_dir = context.state_dir / "tc0018" + reset_directory(case_work_dir) reset_directory(case_log_dir) reset_directory(state_dir) @@ -47,17 +54,24 @@ def run(self, context: E2EContext) -> TestResult: recycle_bin_root = case_work_dir / "RecycleBin" root_name = f"ZZ_E2E_TC0018_{context.run_id}_{os.getpid()}" + reset_directory(sync_root) + reset_directory(verify_root) + reset_directory(recycle_bin_root) + write_text_file(sync_root / root_name / "Keep" / "keep.txt", "keep\n") write_text_file(sync_root / root_name / "OldData" / "old.txt", "old\n") context.bootstrap_config_dir(conf_seed) - self._write_seed_config(conf_seed / "config") + self._write_seed_config(conf_seed / "config", sync_root) + context.bootstrap_config_dir(conf_cleanup) - self._write_cleanup_config(conf_cleanup / "config", recycle_bin_root) + self._write_cleanup_config(conf_cleanup / "config", sync_root, recycle_bin_root) + context.bootstrap_config_dir(conf_remove) - self._write_seed_config(conf_remove / "config") + self._write_seed_config(conf_remove / "config", sync_root) + context.bootstrap_config_dir(conf_verify) - self._write_seed_config(conf_verify / "config") + self._write_seed_config(conf_verify / "config", verify_root) seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" @@ -84,7 +98,7 @@ def run(self, context: E2EContext) -> TestResult: "--single-directory", root_name, "--confdir", - str(conf_remove), + str(conf_seed), ] context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) @@ -99,8 +113,9 @@ def run(self, context: E2EContext) -> TestResult: "--remove-directory", f"{root_name}/OldData", "--confdir", - str(conf_seed), + str(conf_remove), ] + context.log(f"Executing Test Case {self.case_id} remove: {command_to_string(remove_command)}") remove_result = run_command(remove_command, cwd=context.repo_root) write_text_file(remove_stdout, remove_result.stdout) write_text_file(remove_stderr, remove_result.stderr) @@ -118,6 +133,7 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_cleanup), ] + context.log(f"Executing Test Case {self.case_id} cleanup: {command_to_string(cleanup_command)}") cleanup_result = run_command(cleanup_command, cwd=context.repo_root) write_text_file(cleanup_stdout, cleanup_result.stdout) write_text_file(cleanup_stderr, cleanup_result.stderr) @@ -136,6 +152,7 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_verify), ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) @@ -143,6 +160,7 @@ def run(self, context: E2EContext) -> TestResult: recycle_manifest = build_manifest(recycle_bin_root) remote_manifest = build_manifest(verify_root) local_manifest = build_manifest(sync_root) + write_manifest(recycle_manifest_file, recycle_manifest) write_manifest(remote_manifest_file, remote_manifest) write_manifest(local_manifest_file, local_manifest) @@ -153,6 +171,13 @@ def run(self, context: E2EContext) -> TestResult: [ f"case_id={self.case_id}", f"root_name={root_name}", + f"sync_root={sync_root}", + f"verify_root={verify_root}", + f"recycle_bin_root={recycle_bin_root}", + f"seed_confdir={conf_seed}", + f"remove_confdir={conf_remove}", + f"cleanup_confdir={conf_cleanup}", + f"verify_confdir={conf_verify}", f"seed_returncode={seed_result.returncode}", f"remove_returncode={remove_result.returncode}", f"cleanup_returncode={cleanup_result.returncode}", @@ -185,29 +210,96 @@ def run(self, context: E2EContext) -> TestResult: } if seed_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote seed failed with status {seed_result.returncode}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"Remote seed failed with status {seed_result.returncode}", + artifacts, + details, + ) + if remove_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Online directory removal failed with status {remove_result.returncode}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"Online directory removal failed with status {remove_result.returncode}", + artifacts, + details, + ) + if cleanup_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Recycle bin cleanup sync failed with status {cleanup_result.returncode}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"Recycle bin cleanup sync failed with status {cleanup_result.returncode}", + artifacts, + details, + ) + if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"Remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) if (sync_root / root_name / "OldData").exists(): - return TestResult.fail_result(self.case_id, self.name, "OldData still exists locally after online deletion cleanup", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "OldData still exists locally after online deletion cleanup", + artifacts, + details, + ) + if not (sync_root / root_name / "Keep" / "keep.txt").is_file(): - return TestResult.fail_result(self.case_id, self.name, "Keep file is missing locally after recycle bin processing", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Keep file is missing locally after recycle bin processing", + artifacts, + details, + ) recycle_has_file = any(path.endswith("old.txt") for path in recycle_manifest) recycle_has_info = any(path.endswith(".trashinfo") for path in recycle_manifest) + if not recycle_has_file: - return TestResult.fail_result(self.case_id, self.name, "Deleted content was not moved into the configured recycle bin", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Deleted content was not moved into the configured recycle bin", + artifacts, + details, + ) + if not recycle_has_info: - return TestResult.fail_result(self.case_id, self.name, "Recycle bin metadata .trashinfo file was not created", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Recycle bin metadata .trashinfo file was not created", + artifacts, + details, + ) if f"{root_name}/Keep/keep.txt" not in remote_manifest: - return TestResult.fail_result(self.case_id, self.name, "Keep file is missing online after recycle bin processing", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Keep file is missing online after recycle bin processing", + artifacts, + details, + ) + if any(entry == f"{root_name}/OldData" or entry.startswith(f"{root_name}/OldData/") for entry in remote_manifest): - return TestResult.fail_result(self.case_id, self.name, "OldData still exists online after explicit online removal", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "OldData still exists online after explicit online removal", + artifacts, + details, + ) - return TestResult.pass_result(self.case_id, self.name, artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From dfdff07e3ad89fe4e370a9a8516d8da24e43219b Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 10:24:05 +1100 Subject: [PATCH 052/245] Update tc0018_recycle_bin_validation.py * Fix tc0018 --- .../tc0018_recycle_bin_validation.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index c1c063d73..09586388d 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -15,12 +15,11 @@ class TestCase0018RecycleBinValidation(E2ETestCase): name = "recycle bin validation" description = "Validate that online deletions are moved into a FreeDesktop-compliant recycle bin when enabled" - def _write_seed_config(self, config_path: Path, sync_dir: Path) -> None: + def _write_base_config(self, config_path: Path, sync_dir: Path) -> None: write_text_file( config_path, - "# tc0018 seed config\n" - f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n', + "# tc0018 base config\n" + f'sync_dir = "{sync_dir}"\n', ) def _write_cleanup_config(self, config_path: Path, sync_dir: Path, recycle_bin_path: Path) -> None: @@ -28,7 +27,6 @@ def _write_cleanup_config(self, config_path: Path, sync_dir: Path, recycle_bin_p config_path, "# tc0018 cleanup config\n" f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n' 'cleanup_local_files = "true"\n' 'download_only = "true"\n' 'use_recycle_bin = "true"\n' @@ -46,32 +44,36 @@ def run(self, context: E2EContext) -> TestResult: context.ensure_refresh_token_available() sync_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + recycle_bin_root = case_work_dir / "RecycleBin" + conf_seed = case_work_dir / "conf-seed" - conf_cleanup = case_work_dir / "conf-cleanup" conf_remove = case_work_dir / "conf-remove" - verify_root = case_work_dir / "verifyroot" + conf_cleanup = case_work_dir / "conf-cleanup" conf_verify = case_work_dir / "conf-verify" - recycle_bin_root = case_work_dir / "RecycleBin" + root_name = f"ZZ_E2E_TC0018_{context.run_id}_{os.getpid()}" reset_directory(sync_root) reset_directory(verify_root) reset_directory(recycle_bin_root) + # Seed local content write_text_file(sync_root / root_name / "Keep" / "keep.txt", "keep\n") write_text_file(sync_root / root_name / "OldData" / "old.txt", "old\n") + # Per-phase configs context.bootstrap_config_dir(conf_seed) - self._write_seed_config(conf_seed / "config", sync_root) + self._write_base_config(conf_seed / "config", sync_root) + + context.bootstrap_config_dir(conf_remove) + self._write_base_config(conf_remove / "config", sync_root) context.bootstrap_config_dir(conf_cleanup) self._write_cleanup_config(conf_cleanup / "config", sync_root, recycle_bin_root) - context.bootstrap_config_dir(conf_remove) - self._write_seed_config(conf_remove / "config", sync_root) - context.bootstrap_config_dir(conf_verify) - self._write_seed_config(conf_verify / "config", verify_root) + self._write_base_config(conf_verify / "config", verify_root) seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" @@ -81,6 +83,7 @@ def run(self, context: E2EContext) -> TestResult: cleanup_stderr = case_log_dir / "cleanup_stderr.log" verify_stdout = case_log_dir / "verify_stdout.log" verify_stderr = case_log_dir / "verify_stderr.log" + recycle_manifest_file = state_dir / "recycle_manifest.txt" remote_manifest_file = state_dir / "remote_verify_manifest.txt" local_manifest_file = state_dir / "local_manifest_after_cleanup.txt" @@ -245,6 +248,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) + # Local result checks if (sync_root / root_name / "OldData").exists(): return TestResult.fail_result( self.case_id, @@ -263,6 +267,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) + # Recycle bin result checks recycle_has_file = any(path.endswith("old.txt") for path in recycle_manifest) recycle_has_info = any(path.endswith(".trashinfo") for path in recycle_manifest) @@ -284,6 +289,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) + # Remote result checks if f"{root_name}/Keep/keep.txt" not in remote_manifest: return TestResult.fail_result( self.case_id, From 76724b19cbbdbf22751805352da9327d15f7fa12 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 10:55:54 +1100 Subject: [PATCH 053/245] Update tc0018 Update tc0018 --- ci/e2e/run.py | 6 +-- .../tc0018_recycle_bin_validation.py | 52 +++++++++---------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 5518b25d8..9ec023bfd 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -63,9 +63,9 @@ def build_test_suite() -> list: #TestCase0019LoggingAndRunningConfig(), #TestCase0020MonitorModeValidation(), #TestCase0021ResumableTransfersValidation(), - TestCase0022LocalFirstValidation(), - TestCase0023BypassDataPreservationValidation(), - TestCase0024BigDeleteSafeguardValidation(), + #TestCase0022LocalFirstValidation(), + #TestCase0023BypassDataPreservationValidation(), + #TestCase0024BigDeleteSafeguardValidation(), ] diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index 09586388d..56445380f 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -15,17 +15,17 @@ class TestCase0018RecycleBinValidation(E2ETestCase): name = "recycle bin validation" description = "Validate that online deletions are moved into a FreeDesktop-compliant recycle bin when enabled" - def _write_base_config(self, config_path: Path, sync_dir: Path) -> None: + def _write_runtime_base_config(self, config_path: Path, sync_dir: Path) -> None: write_text_file( config_path, - "# tc0018 base config\n" + "# tc0018 runtime base config\n" f'sync_dir = "{sync_dir}"\n', ) - def _write_cleanup_config(self, config_path: Path, sync_dir: Path, recycle_bin_path: Path) -> None: + def _write_runtime_cleanup_config(self, config_path: Path, sync_dir: Path, recycle_bin_path: Path) -> None: write_text_file( config_path, - "# tc0018 cleanup config\n" + "# tc0018 runtime cleanup config\n" f'sync_dir = "{sync_dir}"\n' 'cleanup_local_files = "true"\n' 'download_only = "true"\n' @@ -33,6 +33,13 @@ def _write_cleanup_config(self, config_path: Path, sync_dir: Path, recycle_bin_p f'recycle_bin_path = "{recycle_bin_path}"\n', ) + def _write_verify_config(self, config_path: Path, sync_dir: Path) -> None: + write_text_file( + config_path, + "# tc0018 verify config\n" + f'sync_dir = "{sync_dir}"\n', + ) + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0018" case_log_dir = context.logs_dir / "tc0018" @@ -47,9 +54,7 @@ def run(self, context: E2EContext) -> TestResult: verify_root = case_work_dir / "verifyroot" recycle_bin_root = case_work_dir / "RecycleBin" - conf_seed = case_work_dir / "conf-seed" - conf_remove = case_work_dir / "conf-remove" - conf_cleanup = case_work_dir / "conf-cleanup" + conf_runtime = case_work_dir / "conf-runtime" conf_verify = case_work_dir / "conf-verify" root_name = f"ZZ_E2E_TC0018_{context.run_id}_{os.getpid()}" @@ -58,22 +63,17 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(verify_root) reset_directory(recycle_bin_root) - # Seed local content + # Create initial local content to seed remotely write_text_file(sync_root / root_name / "Keep" / "keep.txt", "keep\n") write_text_file(sync_root / root_name / "OldData" / "old.txt", "old\n") - # Per-phase configs - context.bootstrap_config_dir(conf_seed) - self._write_base_config(conf_seed / "config", sync_root) - - context.bootstrap_config_dir(conf_remove) - self._write_base_config(conf_remove / "config", sync_root) - - context.bootstrap_config_dir(conf_cleanup) - self._write_cleanup_config(conf_cleanup / "config", sync_root, recycle_bin_root) + # Shared runtime config for seed -> remove -> cleanup + context.bootstrap_config_dir(conf_runtime) + self._write_runtime_base_config(conf_runtime / "config", sync_root) + # Fresh verify config for clean remote validation context.bootstrap_config_dir(conf_verify) - self._write_base_config(conf_verify / "config", verify_root) + self._write_verify_config(conf_verify / "config", verify_root) seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" @@ -101,7 +101,7 @@ def run(self, context: E2EContext) -> TestResult: "--single-directory", root_name, "--confdir", - str(conf_seed), + str(conf_runtime), ] context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) @@ -116,13 +116,16 @@ def run(self, context: E2EContext) -> TestResult: "--remove-directory", f"{root_name}/OldData", "--confdir", - str(conf_remove), + str(conf_runtime), ] context.log(f"Executing Test Case {self.case_id} remove: {command_to_string(remove_command)}") remove_result = run_command(remove_command, cwd=context.repo_root) write_text_file(remove_stdout, remove_result.stdout) write_text_file(remove_stderr, remove_result.stderr) + # Rewrite the same runtime config so cleanup reuses the same DB / delta state + self._write_runtime_cleanup_config(conf_runtime / "config", sync_root, recycle_bin_root) + cleanup_command = [ context.onedrive_bin, "--display-running-config", @@ -134,7 +137,7 @@ def run(self, context: E2EContext) -> TestResult: "--single-directory", root_name, "--confdir", - str(conf_cleanup), + str(conf_runtime), ] context.log(f"Executing Test Case {self.case_id} cleanup: {command_to_string(cleanup_command)}") cleanup_result = run_command(cleanup_command, cwd=context.repo_root) @@ -177,9 +180,7 @@ def run(self, context: E2EContext) -> TestResult: f"sync_root={sync_root}", f"verify_root={verify_root}", f"recycle_bin_root={recycle_bin_root}", - f"seed_confdir={conf_seed}", - f"remove_confdir={conf_remove}", - f"cleanup_confdir={conf_cleanup}", + f"runtime_confdir={conf_runtime}", f"verify_confdir={conf_verify}", f"seed_returncode={seed_result.returncode}", f"remove_returncode={remove_result.returncode}", @@ -248,7 +249,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Local result checks if (sync_root / root_name / "OldData").exists(): return TestResult.fail_result( self.case_id, @@ -267,7 +267,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Recycle bin result checks recycle_has_file = any(path.endswith("old.txt") for path in recycle_manifest) recycle_has_info = any(path.endswith(".trashinfo") for path in recycle_manifest) @@ -289,7 +288,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Remote result checks if f"{root_name}/Keep/keep.txt" not in remote_manifest: return TestResult.fail_result( self.case_id, From 445b5d8185288dfb1157320c3bdcd52fa0b3f23f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 11:32:57 +1100 Subject: [PATCH 054/245] tc0018 passed, moving on to tc0022 * tc0018 passed, moving on to tc0022 --- ci/e2e/run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 9ec023bfd..fd2ceb812 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -59,11 +59,11 @@ def build_test_suite() -> list: #TestCase0015SkipSymlinksValidation(), #TestCase0016CheckNosyncValidation(), #TestCase0017CheckNomountValidation(), - TestCase0018RecycleBinValidation(), + #TestCase0018RecycleBinValidation(), #TestCase0019LoggingAndRunningConfig(), #TestCase0020MonitorModeValidation(), #TestCase0021ResumableTransfersValidation(), - #TestCase0022LocalFirstValidation(), + TestCase0022LocalFirstValidation(), #TestCase0023BypassDataPreservationValidation(), #TestCase0024BigDeleteSafeguardValidation(), ] From 3c33dd74be1ba440d815f819bf6676da7a2a2cc8 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 11:41:39 +1100 Subject: [PATCH 055/245] Update tc0022 Update tc0022 --- .../tc0018_recycle_bin_validation.py | 4 - .../tc0022_local_first_validation.py | 207 +++++++++++++++--- 2 files changed, 180 insertions(+), 31 deletions(-) diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index 56445380f..169e14300 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -95,7 +95,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--upload-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -112,7 +111,6 @@ def run(self, context: E2EContext) -> TestResult: context.onedrive_bin, "--display-running-config", "--verbose", - "--verbose", "--remove-directory", f"{root_name}/OldData", "--confdir", @@ -131,7 +129,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--cleanup-local-files", "--single-directory", @@ -149,7 +146,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--resync", "--resync-auth", diff --git a/ci/e2e/testcases/tc0022_local_first_validation.py b/ci/e2e/testcases/tc0022_local_first_validation.py index 362ed8407..7a36269fa 100644 --- a/ci/e2e/testcases/tc0022_local_first_validation.py +++ b/ci/e2e/testcases/tc0022_local_first_validation.py @@ -15,16 +15,21 @@ class TestCase0022LocalFirstValidation(E2ETestCase): name = "local_first validation" description = "Validate that local_first treats local content as the source of truth during a conflict" - def _write_default_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0022 config\n" 'bypass_data_preservation = "true"\n') - - def _write_local_first_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0022 local first config\n" 'bypass_data_preservation = "true"\n' 'local_first = "true"\n') + def _write_config(self, config_path: Path, sync_dir: Path, local_first: bool = False) -> None: + content = ( + "# tc0022 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) + if local_first: + content += 'local_first = "true"\n' + write_text_file(config_path, content) def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0022" case_log_dir = context.logs_dir / "tc0022" state_dir = context.state_dir / "tc0022" + reset_directory(case_work_dir) reset_directory(case_log_dir) reset_directory(state_dir) @@ -34,27 +39,34 @@ def run(self, context: E2EContext) -> TestResult: local_root = case_work_dir / "localroot" remote_update_root = case_work_dir / "remoteupdateroot" verify_root = case_work_dir / "verifyroot" + conf_seed = case_work_dir / "conf-seed" - conf_download = case_work_dir / "conf-download" + conf_local = case_work_dir / "conf-local" conf_remote = case_work_dir / "conf-remote" - conf_localfirst = case_work_dir / "conf-localfirst" conf_verify = case_work_dir / "conf-verify" + root_name = f"ZZ_E2E_TC0022_{context.run_id}_{os.getpid()}" relative_file = f"{root_name}/conflict.txt" + reset_directory(seed_root) + reset_directory(local_root) + reset_directory(remote_update_root) + reset_directory(verify_root) + write_text_file(seed_root / relative_file, "base\n") write_text_file(remote_update_root / relative_file, "remote wins unless local_first applies\n") context.bootstrap_config_dir(conf_seed) - self._write_default_config(conf_seed / "config") - context.bootstrap_config_dir(conf_download) - self._write_default_config(conf_download / "config") + self._write_config(conf_seed / "config", seed_root) + + context.bootstrap_config_dir(conf_local) + self._write_config(conf_local / "config", local_root) + context.bootstrap_config_dir(conf_remote) - self._write_default_config(conf_remote / "config") - context.bootstrap_config_dir(conf_localfirst) - self._write_local_first_config(conf_localfirst / "config") + self._write_config(conf_remote / "config", remote_update_root) + context.bootstrap_config_dir(conf_verify) - self._write_default_config(conf_verify / "config") + self._write_config(conf_verify / "config", verify_root) seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" @@ -69,51 +81,192 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--upload-only", + "--verbose", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] + download_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_local), + ] + context.log(f"Executing Test Case {self.case_id} download: {command_to_string(download_command)}") download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) write_text_file(local_root / relative_file, "local wins because local_first is enabled\n") - remote_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] + remote_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--upload-only", + "--verbose", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_remote), + ] + context.log(f"Executing Test Case {self.case_id} remote update: {command_to_string(remote_command)}") remote_result = run_command(remote_command, cwd=context.repo_root) write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) - final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_localfirst)] + # Reuse the same local-side state, but enable local_first for the conflict resolution phase + self._write_config(conf_local / "config", local_root, local_first=True) + + final_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_local), + ] + context.log(f"Executing Test Case {self.case_id} final sync: {command_to_string(final_command)}") final_result = run_command(final_command, cwd=context.repo_root) write_text_file(final_stdout, final_result.stdout) write_text_file(final_stderr, final_result.stderr) - verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(verify_root), "--confdir", str(conf_verify)] + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) write_manifest(remote_manifest_file, remote_manifest) local_content = (local_root / relative_file).read_text(encoding="utf-8") if (local_root / relative_file).is_file() else "" remote_content = (verify_root / relative_file).read_text(encoding="utf-8") if (verify_root / relative_file).is_file() else "" - write_text_file(metadata_file, f"case_id={self.case_id}\nroot_name={root_name}\nseed_returncode={seed_result.returncode}\ndownload_returncode={download_result.returncode}\nremote_returncode={remote_result.returncode}\nfinal_returncode={final_result.returncode}\nverify_returncode={verify_result.returncode}\nlocal_content={local_content!r}\nremote_content={remote_content!r}\n") - artifacts = [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(remote_stdout), str(remote_stderr), str(final_stdout), str(final_stderr), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] - details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "remote_returncode": remote_result.returncode, "final_returncode": final_result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"seed_root={seed_root}", + f"local_root={local_root}", + f"remote_update_root={remote_update_root}", + f"verify_root={verify_root}", + f"seed_confdir={conf_seed}", + f"local_confdir={conf_local}", + f"remote_confdir={conf_remote}", + f"verify_confdir={conf_verify}", + f"seed_returncode={seed_result.returncode}", + f"download_returncode={download_result.returncode}", + f"remote_returncode={remote_result.returncode}", + f"final_returncode={final_result.returncode}", + f"verify_returncode={verify_result.returncode}", + f"local_content={local_content!r}", + f"remote_content={remote_content!r}", + ] + ) + + "\n", + ) - for label, rc in [("seed", seed_result.returncode), ("download", download_result.returncode), ("remote update", remote_result.returncode), ("final sync", final_result.returncode), ("verify", verify_result.returncode)]: + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(download_stdout), + str(download_stderr), + str(remote_stdout), + str(remote_stderr), + str(final_stdout), + str(final_stderr), + str(verify_stdout), + str(verify_stderr), + str(remote_manifest_file), + str(metadata_file), + ] + details = { + "seed_returncode": seed_result.returncode, + "download_returncode": download_result.returncode, + "remote_returncode": remote_result.returncode, + "final_returncode": final_result.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + } + + for label, rc in [ + ("seed", seed_result.returncode), + ("download", download_result.returncode), + ("remote update", remote_result.returncode), + ("final sync", final_result.returncode), + ("verify", verify_result.returncode), + ]: if rc != 0: - return TestResult.fail_result(self.case_id, self.name, f"{label} phase failed with status {rc}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} phase failed with status {rc}", + artifacts, + details, + ) expected = "local wins because local_first is enabled\n" + if local_content != expected: - return TestResult.fail_result(self.case_id, self.name, "Local content was not retained after conflict resolution with local_first enabled", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Local content was not retained after conflict resolution with local_first enabled", + artifacts, + details, + ) + if remote_content != expected: - return TestResult.fail_result(self.case_id, self.name, "Remote content did not converge to the local source-of-truth content when local_first was enabled", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Remote content did not converge to the local source-of-truth content when local_first was enabled", + artifacts, + details, + ) - return TestResult.pass_result(self.case_id, self.name, artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 38ef1fcc40010fa0a43713eaafbfe8010f3f38be Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 12:08:51 +1100 Subject: [PATCH 056/245] Update PR - fix tc0022 fix tc0022 --- .../tc0022_local_first_validation.py | 20 +++++++++++++------ docs/end_to_end_testing.md | 8 ++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/ci/e2e/testcases/tc0022_local_first_validation.py b/ci/e2e/testcases/tc0022_local_first_validation.py index 7a36269fa..d428926e0 100644 --- a/ci/e2e/testcases/tc0022_local_first_validation.py +++ b/ci/e2e/testcases/tc0022_local_first_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import time from pathlib import Path from framework.base import E2ETestCase @@ -19,7 +20,6 @@ def _write_config(self, config_path: Path, sync_dir: Path, local_first: bool = F content = ( "# tc0022 config\n" f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n' ) if local_first: content += 'local_first = "true"\n' @@ -119,8 +119,6 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) - write_text_file(local_root / relative_file, "local wins because local_first is enabled\n") - remote_command = [ context.onedrive_bin, "--display-running-config", @@ -140,7 +138,18 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) - # Reuse the same local-side state, but enable local_first for the conflict resolution phase + # Ensure the local edit is definitively later than the remote update. + # This is critical so the final sync actually exercises local_first. + time.sleep(2) + + local_file = local_root / relative_file + expected = "local wins because local_first is enabled\n" + write_text_file(local_file, expected) + + now = time.time() + os.utime(local_file, (now, now)) + + # Reuse the same local DB / delta state, but enable local_first self._write_config(conf_local / "config", local_root, local_first=True) final_command = [ @@ -205,6 +214,7 @@ def run(self, context: E2EContext) -> TestResult: f"verify_returncode={verify_result.returncode}", f"local_content={local_content!r}", f"remote_content={remote_content!r}", + f"local_mtime={local_file.stat().st_mtime if local_file.exists() else 0}", ] ) + "\n", @@ -249,8 +259,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - expected = "local wins because local_first is enabled\n" - if local_content != expected: return TestResult.fail_result( self.case_id, diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 1c13b9a23..14ae454d7 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -2,7 +2,7 @@ [![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) -| Test Case | Description | Details | -|:---|:---|:---| -| 0001 | Basic Resync | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | -| 0002 | 'sync_list' Validation | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| \ No newline at end of file +| Test Case | Description | Account Coverage | Test Details | +|:-------|:-------------|:-------|:-------| +| 0001 | Basic Resync | Personal | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | +| 0002 | 'sync_list' Validation | Personal | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| \ No newline at end of file From 8b26efd83485ccdb36037b6b7e5d13bffc21f3b2 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 12:13:37 +1100 Subject: [PATCH 057/245] tc0022 passed, move to tc0023 tc0022 passed, move to tc0023 --- ci/e2e/run.py | 4 ++-- ci/e2e/testcases/tc0022_local_first_validation.py | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index fd2ceb812..8e8c16646 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -63,8 +63,8 @@ def build_test_suite() -> list: #TestCase0019LoggingAndRunningConfig(), #TestCase0020MonitorModeValidation(), #TestCase0021ResumableTransfersValidation(), - TestCase0022LocalFirstValidation(), - #TestCase0023BypassDataPreservationValidation(), + #TestCase0022LocalFirstValidation(), + TestCase0023BypassDataPreservationValidation(), #TestCase0024BigDeleteSafeguardValidation(), ] diff --git a/ci/e2e/testcases/tc0022_local_first_validation.py b/ci/e2e/testcases/tc0022_local_first_validation.py index d428926e0..928ba6d9a 100644 --- a/ci/e2e/testcases/tc0022_local_first_validation.py +++ b/ci/e2e/testcases/tc0022_local_first_validation.py @@ -87,7 +87,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--upload-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -105,7 +104,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--resync", "--resync-auth", @@ -125,7 +123,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--upload-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -157,7 +154,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", @@ -173,7 +169,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--resync", "--resync-auth", From c461be7d14521fe92e37595983abeafe3e723b9c Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 13:07:09 +1100 Subject: [PATCH 058/245] Update tc0023_bypass_data_preservation_validation.py Fix tc0023 --- ...023_bypass_data_preservation_validation.py | 211 +++++++++++++++--- 1 file changed, 175 insertions(+), 36 deletions(-) diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py index b411f126b..52dceaf49 100644 --- a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -6,24 +6,28 @@ from framework.base import E2ETestCase from framework.context import E2EContext from framework.result import TestResult -from framework.utils import reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_text_file class TestCase0023BypassDataPreservationValidation(E2ETestCase): case_id = "0023" name = "bypass_data_preservation validation" - description = "Validate that bypass_data_preservation overwrites local conflict data instead of creating safeBackup files" + description = "Validate that bypass_data_preservation suppresses safe-backup preservation during conflict resolution" - def _write_default_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0023 config\n" 'bypass_data_preservation = "false"\n') - - def _write_bypass_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0023 bypass config\n" 'bypass_data_preservation = "true"\n') + def _write_config(self, config_path: Path, sync_dir: Path, bypass_data_preservation: bool = False) -> None: + content = ( + "# tc0023 config\n" + f'sync_dir = "{sync_dir}"\n' + ) + if bypass_data_preservation: + content += 'bypass_data_preservation = "true"\n' + write_text_file(config_path, content) def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0023" case_log_dir = context.logs_dir / "tc0023" state_dir = context.state_dir / "tc0023" + reset_directory(case_work_dir) reset_directory(case_log_dir) reset_directory(state_dir) @@ -32,24 +36,34 @@ def run(self, context: E2EContext) -> TestResult: seed_root = case_work_dir / "seedroot" local_root = case_work_dir / "localroot" remote_update_root = case_work_dir / "remoteupdateroot" + conf_seed = case_work_dir / "conf-seed" - conf_download = case_work_dir / "conf-download" + conf_local = case_work_dir / "conf-local" conf_remote = case_work_dir / "conf-remote" - conf_bypass = case_work_dir / "conf-bypass" + root_name = f"ZZ_E2E_TC0023_{context.run_id}_{os.getpid()}" relative_file = f"{root_name}/conflict.txt" + reset_directory(seed_root) + reset_directory(local_root) + reset_directory(remote_update_root) + + # Initial remote seed content write_text_file(seed_root / relative_file, "base\n") - write_text_file(remote_update_root / relative_file, "remote authoritative content\n") + + # Remote content that should overwrite the local conflicting edit when + # bypass_data_preservation is enabled + expected_remote_content = "remote conflicting content\n" + write_text_file(remote_update_root / relative_file, expected_remote_content) context.bootstrap_config_dir(conf_seed) - self._write_default_config(conf_seed / "config") - context.bootstrap_config_dir(conf_download) - self._write_default_config(conf_download / "config") + self._write_config(conf_seed / "config", seed_root) + + context.bootstrap_config_dir(conf_local) + self._write_config(conf_local / "config", local_root) + context.bootstrap_config_dir(conf_remote) - self._write_default_config(conf_remote / "config") - context.bootstrap_config_dir(conf_bypass) - self._write_bypass_config(conf_bypass / "config") + self._write_config(conf_remote / "config", remote_update_root) seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" @@ -61,44 +75,169 @@ def run(self, context: E2EContext) -> TestResult: final_stderr = case_log_dir / "final_sync_stderr.log" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--upload-only", + "--verbose", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] + download_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_local), + ] + context.log(f"Executing Test Case {self.case_id} download: {command_to_string(download_command)}") download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) - write_text_file(local_root / relative_file, "local conflicting content\n") - - remote_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(remote_update_root), "--confdir", str(conf_remote)] + # Create the local conflicting edit before the remote update so the + # remote version becomes the newer winning content. + local_file = local_root / relative_file + write_text_file(local_file, "local conflicting content\n") + + remote_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--upload-only", + "--verbose", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_remote), + ] + context.log(f"Executing Test Case {self.case_id} remote update: {command_to_string(remote_command)}") remote_result = run_command(remote_command, cwd=context.repo_root) write_text_file(remote_stdout, remote_result.stdout) write_text_file(remote_stderr, remote_result.stderr) - final_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_bypass)] + # Reuse the same local DB / delta state, but now enable bypass behaviour + self._write_config(conf_local / "config", local_root, bypass_data_preservation=True) + + final_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_local), + ] + context.log(f"Executing Test Case {self.case_id} final sync: {command_to_string(final_command)}") final_result = run_command(final_command, cwd=context.repo_root) write_text_file(final_stdout, final_result.stdout) write_text_file(final_stderr, final_result.stderr) - local_file = local_root / relative_file local_content = local_file.read_text(encoding="utf-8") if local_file.is_file() else "" - safe_backup_files = [p.name for p in local_file.parent.glob("*safeBackup*")] - write_text_file(metadata_file, f"case_id={self.case_id}\nroot_name={root_name}\nseed_returncode={seed_result.returncode}\ndownload_returncode={download_result.returncode}\nremote_returncode={remote_result.returncode}\nfinal_returncode={final_result.returncode}\nlocal_content={local_content!r}\nsafe_backup_files={safe_backup_files!r}\n") - - artifacts = [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(remote_stdout), str(remote_stderr), str(final_stdout), str(final_stderr), str(metadata_file)] - details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "remote_returncode": remote_result.returncode, "final_returncode": final_result.returncode, "root_name": root_name, "safe_backup_count": len(safe_backup_files)} - for label, rc in [("seed", seed_result.returncode), ("download", download_result.returncode), ("remote update", remote_result.returncode), ("final sync", final_result.returncode)]: + safe_backup_files = sorted( + str(path.relative_to(local_root)) + for path in local_root.rglob("*safeBackup*") + if path.is_file() + ) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"seed_root={seed_root}", + f"local_root={local_root}", + f"remote_update_root={remote_update_root}", + f"seed_confdir={conf_seed}", + f"local_confdir={conf_local}", + f"remote_confdir={conf_remote}", + f"seed_returncode={seed_result.returncode}", + f"download_returncode={download_result.returncode}", + f"remote_returncode={remote_result.returncode}", + f"final_returncode={final_result.returncode}", + f"local_content={local_content!r}", + f"safe_backup_files={safe_backup_files!r}", + ] + ) + + "\n", + ) + + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(download_stdout), + str(download_stderr), + str(remote_stdout), + str(remote_stderr), + str(final_stdout), + str(final_stderr), + str(metadata_file), + ] + details = { + "seed_returncode": seed_result.returncode, + "download_returncode": download_result.returncode, + "remote_returncode": remote_result.returncode, + "final_returncode": final_result.returncode, + "root_name": root_name, + "safe_backup_count": len(safe_backup_files), + } + + for label, rc in [ + ("seed", seed_result.returncode), + ("download", download_result.returncode), + ("remote update", remote_result.returncode), + ("final sync", final_result.returncode), + ]: if rc != 0: - return TestResult.fail_result(self.case_id, self.name, f"{label} phase failed with status {rc}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} phase failed with status {rc}", + artifacts, + details, + ) + + if local_content != expected_remote_content: + return TestResult.fail_result( + self.case_id, + self.name, + "Local content was not overwritten by the remote conflicting content when bypass_data_preservation was enabled", + artifacts, + details, + ) - expected = "remote authoritative content\n" - if local_content != expected: - return TestResult.fail_result(self.case_id, self.name, "Local conflict content was not overwritten by the remote version when bypass_data_preservation was enabled", artifacts, details) if safe_backup_files: - return TestResult.fail_result(self.case_id, self.name, "safeBackup files were created despite bypass_data_preservation being enabled", artifacts, details) - - return TestResult.pass_result(self.case_id, self.name, artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Safe-backup files were created despite bypass_data_preservation being enabled", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 9c7d3b0e95c6a8b58fd83494cddfe9e18ef57082 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 14:50:31 +1100 Subject: [PATCH 059/245] tc0023 passed, testing tc0024 tc0023 passed, testing tc0024 --- ci/e2e/run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 8e8c16646..8308f688a 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -64,8 +64,8 @@ def build_test_suite() -> list: #TestCase0020MonitorModeValidation(), #TestCase0021ResumableTransfersValidation(), #TestCase0022LocalFirstValidation(), - TestCase0023BypassDataPreservationValidation(), - #TestCase0024BigDeleteSafeguardValidation(), + #TestCase0023BypassDataPreservationValidation(), + TestCase0024BigDeleteSafeguardValidation(), ] From ac54ba83e58f151f1f3a1f80b7ada6b825cbaa5b Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 15:45:41 +1100 Subject: [PATCH 060/245] Fix tc0024 Fix tc0024 --- ...023_bypass_data_preservation_validation.py | 4 - .../tc0024_big_delete_safeguard_validation.py | 250 +++++++++++++++--- 2 files changed, 217 insertions(+), 37 deletions(-) diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py index 52dceaf49..c49ac2097 100644 --- a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -81,7 +81,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--upload-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -100,7 +99,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -124,7 +122,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--upload-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -145,7 +142,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index dc4bfd08f..502a85684 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -8,7 +8,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_text_file class TestCase0024BigDeleteSafeguardValidation(E2ETestCase): @@ -16,13 +16,20 @@ class TestCase0024BigDeleteSafeguardValidation(E2ETestCase): name = "big delete safeguard validation" description = "Validate classify_as_big_delete protection and forced acknowledgement via --force" - def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0024 config\n" 'bypass_data_preservation = "true"\n' 'classify_as_big_delete = "3"\n') + def _write_config(self, config_path: Path, sync_dir: Path) -> None: + write_text_file( + config_path, + "# tc0024 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + 'classify_as_big_delete = "3"\n', + ) def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0024" case_log_dir = context.logs_dir / "tc0024" state_dir = context.state_dir / "tc0024" + reset_directory(case_work_dir) reset_directory(case_log_dir) reset_directory(state_dir) @@ -31,27 +38,29 @@ def run(self, context: E2EContext) -> TestResult: seed_root = case_work_dir / "seedroot" local_root = case_work_dir / "localroot" verify_root = case_work_dir / "verifyroot" + conf_seed = case_work_dir / "conf-seed" - conf_download = case_work_dir / "conf-download" - conf_blocked = case_work_dir / "conf-blocked" - conf_forced = case_work_dir / "conf-forced" + conf_local = case_work_dir / "conf-local" conf_verify = case_work_dir / "conf-verify" + root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" + reset_directory(seed_root) + reset_directory(local_root) + reset_directory(verify_root) + for idx in range(1, 6): write_text_file(seed_root / root_name / "BigDelete" / f"file{idx}.txt", f"file {idx}\n") write_text_file(seed_root / root_name / "Keep" / "keep.txt", "keep\n") context.bootstrap_config_dir(conf_seed) - self._write_config(conf_seed / "config") - context.bootstrap_config_dir(conf_download) - self._write_config(conf_download / "config") - context.bootstrap_config_dir(conf_blocked) - self._write_config(conf_blocked / "config") - context.bootstrap_config_dir(conf_forced) - self._write_config(conf_forced / "config") + self._write_config(conf_seed / "config", seed_root) + + context.bootstrap_config_dir(conf_local) + self._write_config(conf_local / "config", local_root) + context.bootstrap_config_dir(conf_verify) - self._write_config(conf_verify / "config") + self._write_config(conf_verify / "config", verify_root) seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" @@ -66,59 +75,234 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" - seed_command = [context.onedrive_bin, "--display-running-config", "--sync", "--upload-only", "--verbose", "--verbose", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(seed_root), "--confdir", str(conf_seed)] + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--upload-only", + "--verbose", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - download_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_download)] + download_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_local), + ] + context.log(f"Executing Test Case {self.case_id} download: {command_to_string(download_command)}") download_result = run_command(download_command, cwd=context.repo_root) write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) target = local_root / root_name / "BigDelete" if not target.exists(): - write_text_file(metadata_file, f"case_id={self.case_id}\nroot_name={root_name}\nseed_returncode={seed_result.returncode}\ndownload_returncode={download_result.returncode}\nblocked_returncode=-1\nforced_returncode=-1\nverify_returncode=-1\n") - artifacts = [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(metadata_file)] - details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "root_name": root_name} - return TestResult.fail_result(self.case_id, self.name, "Expected BigDelete path was not downloaded before delete phase", artifacts, details) + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"seed_returncode={seed_result.returncode}", + f"download_returncode={download_result.returncode}", + "blocked_returncode=-1", + "forced_returncode=-1", + "verify_returncode=-1", + ] + ) + + "\n", + ) + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(download_stdout), + str(download_stderr), + str(metadata_file), + ] + details = { + "seed_returncode": seed_result.returncode, + "download_returncode": download_result.returncode, + "root_name": root_name, + } + return TestResult.fail_result( + self.case_id, + self.name, + "Expected BigDelete path was not downloaded before delete phase", + artifacts, + details, + ) shutil.rmtree(target) - blocked_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_blocked)] + blocked_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_local), + ] + context.log(f"Executing Test Case {self.case_id} blocked sync: {command_to_string(blocked_command)}") blocked_result = run_command(blocked_command, cwd=context.repo_root) write_text_file(blocked_stdout, blocked_result.stdout) write_text_file(blocked_stderr, blocked_result.stderr) - forced_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--force", "--single-directory", root_name, "--syncdir", str(local_root), "--confdir", str(conf_forced)] + forced_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--force", + "--single-directory", + root_name, + "--confdir", + str(conf_local), + ] + context.log(f"Executing Test Case {self.case_id} forced sync: {command_to_string(forced_command)}") forced_result = run_command(forced_command, cwd=context.repo_root) write_text_file(forced_stdout, forced_result.stdout) write_text_file(forced_stderr, forced_result.stderr) - verify_command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", "--download-only", "--resync", "--resync-auth", "--single-directory", root_name, "--syncdir", str(verify_root), "--confdir", str(conf_verify)] + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) write_manifest(remote_manifest_file, remote_manifest) blocked_output = (blocked_result.stdout + "\n" + blocked_result.stderr).lower() - write_text_file(metadata_file, f"case_id={self.case_id}\nroot_name={root_name}\nseed_returncode={seed_result.returncode}\ndownload_returncode={download_result.returncode}\nblocked_returncode={blocked_result.returncode}\nforced_returncode={forced_result.returncode}\nverify_returncode={verify_result.returncode}\n") - artifacts = [str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), str(blocked_stdout), str(blocked_stderr), str(forced_stdout), str(forced_stderr), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] - details = {"seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "blocked_returncode": blocked_result.returncode, "forced_returncode": forced_result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name} + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"seed_root={seed_root}", + f"local_root={local_root}", + f"verify_root={verify_root}", + f"seed_confdir={conf_seed}", + f"local_confdir={conf_local}", + f"verify_confdir={conf_verify}", + f"seed_returncode={seed_result.returncode}", + f"download_returncode={download_result.returncode}", + f"blocked_returncode={blocked_result.returncode}", + f"forced_returncode={forced_result.returncode}", + f"verify_returncode={verify_result.returncode}", + ] + ) + + "\n", + ) + + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(download_stdout), + str(download_stderr), + str(blocked_stdout), + str(blocked_stderr), + str(forced_stdout), + str(forced_stderr), + str(verify_stdout), + str(verify_stderr), + str(remote_manifest_file), + str(metadata_file), + ] + details = { + "seed_returncode": seed_result.returncode, + "download_returncode": download_result.returncode, + "blocked_returncode": blocked_result.returncode, + "forced_returncode": forced_result.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + } - for label, rc in [("seed", seed_result.returncode), ("download", download_result.returncode), ("forced sync", forced_result.returncode), ("verify", verify_result.returncode)]: + for label, rc in [ + ("seed", seed_result.returncode), + ("download", download_result.returncode), + ("forced sync", forced_result.returncode), + ("verify", verify_result.returncode), + ]: if rc != 0: - return TestResult.fail_result(self.case_id, self.name, f"{label} phase failed with status {rc}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} phase failed with status {rc}", + artifacts, + details, + ) if blocked_result.returncode == 0 and "big delete" not in blocked_output: - return TestResult.fail_result(self.case_id, self.name, "Big delete safeguard did not trigger before forced acknowledgement", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Big delete safeguard did not trigger before forced acknowledgement", + artifacts, + details, + ) + if "big delete" not in blocked_output and "--force" not in blocked_output: - return TestResult.fail_result(self.case_id, self.name, "Blocked sync did not emit a big delete safeguard warning", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Blocked sync did not emit a big delete safeguard warning", + artifacts, + details, + ) + if any(entry == f"{root_name}/BigDelete" or entry.startswith(f"{root_name}/BigDelete/") for entry in remote_manifest): - return TestResult.fail_result(self.case_id, self.name, "BigDelete content still exists online after acknowledged forced delete", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "BigDelete content still exists online after acknowledged forced delete", + artifacts, + details, + ) + if f"{root_name}/Keep/keep.txt" not in remote_manifest: - return TestResult.fail_result(self.case_id, self.name, "Keep content disappeared during big delete safeguard processing", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Keep content disappeared during big delete safeguard processing", + artifacts, + details, + ) - return TestResult.pass_result(self.case_id, self.name, artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 9ca0243027146a3d4ff5f0afa92efbaec91747f1 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 15 Mar 2026 16:01:29 +1100 Subject: [PATCH 061/245] Update tc0024_big_delete_safeguard_validation.py Update tc0024 --- .../tc0024_big_delete_safeguard_validation.py | 106 +++++++++++++----- 1 file changed, 77 insertions(+), 29 deletions(-) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index 502a85684..c2b8edb18 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import shutil from pathlib import Path from framework.base import E2ETestCase @@ -44,13 +43,15 @@ def run(self, context: E2EContext) -> TestResult: conf_verify = case_work_dir / "conf-verify" root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" + delete_files = [f"Delete0{idx}.txt" for idx in range(1, 6)] reset_directory(seed_root) reset_directory(local_root) reset_directory(verify_root) - for idx in range(1, 6): - write_text_file(seed_root / root_name / "BigDelete" / f"file{idx}.txt", f"file {idx}\n") + # Seed multiple top-level delete candidates. + for filename in delete_files: + write_text_file(seed_root / root_name / filename, f"{filename}\n") write_text_file(seed_root / root_name / "Keep" / "keep.txt", "keep\n") context.bootstrap_config_dir(conf_seed) @@ -72,6 +73,7 @@ def run(self, context: E2EContext) -> TestResult: forced_stderr = case_log_dir / "forced_stderr.log" verify_stdout = case_log_dir / "verify_stdout.log" verify_stderr = case_log_dir / "verify_stderr.log" + blocked_verify_manifest_file = state_dir / "blocked_verify_manifest.txt" remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" @@ -113,8 +115,13 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) - target = local_root / root_name / "BigDelete" - if not target.exists(): + # Confirm the delete candidates were downloaded locally. + missing_local = [ + filename + for filename in delete_files + if not (local_root / root_name / filename).is_file() + ] + if missing_local: write_text_file( metadata_file, "\n".join( @@ -123,9 +130,7 @@ def run(self, context: E2EContext) -> TestResult: f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", f"download_returncode={download_result.returncode}", - "blocked_returncode=-1", - "forced_returncode=-1", - "verify_returncode=-1", + f"missing_local={missing_local!r}", ] ) + "\n", @@ -145,12 +150,16 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - "Expected BigDelete path was not downloaded before delete phase", + "Expected delete candidate files were not downloaded before delete phase", artifacts, details, ) - shutil.rmtree(target) + # Delete multiple top-level files so the safeguard sees > threshold candidates. + for filename in delete_files: + candidate = local_root / root_name / filename + if candidate.exists(): + candidate.unlink() blocked_command = [ context.onedrive_bin, @@ -168,6 +177,35 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(blocked_stdout, blocked_result.stdout) write_text_file(blocked_stderr, blocked_result.stderr) + blocked_output = (blocked_result.stdout + "\n" + blocked_result.stderr).lower() + + # Verify after blocked sync using a fresh config to ensure the remote side + # was not modified before acknowledgement. + reset_directory(verify_root) + blocked_verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} blocked verify: {command_to_string(blocked_verify_command)}") + blocked_verify_result = run_command(blocked_verify_command, cwd=context.repo_root) + + # Reuse the same files for the final verify logs to avoid adding extra artifacts. + write_text_file(verify_stdout, blocked_verify_result.stdout) + write_text_file(verify_stderr, blocked_verify_result.stderr) + + blocked_remote_manifest = build_manifest(verify_root) + write_manifest(blocked_verify_manifest_file, blocked_remote_manifest) + forced_command = [ context.onedrive_bin, "--display-running-config", @@ -185,6 +223,8 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(forced_stdout, forced_result.stdout) write_text_file(forced_stderr, forced_result.stderr) + # Final clean verify after --force. + reset_directory(verify_root) verify_command = [ context.onedrive_bin, "--display-running-config", @@ -207,8 +247,6 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest = build_manifest(verify_root) write_manifest(remote_manifest_file, remote_manifest) - blocked_output = (blocked_result.stdout + "\n" + blocked_result.stderr).lower() - write_text_file( metadata_file, "\n".join( @@ -224,8 +262,10 @@ def run(self, context: E2EContext) -> TestResult: f"seed_returncode={seed_result.returncode}", f"download_returncode={download_result.returncode}", f"blocked_returncode={blocked_result.returncode}", + f"blocked_verify_returncode={blocked_verify_result.returncode}", f"forced_returncode={forced_result.returncode}", f"verify_returncode={verify_result.returncode}", + f"delete_files={delete_files!r}", ] ) + "\n", @@ -242,6 +282,7 @@ def run(self, context: E2EContext) -> TestResult: str(forced_stderr), str(verify_stdout), str(verify_stderr), + str(blocked_verify_manifest_file), str(remote_manifest_file), str(metadata_file), ] @@ -249,6 +290,7 @@ def run(self, context: E2EContext) -> TestResult: "seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, "blocked_returncode": blocked_result.returncode, + "blocked_verify_returncode": blocked_verify_result.returncode, "forced_returncode": forced_result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name, @@ -257,6 +299,7 @@ def run(self, context: E2EContext) -> TestResult: for label, rc in [ ("seed", seed_result.returncode), ("download", download_result.returncode), + ("blocked verify", blocked_verify_result.returncode), ("forced sync", forced_result.returncode), ("verify", verify_result.returncode), ]: @@ -269,15 +312,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if blocked_result.returncode == 0 and "big delete" not in blocked_output: - return TestResult.fail_result( - self.case_id, - self.name, - "Big delete safeguard did not trigger before forced acknowledgement", - artifacts, - details, - ) - + # Blocked sync must emit the safeguard warning / acknowledgement requirement. if "big delete" not in blocked_output and "--force" not in blocked_output: return TestResult.fail_result( self.case_id, @@ -287,14 +322,27 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if any(entry == f"{root_name}/BigDelete" or entry.startswith(f"{root_name}/BigDelete/") for entry in remote_manifest): - return TestResult.fail_result( - self.case_id, - self.name, - "BigDelete content still exists online after acknowledged forced delete", - artifacts, - details, - ) + # Before --force, the remote delete candidates must still exist. + for filename in delete_files: + if f"{root_name}/{filename}" not in blocked_remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + "Remote delete candidates were modified before forced acknowledgement", + artifacts, + details, + ) + + # After --force, the delete candidates must be gone remotely. + for filename in delete_files: + if f"{root_name}/{filename}" in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"{filename} still exists online after acknowledged forced delete", + artifacts, + details, + ) if f"{root_name}/Keep/keep.txt" not in remote_manifest: return TestResult.fail_result( From c16e8b6a7e4d5a3541e9710d7d5b043b9df3c1a8 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 10:16:33 +1100 Subject: [PATCH 062/245] Update tc0024_big_delete_safeguard_validation.py Update tc0024 --- .../tc0024_big_delete_safeguard_validation.py | 296 +++++++++++++----- 1 file changed, 225 insertions(+), 71 deletions(-) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index c2b8edb18..99def873b 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil from pathlib import Path from framework.base import E2ETestCase @@ -15,14 +16,36 @@ class TestCase0024BigDeleteSafeguardValidation(E2ETestCase): name = "big delete safeguard validation" description = "Validate classify_as_big_delete protection and forced acknowledgement via --force" - def _write_config(self, config_path: Path, sync_dir: Path) -> None: - write_text_file( - config_path, - "# tc0024 config\n" - f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n' - 'classify_as_big_delete = "3"\n', - ) + def _write_config( + self, + config_path: Path, + sync_dir: Path, + classify_as_big_delete: int | None = None, + ) -> None: + config_lines = [ + "# tc0024 config", + f'sync_dir = "{sync_dir}"', + 'bypass_data_preservation = "true"', + ] + + if classify_as_big_delete is not None: + config_lines.append(f'classify_as_big_delete = "{classify_as_big_delete}"') + + write_text_file(config_path, "\n".join(config_lines) + "\n") + + def _run_and_capture( + self, + context: E2EContext, + label: str, + command: list[str], + stdout_file: Path, + stderr_file: Path, + ): + context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + return result def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0024" @@ -43,40 +66,69 @@ def run(self, context: E2EContext) -> TestResult: conf_verify = case_work_dir / "conf-verify" root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" - delete_files = [f"Delete0{idx}.txt" for idx in range(1, 6)] + delete_dir_name = "DeleteDirectory" + keep_dir_name = "KeepDirectory" + classify_threshold = 5 + delete_file_count = 10 + + delete_dir_relative = f"{root_name}/{delete_dir_name}" + keep_file_relative = f"{root_name}/{keep_dir_name}/keep.txt" + delete_dir_local = local_root / root_name / delete_dir_name reset_directory(seed_root) reset_directory(local_root) reset_directory(verify_root) - # Seed multiple top-level delete candidates. - for filename in delete_files: - write_text_file(seed_root / root_name / filename, f"{filename}\n") - write_text_file(seed_root / root_name / "Keep" / "keep.txt", "keep\n") + # Seed content: + # - one populated directory that will later be removed entirely + # - one separate keep directory that must remain untouched + for index in range(delete_file_count): + write_text_file( + seed_root / root_name / delete_dir_name / f"file{index}.data", + f"delete-candidate-{index}\n", + ) + + write_text_file( + seed_root / root_name / keep_dir_name / "keep.txt", + "keep\n", + ) context.bootstrap_config_dir(conf_seed) - self._write_config(conf_seed / "config", seed_root) + self._write_config(conf_seed / "config", seed_root, None) context.bootstrap_config_dir(conf_local) - self._write_config(conf_local / "config", local_root) + self._write_config(conf_local / "config", local_root, classify_threshold) context.bootstrap_config_dir(conf_verify) - self._write_config(conf_verify / "config", verify_root) + self._write_config(conf_verify / "config", verify_root, classify_threshold) seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" + download_stdout = case_log_dir / "download_stdout.log" download_stderr = case_log_dir / "download_stderr.log" + + option_change_stdout = case_log_dir / "option_change_stdout.log" + option_change_stderr = case_log_dir / "option_change_stderr.log" + blocked_stdout = case_log_dir / "blocked_stdout.log" blocked_stderr = case_log_dir / "blocked_stderr.log" + + blocked_verify_stdout = case_log_dir / "blocked_verify_stdout.log" + blocked_verify_stderr = case_log_dir / "blocked_verify_stderr.log" + forced_stdout = case_log_dir / "forced_stdout.log" forced_stderr = case_log_dir / "forced_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" verify_stderr = case_log_dir / "verify_stderr.log" + blocked_verify_manifest_file = state_dir / "blocked_verify_manifest.txt" remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" + # Step 1: + # Upload the baseline content without relying on the safeguard setting. seed_command = [ context.onedrive_bin, "--display-running-config", @@ -91,11 +143,17 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_seed), ] - context.log(f"Executing Test Case {self.case_id} seed: {command_to_string(seed_command)}") - seed_result = run_command(seed_command, cwd=context.repo_root) - write_text_file(seed_stdout, seed_result.stdout) - write_text_file(seed_stderr, seed_result.stderr) + seed_result = self._run_and_capture( + context, + "seed", + seed_command, + seed_stdout, + seed_stderr, + ) + # Step 2: + # Download to a separate working tree using a config that has + # classify_as_big_delete enabled at a low threshold. download_command = [ context.onedrive_bin, "--display-running-config", @@ -110,18 +168,53 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_local), ] - context.log(f"Executing Test Case {self.case_id} download: {command_to_string(download_command)}") - download_result = run_command(download_command, cwd=context.repo_root) - write_text_file(download_stdout, download_result.stdout) - write_text_file(download_stderr, download_result.stderr) - - # Confirm the delete candidates were downloaded locally. - missing_local = [ - filename - for filename in delete_files - if not (local_root / root_name / filename).is_file() + download_result = self._run_and_capture( + context, + "download", + download_command, + download_stdout, + download_stderr, + ) + + # Step 2b: + # Perform a normal sync with the updated config before any deletion, + # mirroring the manual validation sequence where the option change is + # applied and confirmed before removing data. + option_change_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_local), ] - if missing_local: + option_change_result = self._run_and_capture( + context, + "option change validation", + option_change_command, + option_change_stdout, + option_change_stderr, + ) + + # Confirm the local working copy contains the populated delete directory + # and the keep content before removing anything. + missing_local_items: list[str] = [] + + if not delete_dir_local.is_dir(): + missing_local_items.append(delete_dir_relative) + + for index in range(delete_file_count): + candidate = delete_dir_local / f"file{index}.data" + if not candidate.is_file(): + missing_local_items.append(f"{delete_dir_relative}/file{index}.data") + + if not (local_root / root_name / keep_dir_name / "keep.txt").is_file(): + missing_local_items.append(keep_file_relative) + + if missing_local_items: write_text_file( metadata_file, "\n".join( @@ -130,36 +223,42 @@ def run(self, context: E2EContext) -> TestResult: f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", f"download_returncode={download_result.returncode}", - f"missing_local={missing_local!r}", + f"option_change_returncode={option_change_result.returncode}", + f"missing_local_items={missing_local_items!r}", ] ) + "\n", ) + artifacts = [ str(seed_stdout), str(seed_stderr), str(download_stdout), str(download_stderr), + str(option_change_stdout), + str(option_change_stderr), str(metadata_file), ] details = { "seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, + "option_change_returncode": option_change_result.returncode, "root_name": root_name, } + return TestResult.fail_result( self.case_id, self.name, - "Expected delete candidate files were not downloaded before delete phase", + "Expected local baseline content was not downloaded before delete phase", artifacts, details, ) - # Delete multiple top-level files so the safeguard sees > threshold candidates. - for filename in delete_files: - candidate = local_root / root_name / filename - if candidate.exists(): - candidate.unlink() + # Step 3: + # Remove the entire populated directory locally. This matches the + # proven working application path for classify_as_big_delete. + if delete_dir_local.exists(): + shutil.rmtree(delete_dir_local) blocked_command = [ context.onedrive_bin, @@ -172,15 +271,17 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_local), ] - context.log(f"Executing Test Case {self.case_id} blocked sync: {command_to_string(blocked_command)}") - blocked_result = run_command(blocked_command, cwd=context.repo_root) - write_text_file(blocked_stdout, blocked_result.stdout) - write_text_file(blocked_stderr, blocked_result.stderr) + blocked_result = self._run_and_capture( + context, + "blocked sync", + blocked_command, + blocked_stdout, + blocked_stderr, + ) blocked_output = (blocked_result.stdout + "\n" + blocked_result.stderr).lower() - # Verify after blocked sync using a fresh config to ensure the remote side - # was not modified before acknowledgement. + # Verify that the remote directory still exists after the blocked sync. reset_directory(verify_root) blocked_verify_command = [ context.onedrive_bin, @@ -196,16 +297,19 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_verify), ] - context.log(f"Executing Test Case {self.case_id} blocked verify: {command_to_string(blocked_verify_command)}") - blocked_verify_result = run_command(blocked_verify_command, cwd=context.repo_root) - - # Reuse the same files for the final verify logs to avoid adding extra artifacts. - write_text_file(verify_stdout, blocked_verify_result.stdout) - write_text_file(verify_stderr, blocked_verify_result.stderr) + blocked_verify_result = self._run_and_capture( + context, + "blocked verify", + blocked_verify_command, + blocked_verify_stdout, + blocked_verify_stderr, + ) blocked_remote_manifest = build_manifest(verify_root) write_manifest(blocked_verify_manifest_file, blocked_remote_manifest) + # Step 4: + # Re-run with --force and confirm the deletion is then allowed. forced_command = [ context.onedrive_bin, "--display-running-config", @@ -218,12 +322,14 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_local), ] - context.log(f"Executing Test Case {self.case_id} forced sync: {command_to_string(forced_command)}") - forced_result = run_command(forced_command, cwd=context.repo_root) - write_text_file(forced_stdout, forced_result.stdout) - write_text_file(forced_stderr, forced_result.stderr) + forced_result = self._run_and_capture( + context, + "forced sync", + forced_command, + forced_stdout, + forced_stderr, + ) - # Final clean verify after --force. reset_directory(verify_root) verify_command = [ context.onedrive_bin, @@ -239,10 +345,13 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_verify), ] - context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") - verify_result = run_command(verify_command, cwd=context.repo_root) - write_text_file(verify_stdout, verify_result.stdout) - write_text_file(verify_stderr, verify_result.stderr) + verify_result = self._run_and_capture( + context, + "verify", + verify_command, + verify_stdout, + verify_stderr, + ) remote_manifest = build_manifest(verify_root) write_manifest(remote_manifest_file, remote_manifest) @@ -259,13 +368,17 @@ def run(self, context: E2EContext) -> TestResult: f"seed_confdir={conf_seed}", f"local_confdir={conf_local}", f"verify_confdir={conf_verify}", + f"classify_as_big_delete={classify_threshold}", + f"delete_dir_relative={delete_dir_relative}", + f"delete_file_count={delete_file_count}", + f"keep_file_relative={keep_file_relative}", f"seed_returncode={seed_result.returncode}", f"download_returncode={download_result.returncode}", + f"option_change_returncode={option_change_result.returncode}", f"blocked_returncode={blocked_result.returncode}", f"blocked_verify_returncode={blocked_verify_result.returncode}", f"forced_returncode={forced_result.returncode}", f"verify_returncode={verify_result.returncode}", - f"delete_files={delete_files!r}", ] ) + "\n", @@ -276,8 +389,12 @@ def run(self, context: E2EContext) -> TestResult: str(seed_stderr), str(download_stdout), str(download_stderr), + str(option_change_stdout), + str(option_change_stderr), str(blocked_stdout), str(blocked_stderr), + str(blocked_verify_stdout), + str(blocked_verify_stderr), str(forced_stdout), str(forced_stderr), str(verify_stdout), @@ -289,6 +406,7 @@ def run(self, context: E2EContext) -> TestResult: details = { "seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, + "option_change_returncode": option_change_result.returncode, "blocked_returncode": blocked_result.returncode, "blocked_verify_returncode": blocked_verify_result.returncode, "forced_returncode": forced_result.returncode, @@ -299,6 +417,7 @@ def run(self, context: E2EContext) -> TestResult: for label, rc in [ ("seed", seed_result.returncode), ("download", download_result.returncode), + ("option change validation", option_change_result.returncode), ("blocked verify", blocked_verify_result.returncode), ("forced sync", forced_result.returncode), ("verify", verify_result.returncode), @@ -312,8 +431,14 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Blocked sync must emit the safeguard warning / acknowledgement requirement. - if "big delete" not in blocked_output and "--force" not in blocked_output: + # The blocked sync must emit the safeguard warning / forced acknowledgement requirement. + safeguard_markers = [ + "large volume of data", + "the total number of items being deleted is", + "classify_as_big_delete", + "--force", + ] + if not any(marker in blocked_output for marker in safeguard_markers): return TestResult.fail_result( self.case_id, self.name, @@ -322,29 +447,58 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Before --force, the remote delete candidates must still exist. - for filename in delete_files: - if f"{root_name}/{filename}" not in blocked_remote_manifest: + # Before --force, the remotely seeded delete directory must still exist. + if delete_dir_relative not in blocked_remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + "Remote delete directory was modified before forced acknowledgement", + artifacts, + details, + ) + + for index in range(delete_file_count): + relative_path = f"{delete_dir_relative}/file{index}.data" + if relative_path not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, self.name, - "Remote delete candidates were modified before forced acknowledgement", + f"{relative_path} was modified before forced acknowledgement", artifacts, details, ) - # After --force, the delete candidates must be gone remotely. - for filename in delete_files: - if f"{root_name}/{filename}" in remote_manifest: + if keep_file_relative not in blocked_remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + "Keep content disappeared during blocked safeguard processing", + artifacts, + details, + ) + + # After --force, the entire delete directory must be gone remotely. + if delete_dir_relative in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + "Delete directory still exists online after acknowledged forced delete", + artifacts, + details, + ) + + for index in range(delete_file_count): + relative_path = f"{delete_dir_relative}/file{index}.data" + if relative_path in remote_manifest: return TestResult.fail_result( self.case_id, self.name, - f"{filename} still exists online after acknowledged forced delete", + f"{relative_path} still exists online after acknowledged forced delete", artifacts, details, ) - if f"{root_name}/Keep/keep.txt" not in remote_manifest: + if keep_file_relative not in remote_manifest: return TestResult.fail_result( self.case_id, self.name, From 28f533bd5607fa9be29845eb09b1d2d17f2fc323 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 10:39:07 +1100 Subject: [PATCH 063/245] Update tc0024_big_delete_safeguard_validation.py Update tc0024 --- .../tc0024_big_delete_safeguard_validation.py | 205 +++++++++--------- 1 file changed, 107 insertions(+), 98 deletions(-) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index 99def873b..2d38f8920 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -57,57 +57,57 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(state_dir) context.ensure_refresh_token_available() - seed_root = case_work_dir / "seedroot" local_root = case_work_dir / "localroot" verify_root = case_work_dir / "verifyroot" - conf_seed = case_work_dir / "conf-seed" conf_local = case_work_dir / "conf-local" conf_verify = case_work_dir / "conf-verify" + reset_directory(local_root) + reset_directory(verify_root) + root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" - delete_dir_name = "DeleteDirectory" - keep_dir_name = "KeepDirectory" + parent_dir_name = "random_1K_files" classify_threshold = 5 - delete_file_count = 10 - delete_dir_relative = f"{root_name}/{delete_dir_name}" - keep_file_relative = f"{root_name}/{keep_dir_name}/keep.txt" - delete_dir_local = local_root / root_name / delete_dir_name + sibling_dir_count = 10 + files_per_dir = 10 + delete_dir_index = 3 + keep_dir_index = 7 - reset_directory(seed_root) - reset_directory(local_root) - reset_directory(verify_root) - - # Seed content: - # - one populated directory that will later be removed entirely - # - one separate keep directory that must remain untouched - for index in range(delete_file_count): - write_text_file( - seed_root / root_name / delete_dir_name / f"file{index}.data", - f"delete-candidate-{index}\n", - ) + delete_dir_name = f"dir_{delete_dir_index:02d}" + keep_dir_name = f"dir_{keep_dir_index:02d}" - write_text_file( - seed_root / root_name / keep_dir_name / "keep.txt", - "keep\n", - ) + delete_dir_relative = f"{root_name}/{parent_dir_name}/{delete_dir_name}" + keep_file_relative = f"{root_name}/{parent_dir_name}/{keep_dir_name}/file0.data" - context.bootstrap_config_dir(conf_seed) - self._write_config(conf_seed / "config", seed_root, None) + delete_dir_local = local_root / root_name / parent_dir_name / delete_dir_name + keep_file_local = local_root / root_name / parent_dir_name / keep_dir_name / "file0.data" context.bootstrap_config_dir(conf_local) - self._write_config(conf_local / "config", local_root, classify_threshold) - context.bootstrap_config_dir(conf_verify) + + # + # Step 1: + # Seed a local structure that mirrors the successful manual validation: + # one parent directory with multiple child directories, each containing files. + # + for dir_index in range(sibling_dir_count): + child_dir = local_root / root_name / parent_dir_name / f"dir_{dir_index:02d}" + for file_index in range(files_per_dir): + write_text_file( + child_dir / f"file{file_index}.data", + f"tc0024 dir={dir_index} file={file_index}\n", + ) + + # Initial config without classify_as_big_delete, matching the manual + # sequence where the option is changed after the initial upload. + self._write_config(conf_local / "config", local_root, None) self._write_config(conf_verify / "config", verify_root, classify_threshold) seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" - download_stdout = case_log_dir / "download_stdout.log" - download_stderr = case_log_dir / "download_stderr.log" - option_change_stdout = case_log_dir / "option_change_stdout.log" option_change_stderr = case_log_dir / "option_change_stderr.log" @@ -127,8 +127,6 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" - # Step 1: - # Upload the baseline content without relying on the safeguard setting. seed_command = [ context.onedrive_bin, "--display-running-config", @@ -141,7 +139,7 @@ def run(self, context: E2EContext) -> TestResult: "--single-directory", root_name, "--confdir", - str(conf_seed), + str(conf_local), ] seed_result = self._run_and_capture( context, @@ -151,35 +149,13 @@ def run(self, context: E2EContext) -> TestResult: seed_stderr, ) + # # Step 2: - # Download to a separate working tree using a config that has - # classify_as_big_delete enabled at a low threshold. - download_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--verbose", - "--verbose", - "--download-only", - "--resync", - "--resync-auth", - "--single-directory", - root_name, - "--confdir", - str(conf_local), - ] - download_result = self._run_and_capture( - context, - "download", - download_command, - download_stdout, - download_stderr, - ) + # Update the same config to enable classify_as_big_delete at a low value, + # then run a normal sync with the same confdir and local database. + # + self._write_config(conf_local / "config", local_root, classify_threshold) - # Step 2b: - # Perform a normal sync with the updated config before any deletion, - # mirroring the manual validation sequence where the option change is - # applied and confirmed before removing data. option_change_command = [ context.onedrive_bin, "--display-running-config", @@ -199,19 +175,20 @@ def run(self, context: E2EContext) -> TestResult: option_change_stderr, ) - # Confirm the local working copy contains the populated delete directory - # and the keep content before removing anything. + # + # Confirm expected baseline content exists locally before delete phase. + # missing_local_items: list[str] = [] if not delete_dir_local.is_dir(): missing_local_items.append(delete_dir_relative) - for index in range(delete_file_count): - candidate = delete_dir_local / f"file{index}.data" + for file_index in range(files_per_dir): + candidate = delete_dir_local / f"file{file_index}.data" if not candidate.is_file(): - missing_local_items.append(f"{delete_dir_relative}/file{index}.data") + missing_local_items.append(f"{delete_dir_relative}/file{file_index}.data") - if not (local_root / root_name / keep_dir_name / "keep.txt").is_file(): + if not keep_file_local.is_file(): missing_local_items.append(keep_file_relative) if missing_local_items: @@ -222,7 +199,6 @@ def run(self, context: E2EContext) -> TestResult: f"case_id={self.case_id}", f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", - f"download_returncode={download_result.returncode}", f"option_change_returncode={option_change_result.returncode}", f"missing_local_items={missing_local_items!r}", ] @@ -233,15 +209,12 @@ def run(self, context: E2EContext) -> TestResult: artifacts = [ str(seed_stdout), str(seed_stderr), - str(download_stdout), - str(download_stderr), str(option_change_stdout), str(option_change_stderr), str(metadata_file), ] details = { "seed_returncode": seed_result.returncode, - "download_returncode": download_result.returncode, "option_change_returncode": option_change_result.returncode, "root_name": root_name, } @@ -249,14 +222,16 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - "Expected local baseline content was not downloaded before delete phase", + "Expected local baseline content was not present before delete phase", artifacts, details, ) + # # Step 3: - # Remove the entire populated directory locally. This matches the - # proven working application path for classify_as_big_delete. + # Remove one entire child directory locally, matching the proven working + # manual path for classify_as_big_delete. + # if delete_dir_local.exists(): shutil.rmtree(delete_dir_local) @@ -279,10 +254,16 @@ def run(self, context: E2EContext) -> TestResult: blocked_stderr, ) - blocked_output = (blocked_result.stdout + "\n" + blocked_result.stderr).lower() + blocked_output = blocked_result.stdout + "\n" + blocked_result.stderr + blocked_output_lower = blocked_output.lower() - # Verify that the remote directory still exists after the blocked sync. + # + # Verify remotely, after the blocked sync, using a fresh config and a + # fresh local root. + # reset_directory(verify_root) + self._write_config(conf_verify / "config", verify_root, classify_threshold) + blocked_verify_command = [ context.onedrive_bin, "--display-running-config", @@ -308,8 +289,10 @@ def run(self, context: E2EContext) -> TestResult: blocked_remote_manifest = build_manifest(verify_root) write_manifest(blocked_verify_manifest_file, blocked_remote_manifest) + # # Step 4: - # Re-run with --force and confirm the deletion is then allowed. + # Acknowledge with --force and verify the delete is then allowed. + # forced_command = [ context.onedrive_bin, "--display-running-config", @@ -331,6 +314,8 @@ def run(self, context: E2EContext) -> TestResult: ) reset_directory(verify_root) + self._write_config(conf_verify / "config", verify_root, classify_threshold) + verify_command = [ context.onedrive_bin, "--display-running-config", @@ -362,18 +347,17 @@ def run(self, context: E2EContext) -> TestResult: [ f"case_id={self.case_id}", f"root_name={root_name}", - f"seed_root={seed_root}", f"local_root={local_root}", f"verify_root={verify_root}", - f"seed_confdir={conf_seed}", f"local_confdir={conf_local}", f"verify_confdir={conf_verify}", f"classify_as_big_delete={classify_threshold}", + f"parent_dir_name={parent_dir_name}", + f"sibling_dir_count={sibling_dir_count}", + f"files_per_dir={files_per_dir}", f"delete_dir_relative={delete_dir_relative}", - f"delete_file_count={delete_file_count}", f"keep_file_relative={keep_file_relative}", f"seed_returncode={seed_result.returncode}", - f"download_returncode={download_result.returncode}", f"option_change_returncode={option_change_result.returncode}", f"blocked_returncode={blocked_result.returncode}", f"blocked_verify_returncode={blocked_verify_result.returncode}", @@ -387,8 +371,6 @@ def run(self, context: E2EContext) -> TestResult: artifacts = [ str(seed_stdout), str(seed_stderr), - str(download_stdout), - str(download_stderr), str(option_change_stdout), str(option_change_stderr), str(blocked_stdout), @@ -405,7 +387,6 @@ def run(self, context: E2EContext) -> TestResult: ] details = { "seed_returncode": seed_result.returncode, - "download_returncode": download_result.returncode, "option_change_returncode": option_change_result.returncode, "blocked_returncode": blocked_result.returncode, "blocked_verify_returncode": blocked_verify_result.returncode, @@ -416,7 +397,6 @@ def run(self, context: E2EContext) -> TestResult: for label, rc in [ ("seed", seed_result.returncode), - ("download", download_result.returncode), ("option change validation", option_change_result.returncode), ("blocked verify", blocked_verify_result.returncode), ("forced sync", forced_result.returncode), @@ -431,23 +411,49 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # The blocked sync must emit the safeguard warning / forced acknowledgement requirement. + # + # The blocked sync must contain the actual safeguard messaging, not just + # the running-config line that mentions classify_as_big_delete. + # safeguard_markers = [ - "large volume of data", - "the total number of items being deleted is", - "classify_as_big_delete", - "--force", + "ERROR: An attempt to remove a large volume of data from OneDrive has been detected", + "ERROR: The total number of items being deleted is:", + "ERROR: To delete a large volume of data use --force", ] - if not any(marker in blocked_output for marker in safeguard_markers): + if not all(marker in blocked_output for marker in safeguard_markers): + return TestResult.fail_result( + self.case_id, + self.name, + "Blocked sync did not emit the expected big delete safeguard warning", + artifacts, + details, + ) + + # + # Additional evidence that the correct code path was exercised. + # + if "the directory has been deleted locally" not in blocked_output_lower: + return TestResult.fail_result( + self.case_id, + self.name, + "Blocked sync did not detect the deleted directory path", + artifacts, + details, + ) + + if "deleted local items to delete on microsoft onedrive: 1" not in blocked_output_lower: return TestResult.fail_result( self.case_id, self.name, - "Blocked sync did not emit a big delete safeguard warning", + "Blocked sync did not queue a single top-level directory delete candidate", artifacts, details, ) - # Before --force, the remotely seeded delete directory must still exist. + # + # Before --force, the deleted directory and its contents must still exist + # remotely, and keep content must remain intact. + # if delete_dir_relative not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, @@ -457,8 +463,8 @@ def run(self, context: E2EContext) -> TestResult: details, ) - for index in range(delete_file_count): - relative_path = f"{delete_dir_relative}/file{index}.data" + for file_index in range(files_per_dir): + relative_path = f"{delete_dir_relative}/file{file_index}.data" if relative_path not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, @@ -477,7 +483,10 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # After --force, the entire delete directory must be gone remotely. + # + # After --force, the deleted directory and its contents must be gone + # remotely, while keep content remains. + # if delete_dir_relative in remote_manifest: return TestResult.fail_result( self.case_id, @@ -487,8 +496,8 @@ def run(self, context: E2EContext) -> TestResult: details, ) - for index in range(delete_file_count): - relative_path = f"{delete_dir_relative}/file{index}.data" + for file_index in range(files_per_dir): + relative_path = f"{delete_dir_relative}/file{file_index}.data" if relative_path in remote_manifest: return TestResult.fail_result( self.case_id, From 7b31feb31510df568d798a7e9898244f4da19792 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 10:57:30 +1100 Subject: [PATCH 064/245] Update tc0024_big_delete_safeguard_validation.py update tc0024 --- .../tc0024_big_delete_safeguard_validation.py | 133 ++++-------------- 1 file changed, 31 insertions(+), 102 deletions(-) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index 2d38f8920..c2d15372a 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -66,10 +66,16 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" + context.bootstrap_config_dir(conf_local) + context.bootstrap_config_dir(conf_verify) + + # Mirror the manual validation structure: + # sync_dir/ + # random_1K_files/ + # <10 dirs>/ + # <10 files each> parent_dir_name = "random_1K_files" classify_threshold = 5 - sibling_dir_count = 10 files_per_dir = 10 delete_dir_index = 3 @@ -78,30 +84,21 @@ def run(self, context: E2EContext) -> TestResult: delete_dir_name = f"dir_{delete_dir_index:02d}" keep_dir_name = f"dir_{keep_dir_index:02d}" - delete_dir_relative = f"{root_name}/{parent_dir_name}/{delete_dir_name}" - keep_file_relative = f"{root_name}/{parent_dir_name}/{keep_dir_name}/file0.data" + delete_dir_relative = f"{parent_dir_name}/{delete_dir_name}" + keep_file_relative = f"{parent_dir_name}/{keep_dir_name}/file0.data" - delete_dir_local = local_root / root_name / parent_dir_name / delete_dir_name - keep_file_local = local_root / root_name / parent_dir_name / keep_dir_name / "file0.data" + delete_dir_local = local_root / parent_dir_name / delete_dir_name + keep_file_local = local_root / parent_dir_name / keep_dir_name / "file0.data" - context.bootstrap_config_dir(conf_local) - context.bootstrap_config_dir(conf_verify) - - # - # Step 1: - # Seed a local structure that mirrors the successful manual validation: - # one parent directory with multiple child directories, each containing files. - # for dir_index in range(sibling_dir_count): - child_dir = local_root / root_name / parent_dir_name / f"dir_{dir_index:02d}" + child_dir = local_root / parent_dir_name / f"dir_{dir_index:02d}" for file_index in range(files_per_dir): write_text_file( child_dir / f"file{file_index}.data", f"tc0024 dir={dir_index} file={file_index}\n", ) - # Initial config without classify_as_big_delete, matching the manual - # sequence where the option is changed after the initial upload. + # Step 1: initial upload without classify_as_big_delete configured. self._write_config(conf_local / "config", local_root, None) self._write_config(conf_verify / "config", verify_root, classify_threshold) @@ -136,8 +133,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--resync", "--resync-auth", - "--single-directory", - root_name, "--confdir", str(conf_local), ] @@ -149,11 +144,7 @@ def run(self, context: E2EContext) -> TestResult: seed_stderr, ) - # - # Step 2: - # Update the same config to enable classify_as_big_delete at a low value, - # then run a normal sync with the same confdir and local database. - # + # Step 2: update config and run a normal sync, matching the manual flow. self._write_config(conf_local / "config", local_root, classify_threshold) option_change_command = [ @@ -162,8 +153,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--verbose", "--verbose", - "--single-directory", - root_name, "--confdir", str(conf_local), ] @@ -175,9 +164,6 @@ def run(self, context: E2EContext) -> TestResult: option_change_stderr, ) - # - # Confirm expected baseline content exists locally before delete phase. - # missing_local_items: list[str] = [] if not delete_dir_local.is_dir(): @@ -197,7 +183,6 @@ def run(self, context: E2EContext) -> TestResult: "\n".join( [ f"case_id={self.case_id}", - f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", f"option_change_returncode={option_change_result.returncode}", f"missing_local_items={missing_local_items!r}", @@ -216,7 +201,6 @@ def run(self, context: E2EContext) -> TestResult: details = { "seed_returncode": seed_result.returncode, "option_change_returncode": option_change_result.returncode, - "root_name": root_name, } return TestResult.fail_result( @@ -227,11 +211,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # - # Step 3: - # Remove one entire child directory locally, matching the proven working - # manual path for classify_as_big_delete. - # + # Step 3: delete one entire child directory locally. if delete_dir_local.exists(): shutil.rmtree(delete_dir_local) @@ -241,8 +221,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--verbose", "--verbose", - "--single-directory", - root_name, "--confdir", str(conf_local), ] @@ -255,12 +233,8 @@ def run(self, context: E2EContext) -> TestResult: ) blocked_output = blocked_result.stdout + "\n" + blocked_result.stderr - blocked_output_lower = blocked_output.lower() - # - # Verify remotely, after the blocked sync, using a fresh config and a - # fresh local root. - # + # Verify remote state after blocked sync. reset_directory(verify_root) self._write_config(conf_verify / "config", verify_root, classify_threshold) @@ -273,8 +247,6 @@ def run(self, context: E2EContext) -> TestResult: "--download-only", "--resync", "--resync-auth", - "--single-directory", - root_name, "--confdir", str(conf_verify), ] @@ -289,10 +261,7 @@ def run(self, context: E2EContext) -> TestResult: blocked_remote_manifest = build_manifest(verify_root) write_manifest(blocked_verify_manifest_file, blocked_remote_manifest) - # - # Step 4: - # Acknowledge with --force and verify the delete is then allowed. - # + # Step 4: rerun with --force. forced_command = [ context.onedrive_bin, "--display-running-config", @@ -300,8 +269,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--verbose", "--force", - "--single-directory", - root_name, "--confdir", str(conf_local), ] @@ -325,8 +292,6 @@ def run(self, context: E2EContext) -> TestResult: "--download-only", "--resync", "--resync-auth", - "--single-directory", - root_name, "--confdir", str(conf_verify), ] @@ -346,7 +311,6 @@ def run(self, context: E2EContext) -> TestResult: "\n".join( [ f"case_id={self.case_id}", - f"root_name={root_name}", f"local_root={local_root}", f"verify_root={verify_root}", f"local_confdir={conf_local}", @@ -392,7 +356,6 @@ def run(self, context: E2EContext) -> TestResult: "blocked_verify_returncode": blocked_verify_result.returncode, "forced_returncode": forced_result.returncode, "verify_returncode": verify_result.returncode, - "root_name": root_name, } for label, rc in [ @@ -411,10 +374,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # - # The blocked sync must contain the actual safeguard messaging, not just - # the running-config line that mentions classify_as_big_delete. - # safeguard_markers = [ "ERROR: An attempt to remove a large volume of data from OneDrive has been detected", "ERROR: The total number of items being deleted is:", @@ -429,84 +388,54 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # - # Additional evidence that the correct code path was exercised. - # - if "the directory has been deleted locally" not in blocked_output_lower: + # Before --force, the deleted directory and one known file beneath it must still exist remotely. + if delete_dir_relative not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, self.name, - "Blocked sync did not detect the deleted directory path", + "Remote delete directory was modified before forced acknowledgement", artifacts, details, ) - if "deleted local items to delete on microsoft onedrive: 1" not in blocked_output_lower: + deleted_probe_file = f"{delete_dir_relative}/file0.data" + if deleted_probe_file not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, self.name, - "Blocked sync did not queue a single top-level directory delete candidate", + f"{deleted_probe_file} was modified before forced acknowledgement", artifacts, details, ) - # - # Before --force, the deleted directory and its contents must still exist - # remotely, and keep content must remain intact. - # - if delete_dir_relative not in blocked_remote_manifest: + if keep_file_relative not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, self.name, - "Remote delete directory was modified before forced acknowledgement", + "Keep content disappeared during blocked safeguard processing", artifacts, details, ) - for file_index in range(files_per_dir): - relative_path = f"{delete_dir_relative}/file{file_index}.data" - if relative_path not in blocked_remote_manifest: - return TestResult.fail_result( - self.case_id, - self.name, - f"{relative_path} was modified before forced acknowledgement", - artifacts, - details, - ) - - if keep_file_relative not in blocked_remote_manifest: + # After --force, the deleted directory must be gone remotely. + if delete_dir_relative in remote_manifest: return TestResult.fail_result( self.case_id, self.name, - "Keep content disappeared during blocked safeguard processing", + "Delete directory still exists online after acknowledged forced delete", artifacts, details, ) - # - # After --force, the deleted directory and its contents must be gone - # remotely, while keep content remains. - # - if delete_dir_relative in remote_manifest: + if deleted_probe_file in remote_manifest: return TestResult.fail_result( self.case_id, self.name, - "Delete directory still exists online after acknowledged forced delete", + f"{deleted_probe_file} still exists online after acknowledged forced delete", artifacts, details, ) - for file_index in range(files_per_dir): - relative_path = f"{delete_dir_relative}/file{file_index}.data" - if relative_path in remote_manifest: - return TestResult.fail_result( - self.case_id, - self.name, - f"{relative_path} still exists online after acknowledged forced delete", - artifacts, - details, - ) - if keep_file_relative not in remote_manifest: return TestResult.fail_result( self.case_id, From 851f75640d4f5c39c3fb8065cf61d8c0926552ab Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 11:21:36 +1100 Subject: [PATCH 065/245] Update tc0024_big_delete_safeguard_validation.py remove debugging --- ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index c2d15372a..7cd7ac832 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -130,7 +130,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--upload-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--confdir", @@ -152,7 +151,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--confdir", str(conf_local), ] @@ -220,7 +218,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--confdir", str(conf_local), ] @@ -243,7 +240,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--resync", "--resync-auth", @@ -267,7 +263,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--force", "--confdir", str(conf_local), @@ -288,7 +283,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--resync", "--resync-auth", From 33d37727616d25a5081e189ae18be77e6349959f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 12:08:59 +1100 Subject: [PATCH 066/245] Add files via upload Add e2e-business.yaml --- .github/workflows/e2e-business.yaml | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .github/workflows/e2e-business.yaml diff --git a/.github/workflows/e2e-business.yaml b/.github/workflows/e2e-business.yaml new file mode 100644 index 000000000..6f9d935d2 --- /dev/null +++ b/.github/workflows/e2e-business.yaml @@ -0,0 +1,156 @@ +name: E2E Business Account Testing + +on: + push: + branches-ignore: + - master + - main + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + e2e_business: + runs-on: ubuntu-latest + container: fedora:latest + environment: onedrive-e2e + + steps: + - uses: actions/checkout@v4 + + - name: Install Dependencies + run: | + dnf -y update + dnf -y group install development-tools + dnf -y install python3 ldc libcurl-devel sqlite-devel dbus-devel jq + + - name: Build + local install prefix + run: | + ./configure --prefix="$PWD/.ci/prefix" + make -j"$(nproc)" + make install + "$PWD/.ci/prefix/bin/onedrive" --version + + - name: Prepare isolated HOME + run: | + set -euo pipefail + export HOME="$RUNNER_TEMP/home-business" + echo "HOME=$HOME" >> "$GITHUB_ENV" + echo "XDG_CONFIG_HOME=$HOME/.config" >> "$GITHUB_ENV" + echo "XDG_CACHE_HOME=$HOME/.cache" >> "$GITHUB_ENV" + mkdir -p "$HOME" + + - name: Inject refresh token into onedrive config + env: + REFRESH_TOKEN_BUSINESS: ${{ secrets.REFRESH_TOKEN_BUSINESS }} + run: | + set -euo pipefail + mkdir -p "$XDG_CONFIG_HOME/onedrive" + umask 077 + printf "%s" "$REFRESH_TOKEN_BUSINESS" > "$XDG_CONFIG_HOME/onedrive/refresh_token" + chmod 600 "$XDG_CONFIG_HOME/onedrive/refresh_token" + + - name: Run E2E harness + env: + ONEDRIVE_BIN: ${{ github.workspace }}/.ci/prefix/bin/onedrive + E2E_TARGET: business + RUN_ID: ${{ github.run_id }} + run: | + python3 ci/e2e/run.py + + - name: Upload E2E artefacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-business + path: ci/e2e/out/** + + pr_comment: + name: Post PR summary comment + needs: [ e2e_business ] + runs-on: ubuntu-latest + if: always() + + steps: + - uses: actions/checkout@v4 + + - name: Download artefact + uses: actions/download-artifact@v4 + with: + name: e2e-business + path: artifacts/e2e-business + + - name: Build markdown summary + id: summary + run: | + set -euo pipefail + + f="$(find artifacts/e2e-business -name results.json -type f | head -n 1 || true)" + if [ -z "$f" ] || [ ! -f "$f" ]; then + echo "md=⚠️ E2E ran but results.json was not found." >> "$GITHUB_OUTPUT" + exit 0 + fi + + target=$(jq -r '.target // "business"' "$f") + total=$(jq -r '.cases | length' "$f") + passed=$(jq -r '[.cases[] | select(.status=="pass")] | length' "$f") + failed=$(jq -r '[.cases[] | select(.status=="fail")] | length' "$f") + + failures=$(jq -r '.cases[] + | select(.status=="fail") + | "- Test Case \(.id // "????"): \(.name) — \(.reason // "no reason provided")"' "$f" || true) + + md="## ${target^} Account Testing\n" + md+="**${total}** Test Cases Run \n" + md+="**${passed}** Test Cases Passed \n" + md+="**${failed}** Test Cases Failed \n\n" + + if [ "$failed" -gt 0 ] && [ -n "$failures" ]; then + md+="### ${target^} Account Test Failures\n" + md+="$failures\n" + fi + + echo "md<> "$GITHUB_OUTPUT" + echo -e "$md" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Find PR associated with this commit + id: pr + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const sha = context.sha; + + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, repo, commit_sha: sha + }); + + if (!prs.data.length) { + core.setOutput("found", "false"); + return; + } + + core.setOutput("found", "true"); + core.setOutput("number", String(prs.data[0].number)); + + - name: Post PR comment + if: steps.pr.outputs.found == 'true' + uses: actions/github-script@v7 + env: + COMMENT_MD: ${{ steps.summary.outputs.md }} + with: + script: | + const { owner, repo } = context.repo; + const issue_number = Number("${{ steps.pr.outputs.number }}"); + + const md = process.env.COMMENT_MD || "⚠️ No summary text produced."; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: md + }); \ No newline at end of file From 1b39a139feff2742ce7e5bc9985bf3c6a9444ea5 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 12:29:13 +1100 Subject: [PATCH 067/245] Update e2e-business.yaml * switch to own environment --- .github/workflows/e2e-business.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-business.yaml b/.github/workflows/e2e-business.yaml index 6f9d935d2..bcd3cf51c 100644 --- a/.github/workflows/e2e-business.yaml +++ b/.github/workflows/e2e-business.yaml @@ -15,7 +15,7 @@ jobs: e2e_business: runs-on: ubuntu-latest container: fedora:latest - environment: onedrive-e2e + environment: e2e-business steps: - uses: actions/checkout@v4 From 64b473c3c8e5b862c62e756de9a1cac907e657d1 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 13:54:46 +1100 Subject: [PATCH 068/245] Update docs * Update docs + badges --- docs/end_to_end_testing.md | 31 ++++++++++++++++++++++++++++--- readme.md | 3 ++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 14ae454d7..12e55ecd9 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -1,8 +1,33 @@ # End to End Testing of OneDrive Client for Linux -[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) | Test Case | Description | Account Coverage | Test Details | |:-------|:-------------|:-------|:-------| -| 0001 | Basic Resync | Personal | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | -| 0002 | 'sync_list' Validation | Personal | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| \ No newline at end of file +| 0001 | Basic Resync | Personal
Business | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | +| 0002 | 'sync_list' Validation | Personal
Business | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| + +| 0003 | Dry-run safety validation | Personal
Business | This test validates that running the client with `--dry-run` performs a full synchronisation analysis without making any changes locally or remotely. Files and directories are created in the test environment, the client is executed with `--dry-run`, and the test verifies that no filesystem or remote changes occur.| +| 0004 | Single-directory sync scope validation | Personal
Business | This test validates that the `--single-directory` option restricts synchronisation to the specified directory subtree. Only the nominated directory should be synchronised, while other directories outside the scope must remain untouched. | +| 0005 | Force-sync override validation | Personal
Business | This test validates that the `--force-sync` option overrides blocking conditions that would normally prevent synchronisation of a single-directory scope. The test confirms that forced synchronisation proceeds even when skip rules would otherwise block the operation. | +| 0006 | Download-only sync validation | Personal
Business | This test validates the behaviour of the `--download-only` option. Remote content is seeded in OneDrive and the client is executed in download-only mode. The test verifies that the remote data is correctly downloaded locally without performing any upload operations. | +| 0007 | Download-only cleanup validation | Personal
Business | This test validates the behaviour of `--cleanup-local-files` when used with `--download-only`. Local files that no longer exist remotely should be removed during synchronisation while valid remote content is preserved. | +| 0008 | Upload-only sync validation | Personal
Business | This test validates that `--upload-only` mode correctly uploads local files and directories to OneDrive. The test ensures that no download operations occur and that remote content reflects the locally created files. | +| 0009 | Upload-only remote preservation validation | Personal
Business | This test validates the behaviour of the `--no-remote-delete` option when used with `--upload-only` mode. The test confirms that remote files are not deleted even when they do not exist locally. | +| 0010 | Upload-only source removal validation | Personal
Business | This test validates the `--remove-source-files` option used with `--upload-only` mode. After files are successfully uploaded to OneDrive, the local source files should be automatically removed. | +| 0011 | Skip-file pattern validation | Personal
Business | This test validates that `skip_file` configuration patterns correctly exclude matching files from synchronisation. Files matching the configured patterns should not be uploaded or downloaded. | +| 0012 | Skip-directory rule validation | Personal
Business | This test validates the behaviour of the 'skip_dir' option and the 'skip_dir_strict_match' setting. The test confirms that directories matching the configured patterns are correctly excluded from synchronisation. | +| 0013 | Dotfile exclusion validation | Personal
Business | This test validates the 'skip_dotfiles' option. Files and directories beginning with a dot (`.`) should be excluded from synchronisation when this option is enabled. | +| 0014 | File size exclusion validation | Personal
Business | This test validates the 'skip_size' option. Files exceeding the configured size threshold should be excluded from synchronisation.| +| 0015 | Symlink exclusion validation | Personal
Business | This test validates the 'skip_symlinks' configuration option. Symbolic links present in the local filesystem should not be synchronised when this option is enabled. | +| 0016 | `.nosync` directory exclusion validation | Personal
Business | This test validates the 'check_nosync' feature. Directories containing a `.nosync` marker file should be excluded from synchronisation. | +| 0017 | `.nosync` mount protection validation | Personal
Business | This test validates the 'check_nomount' safeguard. When a `.nosync` marker exists in the mount point of the synchronisation directory, the client should abort synchronisation to prevent unintended operations.| +| 0018 | Recycle bin integration validation | Personal
Business | This test validates integration with the FreeDesktop-compliant recycle bin. When files are deleted remotely, the client should move them into the configured recycle bin instead of permanently deleting them when the feature is enabled.| +| 0019 | Logging and runtime configuration validation | Personal
Business | This test validates that the client correctly writes logs to a configured 'log_dir' and that `--display-running-config` outputs the effective runtime configuration. | +| 0020 | Monitor mode real-time sync validation | Personal
Business | This test validates that when the client runs in `--monitor mode`, filesystem changes made while the process is running are automatically detected and synchronised without manually re-running the client. | +| 0021 | Resumable upload recovery validation | Personal
Business | This test validates resumable upload session behaviour. A large file upload is intentionally interrupted and then resumed during a subsequent client execution. The test confirms that the upload successfully completes. | +| 0022 | Local-first conflict resolution validation | Personal
Business | This test validates the 'local_first' configuration option. When a file conflict occurs between local and remote versions, the client should treat the local file as the authoritative source and update the remote version accordingly. | +| 0023 | Bypass data preservation validation | Personal
Business | This test validates the behaviour of the 'bypass_data_preservation' option. When enabled, the client should suppress the creation of safe-backup files during conflict resolution and allow remote content to overwrite local changes. | +| 0024 | Big delete safeguard validation | Personal
Business | This test validates the 'classify_as_big_delete' protection mechanism. When a large number of items are deleted locally, the client should halt synchronisation and emit a warning. The deletion should only proceed after the user explicitly acknowledges the action using `--force`. | + diff --git a/readme.md b/readme.md index c13b0953a..9c6d7c393 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,8 @@ [![Release Date](https://img.shields.io/github/release-date/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) [![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) -[![End to End Testing](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![Build Docker Images](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/driveone/onedrive)](https://hub.docker.com/r/driveone/onedrive) From a0f1221b5abe3d63039df6f7d3d6d5ea801c4a55 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 14:23:45 +1100 Subject: [PATCH 069/245] Update PR Update PR --- .../tc0024_big_delete_safeguard_validation.py | 64 +++++++++++++------ docs/end_to_end_testing.md | 8 ++- readme.md | 7 +- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index 7cd7ac832..7c0826860 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -69,11 +69,14 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(conf_local) context.bootstrap_config_dir(conf_verify) + root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" + # Mirror the manual validation structure: # sync_dir/ - # random_1K_files/ - # <10 dirs>/ - # <10 files each> + # ZZ_E2E_TC0024__/ + # random_1K_files/ + # <10 dirs>/ + # <10 files each> parent_dir_name = "random_1K_files" classify_threshold = 5 sibling_dir_count = 10 @@ -87,18 +90,21 @@ def run(self, context: E2EContext) -> TestResult: delete_dir_relative = f"{parent_dir_name}/{delete_dir_name}" keep_file_relative = f"{parent_dir_name}/{keep_dir_name}/file0.data" - delete_dir_local = local_root / parent_dir_name / delete_dir_name - keep_file_local = local_root / parent_dir_name / keep_dir_name / "file0.data" + remote_delete_dir = f"{root_name}/{delete_dir_relative}" + remote_deleted_probe_file = f"{remote_delete_dir}/file0.data" + remote_keep_file = f"{root_name}/{keep_file_relative}" + + delete_dir_local = local_root / root_name / parent_dir_name / delete_dir_name + keep_file_local = local_root / root_name / parent_dir_name / keep_dir_name / "file0.data" for dir_index in range(sibling_dir_count): - child_dir = local_root / parent_dir_name / f"dir_{dir_index:02d}" + child_dir = local_root / root_name / parent_dir_name / f"dir_{dir_index:02d}" for file_index in range(files_per_dir): write_text_file( child_dir / f"file{file_index}.data", - f"tc0024 dir={dir_index} file={file_index}\n", + f"tc0024 root={root_name} dir={dir_index} file={file_index}\n", ) - # Step 1: initial upload without classify_as_big_delete configured. self._write_config(conf_local / "config", local_root, None) self._write_config(conf_verify / "config", verify_root, classify_threshold) @@ -132,6 +138,8 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--resync", "--resync-auth", + "--single-directory", + root_name, "--confdir", str(conf_local), ] @@ -151,6 +159,8 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--single-directory", + root_name, "--confdir", str(conf_local), ] @@ -165,15 +175,15 @@ def run(self, context: E2EContext) -> TestResult: missing_local_items: list[str] = [] if not delete_dir_local.is_dir(): - missing_local_items.append(delete_dir_relative) + missing_local_items.append(remote_delete_dir) for file_index in range(files_per_dir): candidate = delete_dir_local / f"file{file_index}.data" if not candidate.is_file(): - missing_local_items.append(f"{delete_dir_relative}/file{file_index}.data") + missing_local_items.append(f"{remote_delete_dir}/file{file_index}.data") if not keep_file_local.is_file(): - missing_local_items.append(keep_file_relative) + missing_local_items.append(remote_keep_file) if missing_local_items: write_text_file( @@ -181,6 +191,7 @@ def run(self, context: E2EContext) -> TestResult: "\n".join( [ f"case_id={self.case_id}", + f"root_name={root_name}", f"seed_returncode={seed_result.returncode}", f"option_change_returncode={option_change_result.returncode}", f"missing_local_items={missing_local_items!r}", @@ -199,6 +210,7 @@ def run(self, context: E2EContext) -> TestResult: details = { "seed_returncode": seed_result.returncode, "option_change_returncode": option_change_result.returncode, + "root_name": root_name, } return TestResult.fail_result( @@ -218,6 +230,8 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--single-directory", + root_name, "--confdir", str(conf_local), ] @@ -243,6 +257,8 @@ def run(self, context: E2EContext) -> TestResult: "--download-only", "--resync", "--resync-auth", + "--single-directory", + root_name, "--confdir", str(conf_verify), ] @@ -264,6 +280,8 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--verbose", "--force", + "--single-directory", + root_name, "--confdir", str(conf_local), ] @@ -286,6 +304,8 @@ def run(self, context: E2EContext) -> TestResult: "--download-only", "--resync", "--resync-auth", + "--single-directory", + root_name, "--confdir", str(conf_verify), ] @@ -305,6 +325,7 @@ def run(self, context: E2EContext) -> TestResult: "\n".join( [ f"case_id={self.case_id}", + f"root_name={root_name}", f"local_root={local_root}", f"verify_root={verify_root}", f"local_confdir={conf_local}", @@ -315,6 +336,9 @@ def run(self, context: E2EContext) -> TestResult: f"files_per_dir={files_per_dir}", f"delete_dir_relative={delete_dir_relative}", f"keep_file_relative={keep_file_relative}", + f"remote_delete_dir={remote_delete_dir}", + f"remote_deleted_probe_file={remote_deleted_probe_file}", + f"remote_keep_file={remote_keep_file}", f"seed_returncode={seed_result.returncode}", f"option_change_returncode={option_change_result.returncode}", f"blocked_returncode={blocked_result.returncode}", @@ -350,6 +374,7 @@ def run(self, context: E2EContext) -> TestResult: "blocked_verify_returncode": blocked_verify_result.returncode, "forced_returncode": forced_result.returncode, "verify_returncode": verify_result.returncode, + "root_name": root_name, } for label, rc in [ @@ -383,7 +408,7 @@ def run(self, context: E2EContext) -> TestResult: ) # Before --force, the deleted directory and one known file beneath it must still exist remotely. - if delete_dir_relative not in blocked_remote_manifest: + if remote_delete_dir not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, self.name, @@ -392,17 +417,16 @@ def run(self, context: E2EContext) -> TestResult: details, ) - deleted_probe_file = f"{delete_dir_relative}/file0.data" - if deleted_probe_file not in blocked_remote_manifest: + if remote_deleted_probe_file not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, self.name, - f"{deleted_probe_file} was modified before forced acknowledgement", + f"{remote_deleted_probe_file} was modified before forced acknowledgement", artifacts, details, ) - if keep_file_relative not in blocked_remote_manifest: + if remote_keep_file not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, self.name, @@ -412,7 +436,7 @@ def run(self, context: E2EContext) -> TestResult: ) # After --force, the deleted directory must be gone remotely. - if delete_dir_relative in remote_manifest: + if remote_delete_dir in remote_manifest: return TestResult.fail_result( self.case_id, self.name, @@ -421,16 +445,16 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if deleted_probe_file in remote_manifest: + if remote_deleted_probe_file in remote_manifest: return TestResult.fail_result( self.case_id, self.name, - f"{deleted_probe_file} still exists online after acknowledged forced delete", + f"{remote_deleted_probe_file} still exists online after acknowledged forced delete", artifacts, details, ) - if keep_file_relative not in remote_manifest: + if remote_keep_file not in remote_manifest: return TestResult.fail_result( self.case_id, self.name, diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 12e55ecd9..6b9920e78 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -3,11 +3,17 @@ [![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +This document describes the **end-to-end (E2E) automated testing framework** used to validate the behaviour of the OneDrive Client for Linux. + +The test suite runs inside **GitHub Actions** and performs **real synchronisation operations against Microsoft OneDrive** using dedicated test accounts. + +The objective of the test framework is to ensure that all client functionality continues to operate correctly across future changes to the application. + + | Test Case | Description | Account Coverage | Test Details | |:-------|:-------------|:-------|:-------| | 0001 | Basic Resync | Personal
Business | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | | 0002 | 'sync_list' Validation | Personal
Business | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| - | 0003 | Dry-run safety validation | Personal
Business | This test validates that running the client with `--dry-run` performs a full synchronisation analysis without making any changes locally or remotely. Files and directories are created in the test environment, the client is executed with `--dry-run`, and the test verifies that no filesystem or remote changes occur.| | 0004 | Single-directory sync scope validation | Personal
Business | This test validates that the `--single-directory` option restricts synchronisation to the specified directory subtree. Only the nominated directory should be synchronised, while other directories outside the scope must remain untouched. | | 0005 | Force-sync override validation | Personal
Business | This test validates that the `--force-sync` option overrides blocking conditions that would normally prevent synchronisation of a single-directory scope. The test confirms that forced synchronisation proceeds even when skip rules would otherwise block the operation. | diff --git a/readme.md b/readme.md index 9c6d7c393..7e8487dec 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,7 @@ [![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) [![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) + [![Build Docker Images](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/driveone/onedrive)](https://hub.docker.com/r/driveone/onedrive) @@ -170,7 +171,11 @@ If you encounter any issues running the application, please follow these steps * - See [Compatibility with curl](https://github.com/abraunegg/onedrive/blob/master/docs/usage.md#compatibility-with-curl) for details on curl bugs that impact this client. - Refer to the official [cURL Releases](https://curl.se/docs/releases.html) page for version information. -6. **Open a new issue** +6. **Perform a --resync** + In some cases, a `--resync` is needed to ensure your data is correctly synced. This option instructs the client to delete its local state database and fully rebuild it from the current online OneDrive contents. This is a powerful recovery and re-alignment action that should be used sparingly and with care. + - See [Performing a --resync](https://github.com/abraunegg/onedrive/blob/master/docs/usage.md#performing-a---resync) for further details. + +7. **Open a new issue** If the problem persists after completing the steps above, proceed to **Reporting an Issue or Bug** below and open a new issue with the requested details and logs. From 5ed3381653a98258ad034c0315871b172e7bf25c Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 14:33:47 +1100 Subject: [PATCH 070/245] Fix tc0024 Fix tc0024 --- .../tc0024_big_delete_safeguard_validation.py | 122 +++++++----------- docs/end_to_end_testing.md | 48 +++---- 2 files changed, 74 insertions(+), 96 deletions(-) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index 7c0826860..7b47bd1a5 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -71,12 +71,6 @@ def run(self, context: E2EContext) -> TestResult: root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" - # Mirror the manual validation structure: - # sync_dir/ - # ZZ_E2E_TC0024__/ - # random_1K_files/ - # <10 dirs>/ - # <10 files each> parent_dir_name = "random_1K_files" classify_threshold = 5 sibling_dir_count = 10 @@ -91,12 +85,11 @@ def run(self, context: E2EContext) -> TestResult: keep_file_relative = f"{parent_dir_name}/{keep_dir_name}/file0.data" remote_delete_dir = f"{root_name}/{delete_dir_relative}" - remote_deleted_probe_file = f"{remote_delete_dir}/file0.data" remote_keep_file = f"{root_name}/{keep_file_relative}" delete_dir_local = local_root / root_name / parent_dir_name / delete_dir_name - keep_file_local = local_root / root_name / parent_dir_name / keep_dir_name / "file0.data" + # Create test content for dir_index in range(sibling_dir_count): child_dir = local_root / root_name / parent_dir_name / f"dir_{dir_index:02d}" for file_index in range(files_per_dir): @@ -151,7 +144,24 @@ def run(self, context: E2EContext) -> TestResult: seed_stderr, ) - # Step 2: update config and run a normal sync, matching the manual flow. + if seed_result.returncode != 0: + artifacts = [ + str(seed_stdout), + str(seed_stderr), + ] + details = { + "seed_returncode": seed_result.returncode, + "root_name": root_name, + } + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {seed_result.returncode}", + artifacts, + details, + ) + + # Update config to enable big delete safeguard self._write_config(conf_local / "config", local_root, classify_threshold) option_change_command = [ @@ -172,58 +182,49 @@ def run(self, context: E2EContext) -> TestResult: option_change_stderr, ) - missing_local_items: list[str] = [] - - if not delete_dir_local.is_dir(): - missing_local_items.append(remote_delete_dir) - - for file_index in range(files_per_dir): - candidate = delete_dir_local / f"file{file_index}.data" - if not candidate.is_file(): - missing_local_items.append(f"{remote_delete_dir}/file{file_index}.data") - - if not keep_file_local.is_file(): - missing_local_items.append(remote_keep_file) - - if missing_local_items: - write_text_file( - metadata_file, - "\n".join( - [ - f"case_id={self.case_id}", - f"root_name={root_name}", - f"seed_returncode={seed_result.returncode}", - f"option_change_returncode={option_change_result.returncode}", - f"missing_local_items={missing_local_items!r}", - ] - ) - + "\n", + if option_change_result.returncode != 0: + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(option_change_stdout), + str(option_change_stderr), + ] + details = { + "seed_returncode": seed_result.returncode, + "option_change_returncode": option_change_result.returncode, + "root_name": root_name, + } + return TestResult.fail_result( + self.case_id, + self.name, + f"option change validation phase failed with status {option_change_result.returncode}", + artifacts, + details, ) + # Delete just the directory locally. The client handles the contained file deletions. + if not delete_dir_local.is_dir(): artifacts = [ str(seed_stdout), str(seed_stderr), str(option_change_stdout), str(option_change_stderr), - str(metadata_file), ] details = { "seed_returncode": seed_result.returncode, "option_change_returncode": option_change_result.returncode, "root_name": root_name, + "delete_dir_local": str(delete_dir_local), } - return TestResult.fail_result( self.case_id, self.name, - "Expected local baseline content was not present before delete phase", + "Expected local delete directory was not present before delete phase", artifacts, details, ) - # Step 3: delete one entire child directory locally. - if delete_dir_local.exists(): - shutil.rmtree(delete_dir_local) + shutil.rmtree(delete_dir_local) blocked_command = [ context.onedrive_bin, @@ -245,7 +246,7 @@ def run(self, context: E2EContext) -> TestResult: blocked_output = blocked_result.stdout + "\n" + blocked_result.stderr - # Verify remote state after blocked sync. + # Verify remote state after blocked sync reset_directory(verify_root) self._write_config(conf_verify / "config", verify_root, classify_threshold) @@ -273,7 +274,7 @@ def run(self, context: E2EContext) -> TestResult: blocked_remote_manifest = build_manifest(verify_root) write_manifest(blocked_verify_manifest_file, blocked_remote_manifest) - # Step 4: rerun with --force. + # Force acknowledgement and retry forced_command = [ context.onedrive_bin, "--display-running-config", @@ -337,7 +338,6 @@ def run(self, context: E2EContext) -> TestResult: f"delete_dir_relative={delete_dir_relative}", f"keep_file_relative={keep_file_relative}", f"remote_delete_dir={remote_delete_dir}", - f"remote_deleted_probe_file={remote_deleted_probe_file}", f"remote_keep_file={remote_keep_file}", f"seed_returncode={seed_result.returncode}", f"option_change_returncode={option_change_result.returncode}", @@ -378,8 +378,6 @@ def run(self, context: E2EContext) -> TestResult: } for label, rc in [ - ("seed", seed_result.returncode), - ("option change validation", option_change_result.returncode), ("blocked verify", blocked_verify_result.returncode), ("forced sync", forced_result.returncode), ("verify", verify_result.returncode), @@ -393,12 +391,10 @@ def run(self, context: E2EContext) -> TestResult: details, ) - safeguard_markers = [ - "ERROR: An attempt to remove a large volume of data from OneDrive has been detected", - "ERROR: The total number of items being deleted is:", - "ERROR: To delete a large volume of data use --force", - ] - if not all(marker in blocked_output for marker in safeguard_markers): + primary_safeguard_marker = ( + "ERROR: An attempt to remove a large volume of data from OneDrive has been detected" + ) + if primary_safeguard_marker not in blocked_output: return TestResult.fail_result( self.case_id, self.name, @@ -407,7 +403,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Before --force, the deleted directory and one known file beneath it must still exist remotely. + # Before --force, deleted directory must still exist remotely and sibling content must remain. if remote_delete_dir not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, @@ -417,15 +413,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if remote_deleted_probe_file not in blocked_remote_manifest: - return TestResult.fail_result( - self.case_id, - self.name, - f"{remote_deleted_probe_file} was modified before forced acknowledgement", - artifacts, - details, - ) - if remote_keep_file not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, @@ -435,7 +422,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # After --force, the deleted directory must be gone remotely. + # After --force, the deleted directory must be gone remotely, but sibling content must remain. if remote_delete_dir in remote_manifest: return TestResult.fail_result( self.case_id, @@ -445,15 +432,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if remote_deleted_probe_file in remote_manifest: - return TestResult.fail_result( - self.case_id, - self.name, - f"{remote_deleted_probe_file} still exists online after acknowledged forced delete", - artifacts, - details, - ) - if remote_keep_file not in remote_manifest: return TestResult.fail_result( self.case_id, diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 6b9920e78..d774cb49b 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -12,28 +12,28 @@ The objective of the test framework is to ensure that all client functionality c | Test Case | Description | Account Coverage | Test Details | |:-------|:-------------|:-------|:-------| -| 0001 | Basic Resync | Personal
Business | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | -| 0002 | 'sync_list' Validation | Personal
Business | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| -| 0003 | Dry-run safety validation | Personal
Business | This test validates that running the client with `--dry-run` performs a full synchronisation analysis without making any changes locally or remotely. Files and directories are created in the test environment, the client is executed with `--dry-run`, and the test verifies that no filesystem or remote changes occur.| -| 0004 | Single-directory sync scope validation | Personal
Business | This test validates that the `--single-directory` option restricts synchronisation to the specified directory subtree. Only the nominated directory should be synchronised, while other directories outside the scope must remain untouched. | -| 0005 | Force-sync override validation | Personal
Business | This test validates that the `--force-sync` option overrides blocking conditions that would normally prevent synchronisation of a single-directory scope. The test confirms that forced synchronisation proceeds even when skip rules would otherwise block the operation. | -| 0006 | Download-only sync validation | Personal
Business | This test validates the behaviour of the `--download-only` option. Remote content is seeded in OneDrive and the client is executed in download-only mode. The test verifies that the remote data is correctly downloaded locally without performing any upload operations. | -| 0007 | Download-only cleanup validation | Personal
Business | This test validates the behaviour of `--cleanup-local-files` when used with `--download-only`. Local files that no longer exist remotely should be removed during synchronisation while valid remote content is preserved. | -| 0008 | Upload-only sync validation | Personal
Business | This test validates that `--upload-only` mode correctly uploads local files and directories to OneDrive. The test ensures that no download operations occur and that remote content reflects the locally created files. | -| 0009 | Upload-only remote preservation validation | Personal
Business | This test validates the behaviour of the `--no-remote-delete` option when used with `--upload-only` mode. The test confirms that remote files are not deleted even when they do not exist locally. | -| 0010 | Upload-only source removal validation | Personal
Business | This test validates the `--remove-source-files` option used with `--upload-only` mode. After files are successfully uploaded to OneDrive, the local source files should be automatically removed. | -| 0011 | Skip-file pattern validation | Personal
Business | This test validates that `skip_file` configuration patterns correctly exclude matching files from synchronisation. Files matching the configured patterns should not be uploaded or downloaded. | -| 0012 | Skip-directory rule validation | Personal
Business | This test validates the behaviour of the 'skip_dir' option and the 'skip_dir_strict_match' setting. The test confirms that directories matching the configured patterns are correctly excluded from synchronisation. | -| 0013 | Dotfile exclusion validation | Personal
Business | This test validates the 'skip_dotfiles' option. Files and directories beginning with a dot (`.`) should be excluded from synchronisation when this option is enabled. | -| 0014 | File size exclusion validation | Personal
Business | This test validates the 'skip_size' option. Files exceeding the configured size threshold should be excluded from synchronisation.| -| 0015 | Symlink exclusion validation | Personal
Business | This test validates the 'skip_symlinks' configuration option. Symbolic links present in the local filesystem should not be synchronised when this option is enabled. | -| 0016 | `.nosync` directory exclusion validation | Personal
Business | This test validates the 'check_nosync' feature. Directories containing a `.nosync` marker file should be excluded from synchronisation. | -| 0017 | `.nosync` mount protection validation | Personal
Business | This test validates the 'check_nomount' safeguard. When a `.nosync` marker exists in the mount point of the synchronisation directory, the client should abort synchronisation to prevent unintended operations.| -| 0018 | Recycle bin integration validation | Personal
Business | This test validates integration with the FreeDesktop-compliant recycle bin. When files are deleted remotely, the client should move them into the configured recycle bin instead of permanently deleting them when the feature is enabled.| -| 0019 | Logging and runtime configuration validation | Personal
Business | This test validates that the client correctly writes logs to a configured 'log_dir' and that `--display-running-config` outputs the effective runtime configuration. | -| 0020 | Monitor mode real-time sync validation | Personal
Business | This test validates that when the client runs in `--monitor mode`, filesystem changes made while the process is running are automatically detected and synchronised without manually re-running the client. | -| 0021 | Resumable upload recovery validation | Personal
Business | This test validates resumable upload session behaviour. A large file upload is intentionally interrupted and then resumed during a subsequent client execution. The test confirms that the upload successfully completes. | -| 0022 | Local-first conflict resolution validation | Personal
Business | This test validates the 'local_first' configuration option. When a file conflict occurs between local and remote versions, the client should treat the local file as the authoritative source and update the remote version accordingly. | -| 0023 | Bypass data preservation validation | Personal
Business | This test validates the behaviour of the 'bypass_data_preservation' option. When enabled, the client should suppress the creation of safe-backup files during conflict resolution and allow remote content to overwrite local changes. | -| 0024 | Big delete safeguard validation | Personal
Business | This test validates the 'classify_as_big_delete' protection mechanism. When a large number of items are deleted locally, the client should halt synchronisation and emit a warning. The deletion should only proceed after the user explicitly acknowledges the action using `--force`. | +| 0001 | Basic Resync | - Personal
- Business | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | +| 0002 | 'sync_list' Validation | - Personal
- Business | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| +| 0003 | Dry-run safety validation | - Personal
- Business | This test validates that running the client with `--dry-run` performs a full synchronisation analysis without making any changes locally or remotely. Files and directories are created in the test environment, the client is executed with `--dry-run`, and the test verifies that no filesystem or remote changes occur.| +| 0004 | Single-directory sync scope validation | - Personal
- Business | This test validates that the `--single-directory` option restricts synchronisation to the specified directory subtree. Only the nominated directory should be synchronised, while other directories outside the scope must remain untouched. | +| 0005 | Force-sync override validation | - Personal
- Business | This test validates that the `--force-sync` option overrides blocking conditions that would normally prevent synchronisation of a single-directory scope. The test confirms that forced synchronisation proceeds even when skip rules would otherwise block the operation. | +| 0006 | Download-only sync validation | - Personal
- Business | This test validates the behaviour of the `--download-only` option. Remote content is seeded in OneDrive and the client is executed in download-only mode. The test verifies that the remote data is correctly downloaded locally without performing any upload operations. | +| 0007 | Download-only cleanup validation | - Personal
- Business | This test validates the behaviour of `--cleanup-local-files` when used with `--download-only`. Local files that no longer exist remotely should be removed during synchronisation while valid remote content is preserved. | +| 0008 | Upload-only sync validation | - Personal
- Business | This test validates that `--upload-only` mode correctly uploads local files and directories to OneDrive. The test ensures that no download operations occur and that remote content reflects the locally created files. | +| 0009 | Upload-only remote preservation validation | - Personal
- Business | This test validates the behaviour of the `--no-remote-delete` option when used with `--upload-only` mode. The test confirms that remote files are not deleted even when they do not exist locally. | +| 0010 | Upload-only source removal validation | - Personal
- Business | This test validates the `--remove-source-files` option used with `--upload-only` mode. After files are successfully uploaded to OneDrive, the local source files should be automatically removed. | +| 0011 | Skip-file pattern validation | - Personal
- Business | This test validates that `skip_file` configuration patterns correctly exclude matching files from synchronisation. Files matching the configured patterns should not be uploaded or downloaded. | +| 0012 | Skip-directory rule validation | - Personal
- Business | This test validates the behaviour of the 'skip_dir' option and the 'skip_dir_strict_match' setting. The test confirms that directories matching the configured patterns are correctly excluded from synchronisation. | +| 0013 | Dotfile exclusion validation | - Personal
- Business | This test validates the 'skip_dotfiles' option. Files and directories beginning with a dot (`.`) should be excluded from synchronisation when this option is enabled. | +| 0014 | File size exclusion validation | - Personal
- Business | This test validates the 'skip_size' option. Files exceeding the configured size threshold should be excluded from synchronisation.| +| 0015 | Symlink exclusion validation | - Personal
- Business | This test validates the 'skip_symlinks' configuration option. Symbolic links present in the local filesystem should not be synchronised when this option is enabled. | +| 0016 | `.nosync` directory exclusion validation | - Personal
- Business | This test validates the 'check_nosync' feature. Directories containing a `.nosync` marker file should be excluded from synchronisation. | +| 0017 | `.nosync` mount protection validation | - Personal
- Business | This test validates the 'check_nomount' safeguard. When a `.nosync` marker exists in the mount point of the synchronisation directory, the client should abort synchronisation to prevent unintended operations.| +| 0018 | Recycle bin integration validation | - Personal
- Business | This test validates integration with the FreeDesktop-compliant recycle bin. When files are deleted remotely, the client should move them into the configured recycle bin instead of permanently deleting them when the feature is enabled.| +| 0019 | Logging and runtime configuration validation | - Personal
- Business | This test validates that the client correctly writes logs to a configured 'log_dir' and that `--display-running-config` outputs the effective runtime configuration. | +| 0020 | Monitor mode real-time sync validation | - Personal
- Business | This test validates that when the client runs in `--monitor mode`, filesystem changes made while the process is running are automatically detected and synchronised without manually re-running the client. | +| 0021 | Resumable upload recovery validation | - Personal
- Business | This test validates resumable upload session behaviour. A large file upload is intentionally interrupted and then resumed during a subsequent client execution. The test confirms that the upload successfully completes. | +| 0022 | Local-first conflict resolution validation | - Personal
- Business | This test validates the 'local_first' configuration option. When a file conflict occurs between local and remote versions, the client should treat the local file as the authoritative source and update the remote version accordingly. | +| 0023 | Bypass data preservation validation | - Personal
- Business | This test validates the behaviour of the 'bypass_data_preservation' option. When enabled, the client should suppress the creation of safe-backup files during conflict resolution and allow remote content to overwrite local changes. | +| 0024 | Big delete safeguard validation | - Personal
- Business | This test validates the 'classify_as_big_delete' protection mechanism. When a large number of items are deleted locally, the client should halt synchronisation and emit a warning. The deletion should only proceed after the user explicitly acknowledges the action using `--force`. | From a0841fd8cf54cb8d6970c63dc7d182740726e894 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 14:41:28 +1100 Subject: [PATCH 071/245] Update tc0024_big_delete_safeguard_validation.py * Fix tc0024 --- .../tc0024_big_delete_safeguard_validation.py | 115 ++++++++++-------- 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index 7b47bd1a5..0c856b15b 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -20,17 +20,13 @@ def _write_config( self, config_path: Path, sync_dir: Path, - classify_as_big_delete: int | None = None, + classify_as_big_delete: int, ) -> None: config_lines = [ "# tc0024 config", f'sync_dir = "{sync_dir}"', - 'bypass_data_preservation = "true"', + f'classify_as_big_delete = "{classify_as_big_delete}"', ] - - if classify_as_big_delete is not None: - config_lines.append(f'classify_as_big_delete = "{classify_as_big_delete}"') - write_text_file(config_path, "\n".join(config_lines) + "\n") def _run_and_capture( @@ -69,36 +65,50 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(conf_local) context.bootstrap_config_dir(conf_verify) - root_name = f"ZZ_E2E_TC0024_{context.run_id}_{os.getpid()}" - parent_dir_name = "random_1K_files" + initial_threshold = 1000 classify_threshold = 5 sibling_dir_count = 10 files_per_dir = 10 - delete_dir_index = 3 + delete_dir_index = 2 keep_dir_index = 7 - delete_dir_name = f"dir_{delete_dir_index:02d}" - keep_dir_name = f"dir_{keep_dir_index:02d}" + # Use deterministic random-looking directory names to mirror the proven manual workflow + dir_names = [ + "q0NToXSgyrO8R5XO9t3jkzmVfEu4WCVh", + "RlWYV0dKiI096pt5F9eXg6jGZGUejI30", + "70M1EMwQUqzzQU4c8ua7C4DVvzo7KUWO", + "9systOMPHWQ7TozssIbYZFPGgPhQA9vt", + "ilmjoysWYI1EnbLscBmYxc5H9ikqLZ4Z", + "ZNZnjOCA83dutD8d3SD6j87CGeYMnCMH", + "qtkaQHpZcMbM7GJnNUBfBwJ4YLxfmxp3", + "YwTfxsBmSgSaCS39vpgEswNU27wJcogI", + "oWvTo5vd9rLJI3KB1RErqAH8fy4sjQjp", + "aUmMwbQVWImEHEr555QyHqHveKMT0XGJ", + ] + + delete_dir_name = dir_names[delete_dir_index] + keep_dir_name = dir_names[keep_dir_index] delete_dir_relative = f"{parent_dir_name}/{delete_dir_name}" keep_file_relative = f"{parent_dir_name}/{keep_dir_name}/file0.data" - remote_delete_dir = f"{root_name}/{delete_dir_relative}" - remote_keep_file = f"{root_name}/{keep_file_relative}" + remote_delete_dir = delete_dir_relative + remote_deleted_probe_file = f"{delete_dir_relative}/file0.data" + remote_keep_file = keep_file_relative - delete_dir_local = local_root / root_name / parent_dir_name / delete_dir_name + delete_dir_local = local_root / parent_dir_name / delete_dir_name - # Create test content - for dir_index in range(sibling_dir_count): - child_dir = local_root / root_name / parent_dir_name / f"dir_{dir_index:02d}" + # Create 10 directories x 10 files = 100 files + for dir_name in dir_names: + child_dir = local_root / parent_dir_name / dir_name for file_index in range(files_per_dir): write_text_file( child_dir / f"file{file_index}.data", - f"tc0024 root={root_name} dir={dir_index} file={file_index}\n", + f"tc0024 dir={dir_name} file={file_index}\n", ) - self._write_config(conf_local / "config", local_root, None) + self._write_config(conf_local / "config", local_root, initial_threshold) self._write_config(conf_verify / "config", verify_root, classify_threshold) seed_stdout = case_log_dir / "seed_stdout.log" @@ -123,6 +133,7 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" + # Step 1: upload all data with a high threshold, matching the manual process seed_command = [ context.onedrive_bin, "--display-running-config", @@ -131,8 +142,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--resync", "--resync-auth", - "--single-directory", - root_name, "--confdir", str(conf_local), ] @@ -145,14 +154,8 @@ def run(self, context: E2EContext) -> TestResult: ) if seed_result.returncode != 0: - artifacts = [ - str(seed_stdout), - str(seed_stderr), - ] - details = { - "seed_returncode": seed_result.returncode, - "root_name": root_name, - } + artifacts = [str(seed_stdout), str(seed_stderr)] + details = {"seed_returncode": seed_result.returncode} return TestResult.fail_result( self.case_id, self.name, @@ -161,7 +164,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Update config to enable big delete safeguard + # Step 2: update config to enable big delete safeguard and run again with no changes self._write_config(conf_local / "config", local_root, classify_threshold) option_change_command = [ @@ -169,8 +172,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--single-directory", - root_name, "--confdir", str(conf_local), ] @@ -192,7 +193,6 @@ def run(self, context: E2EContext) -> TestResult: details = { "seed_returncode": seed_result.returncode, "option_change_returncode": option_change_result.returncode, - "root_name": root_name, } return TestResult.fail_result( self.case_id, @@ -202,7 +202,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Delete just the directory locally. The client handles the contained file deletions. + # Step 3: remove one entire directory locally if not delete_dir_local.is_dir(): artifacts = [ str(seed_stdout), @@ -213,7 +213,6 @@ def run(self, context: E2EContext) -> TestResult: details = { "seed_returncode": seed_result.returncode, "option_change_returncode": option_change_result.returncode, - "root_name": root_name, "delete_dir_local": str(delete_dir_local), } return TestResult.fail_result( @@ -231,8 +230,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--single-directory", - root_name, "--confdir", str(conf_local), ] @@ -258,8 +255,6 @@ def run(self, context: E2EContext) -> TestResult: "--download-only", "--resync", "--resync-auth", - "--single-directory", - root_name, "--confdir", str(conf_verify), ] @@ -274,15 +269,13 @@ def run(self, context: E2EContext) -> TestResult: blocked_remote_manifest = build_manifest(verify_root) write_manifest(blocked_verify_manifest_file, blocked_remote_manifest) - # Force acknowledgement and retry + # Step 4: rerun with --force forced_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--force", - "--single-directory", - root_name, "--confdir", str(conf_local), ] @@ -305,8 +298,6 @@ def run(self, context: E2EContext) -> TestResult: "--download-only", "--resync", "--resync-auth", - "--single-directory", - root_name, "--confdir", str(conf_verify), ] @@ -326,11 +317,11 @@ def run(self, context: E2EContext) -> TestResult: "\n".join( [ f"case_id={self.case_id}", - f"root_name={root_name}", f"local_root={local_root}", f"verify_root={verify_root}", f"local_confdir={conf_local}", f"verify_confdir={conf_verify}", + f"initial_threshold={initial_threshold}", f"classify_as_big_delete={classify_threshold}", f"parent_dir_name={parent_dir_name}", f"sibling_dir_count={sibling_dir_count}", @@ -338,6 +329,7 @@ def run(self, context: E2EContext) -> TestResult: f"delete_dir_relative={delete_dir_relative}", f"keep_file_relative={keep_file_relative}", f"remote_delete_dir={remote_delete_dir}", + f"remote_deleted_probe_file={remote_deleted_probe_file}", f"remote_keep_file={remote_keep_file}", f"seed_returncode={seed_result.returncode}", f"option_change_returncode={option_change_result.returncode}", @@ -374,7 +366,6 @@ def run(self, context: E2EContext) -> TestResult: "blocked_verify_returncode": blocked_verify_result.returncode, "forced_returncode": forced_result.returncode, "verify_returncode": verify_result.returncode, - "root_name": root_name, } for label, rc in [ @@ -391,10 +382,12 @@ def run(self, context: E2EContext) -> TestResult: details, ) - primary_safeguard_marker = ( - "ERROR: An attempt to remove a large volume of data from OneDrive has been detected" - ) - if primary_safeguard_marker not in blocked_output: + safeguard_markers = [ + "ERROR: An attempt to remove a large volume of data from OneDrive has been detected", + "ERROR: The total number of items being deleted is:", + "ERROR: To delete a large volume of data use --force", + ] + if not all(marker in blocked_output for marker in safeguard_markers): return TestResult.fail_result( self.case_id, self.name, @@ -403,7 +396,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Before --force, deleted directory must still exist remotely and sibling content must remain. + # Before --force, the deleted directory and probe file must still exist remotely if remote_delete_dir not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, @@ -413,6 +406,15 @@ def run(self, context: E2EContext) -> TestResult: details, ) + if remote_deleted_probe_file not in blocked_remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"{remote_deleted_probe_file} was modified before forced acknowledgement", + artifacts, + details, + ) + if remote_keep_file not in blocked_remote_manifest: return TestResult.fail_result( self.case_id, @@ -422,7 +424,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # After --force, the deleted directory must be gone remotely, but sibling content must remain. + # After --force, deleted directory must be gone, sibling content must remain if remote_delete_dir in remote_manifest: return TestResult.fail_result( self.case_id, @@ -432,6 +434,15 @@ def run(self, context: E2EContext) -> TestResult: details, ) + if remote_deleted_probe_file in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"{remote_deleted_probe_file} still exists online after acknowledged forced delete", + artifacts, + details, + ) + if remote_keep_file not in remote_manifest: return TestResult.fail_result( self.case_id, From 5f957e6d5591c4882547433be60f5ffa397efbb4 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 15:26:42 +1100 Subject: [PATCH 072/245] Add Cleanup Add Cleanup --- ci/e2e/framework/context.py | 22 +++- ci/e2e/framework/utils.py | 194 +++++++++++++++++++++++++++++++++++- ci/e2e/run.py | 39 +++++++- 3 files changed, 252 insertions(+), 3 deletions(-) diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py index 2dc86421a..100ae2da5 100644 --- a/ci/e2e/framework/context.py +++ b/ci/e2e/framework/context.py @@ -100,4 +100,24 @@ def log(self, message: str) -> None: ensure_directory(self.out_dir) line = f"[{timestamp_now()}] {message}\n" print(line, end="") - write_text_file_append(self.master_log_file, line) \ No newline at end of file + write_text_file_append(self.master_log_file, line) + + @property + def default_sync_dir(self) -> Path: + home = os.environ.get("HOME", "").strip() + if not home: + raise RuntimeError("HOME is not set") + return Path(home) / "OneDrive" + + @property + def suite_cleanup_config_dir(self) -> Path: + return self.work_root / "suite-cleanup-conf" + + @property + def suite_cleanup_log_dir(self) -> Path: + return self.logs_dir / "_suite_cleanup" + + def bootstrap_suite_cleanup_config_dir(self) -> Path: + if self.suite_cleanup_config_dir.exists(): + shutil.rmtree(self.suite_cleanup_config_dir) + return self.bootstrap_config_dir(self.suite_cleanup_config_dir) \ No newline at end of file diff --git a/ci/e2e/framework/utils.py b/ci/e2e/framework/utils.py index 66f2976f0..839563fff 100644 --- a/ci/e2e/framework/utils.py +++ b/ci/e2e/framework/utils.py @@ -77,4 +77,196 @@ def run_command( def command_to_string(command: list[str]) -> str: - return " ".join(command) \ No newline at end of file + return " ".join(command) + +def purge_directory_contents(path: Path) -> None: + """ + Delete all files and folders inside 'path', but do not delete 'path' itself. + """ + ensure_directory(path) + + for child in path.iterdir(): + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + else: + child.unlink(missing_ok=True) + + +def run_command_logged( + command: list[str], + stdout_file: Path, + stderr_file: Path, + cwd: Path | None = None, + env: dict[str, str] | None = None, + input_text: str | None = None, +) -> CommandResult: + result = run_command( + command=command, + cwd=cwd, + env=env, + input_text=input_text, + ) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + return result + + +def perform_full_account_cleanup( + *, + onedrive_bin: str, + repo_root: Path, + config_dir: Path, + sync_dir: Path, + log_dir: Path, +) -> tuple[bool, str, list[str], dict]: + """ + Clean the entire account by: + 1. full resync/resync-auth + 2. deleting everything locally + 3. running sync to push deletes online + 4. running sync again to confirm no further transfers + + Returns: + (success, reason, artifacts, details) + """ + ensure_directory(log_dir) + ensure_directory(sync_dir) + + phase1_stdout = log_dir / "cleanup_phase1_resync_stdout.log" + phase1_stderr = log_dir / "cleanup_phase1_resync_stderr.log" + phase2_state = log_dir / "cleanup_phase2_local_purge_state.txt" + phase3_stdout = log_dir / "cleanup_phase3_push_deletes_stdout.log" + phase3_stderr = log_dir / "cleanup_phase3_push_deletes_stderr.log" + phase4_stdout = log_dir / "cleanup_phase4_verify_empty_stdout.log" + phase4_stderr = log_dir / "cleanup_phase4_verify_empty_stderr.log" + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_state), + str(phase3_stdout), + str(phase3_stderr), + str(phase4_stdout), + str(phase4_stderr), + ] + + phase1_command = [ + onedrive_bin, + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--confdir", + str(config_dir), + ] + phase1 = run_command_logged( + phase1_command, + stdout_file=phase1_stdout, + stderr_file=phase1_stderr, + cwd=repo_root, + ) + if phase1.returncode != 0: + return ( + False, + f"Cleanup phase 1 failed with status {phase1.returncode}", + artifacts, + {"phase1_returncode": phase1.returncode}, + ) + + purge_directory_contents(sync_dir) + + remaining_after_purge = [] + for child in sync_dir.iterdir(): + remaining_after_purge.append(str(child)) + write_text_file( + phase2_state, + "\n".join(remaining_after_purge) + ("\n" if remaining_after_purge else ""), + ) + + if remaining_after_purge: + return ( + False, + "Cleanup phase 2 failed: local sync directory is not empty after purge", + artifacts, + {"remaining_after_purge": remaining_after_purge}, + ) + + phase3_command = [ + onedrive_bin, + "--sync", + "--verbose", + "--confdir", + str(config_dir), + ] + phase3 = run_command_logged( + phase3_command, + stdout_file=phase3_stdout, + stderr_file=phase3_stderr, + cwd=repo_root, + ) + if phase3.returncode != 0: + return ( + False, + f"Cleanup phase 3 failed with status {phase3.returncode}", + artifacts, + {"phase3_returncode": phase3.returncode}, + ) + + phase4_command = [ + onedrive_bin, + "--sync", + "--verbose", + "--confdir", + str(config_dir), + ] + phase4 = run_command_logged( + phase4_command, + stdout_file=phase4_stdout, + stderr_file=phase4_stderr, + cwd=repo_root, + ) + if phase4.returncode != 0: + return ( + False, + f"Cleanup phase 4 failed with status {phase4.returncode}", + artifacts, + {"phase4_returncode": phase4.returncode}, + ) + + remaining_after_verify = [] + for child in sync_dir.iterdir(): + remaining_after_verify.append(str(child)) + + if remaining_after_verify: + return ( + False, + "Cleanup verification failed: local sync directory is not empty after final sync", + artifacts, + {"remaining_after_verify": remaining_after_verify}, + ) + + phase4_output = phase4.stdout + "\n" + phase4.stderr + suspicious_markers = [ + "Downloading ", + "Creating local directory:", + "Uploading ", + ] + detected_markers = [marker for marker in suspicious_markers if marker in phase4_output] + if detected_markers: + return ( + False, + "Cleanup verification failed: final sync still performed transfer activity", + artifacts, + {"detected_markers": detected_markers}, + ) + + return ( + True, + "", + artifacts, + { + "phase1_returncode": phase1.returncode, + "phase3_returncode": phase3.returncode, + "phase4_returncode": phase4.returncode, + }, + ) \ No newline at end of file diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 8308f688a..9f6935881 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -8,7 +8,7 @@ from framework.context import E2EContext from framework.result import TestResult -from framework.utils import ensure_directory, write_text_file +from framework.utils import ensure_directory, perform_full_account_cleanup, write_text_file from testcases.tc0001_basic_resync import TestCase0001BasicResync from testcases.tc0002_sync_list_validation import TestCase0002SyncListValidation from testcases.tc0003_dry_run_validation import TestCase0003DryRunValidation @@ -99,6 +99,43 @@ def main() -> int: ensure_directory(context.state_dir) ensure_directory(context.work_root) + context.bootstrap_suite_cleanup_config_dir() + + context.log("Starting suite-wide cleanup of local and remote OneDrive content") + + cleanup_ok, cleanup_reason, cleanup_artifacts, cleanup_details = perform_full_account_cleanup( + onedrive_bin=context.onedrive_bin, + repo_root=context.repo_root, + config_dir=context.suite_cleanup_config_dir, + sync_dir=context.default_sync_dir, + log_dir=context.suite_cleanup_log_dir, + ) + + if not cleanup_ok: + context.log(f"Suite cleanup FAILED: {cleanup_reason}") + + results = { + "target": context.e2e_target, + "run_id": context.run_id, + "cases": [ + { + "id": "0000", + "name": "suite cleanup", + "status": "fail", + "reason": cleanup_reason, + "artifacts": cleanup_artifacts, + "details": cleanup_details, + } + ], + } + + results_file = context.out_dir / "results.json" + results_json = json.dumps(results, indent=2, sort_keys=False) + write_text_file(results_file, results_json) + return 1 + + context.log("Suite-wide cleanup completed successfully") + context.log( f"Initialising E2E framework for target='{context.e2e_target}', " f"run_id='{context.run_id}'" From eac3ae30b2a75c4b1ff41b8f03662def3edb4e09 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 15:47:27 +1100 Subject: [PATCH 073/245] Add tc0025 Add tc0025 - invalid character filename validation --- ci/e2e/run.py | 2 + ...5_invalid_character_filename_validation.py | 260 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 ci/e2e/testcases/tc0025_invalid_character_filename_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 9f6935881..ef785d510 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -33,6 +33,7 @@ from testcases.tc0022_local_first_validation import TestCase0022LocalFirstValidation from testcases.tc0023_bypass_data_preservation_validation import TestCase0023BypassDataPreservationValidation from testcases.tc0024_big_delete_safeguard_validation import TestCase0024BigDeleteSafeguardValidation +from testcases.tc0025_invalid_character_filename_validation import TestCase0025InvalidCharacterFilenameValidation def build_test_suite() -> list: @@ -66,6 +67,7 @@ def build_test_suite() -> list: #TestCase0022LocalFirstValidation(), #TestCase0023BypassDataPreservationValidation(), TestCase0024BigDeleteSafeguardValidation(), + TestCase0025InvalidCharacterFilenameValidation(), ] diff --git a/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py b/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py new file mode 100644 index 000000000..2de5010ab --- /dev/null +++ b/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0025InvalidCharacterFilenameValidation(E2ETestCase): + case_id = "0025" + name = "invalid character filename validation" + description = "Validate invalid filename characters are blocked while valid sibling files still synchronise" + + def _write_config(self, config_path: Path) -> None: + write_text_file( + config_path, + "# tc0025 config\n" + 'bypass_data_preservation = "true"\n', + ) + + def _create_binary_file(self, path: Path, size_kb: int = 8) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = os.urandom(size_kb * 1024) + path.write_bytes(payload) + + def _run_and_capture( + self, + context: E2EContext, + label: str, + command: list[str], + stdout_file: Path, + stderr_file: Path, + ): + context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + return result + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0025" + case_log_dir = context.logs_dir / "tc0025" + state_dir = context.state_dir / "tc0025" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + confdir = case_work_dir / "conf-main" + verify_conf = case_work_dir / "conf-verify" + + reset_directory(sync_root) + reset_directory(verify_root) + + context.bootstrap_config_dir(confdir) + context.bootstrap_config_dir(verify_conf) + + self._write_config(confdir / "config") + self._write_config(verify_conf / "config") + + root_name = f"ZZ_E2E_TC0025_{context.run_id}_{os.getpid()}" + + valid_files = [ + f"{root_name}/valid_file_1.bin", + f"{root_name}/valid_file_2.txt", + f"{root_name}/valid_subdir/nested_valid_file.dat", + ] + + invalid_files = [ + f"{root_name}/includes < in the filename", + f"{root_name}/includes > in the filename", + f'{root_name}/includes " in the filename', + f"{root_name}/includes | in the filename", + f"{root_name}/includes ? in the filename", + f"{root_name}/includes * in the filename", + ] + + # Do not include ':' because POSIX filesystems allow it and Microsoft Graph / + # OneDrive handling is different enough that it is better validated separately. + # Start TC0025 with the clearly invalid cross-platform troublemakers first. + + for rel_path in valid_files + invalid_files: + self._create_binary_file(sync_root / rel_path, size_kb=8) + + initial_stdout = case_log_dir / "initial_sync_stdout.log" + initial_stderr = case_log_dir / "initial_sync_stderr.log" + + second_stdout = case_log_dir / "second_sync_stdout.log" + second_stderr = case_log_dir / "second_sync_stderr.log" + + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + initial_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--syncdir", + str(sync_root), + "--confdir", + str(confdir), + ] + initial_result = self._run_and_capture( + context, + "initial sync", + initial_command, + initial_stdout, + initial_stderr, + ) + + second_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--syncdir", + str(sync_root), + "--confdir", + str(confdir), + ] + second_result = self._run_and_capture( + context, + "second sync", + second_command, + second_stdout, + second_stderr, + ) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--syncdir", + str(verify_root), + "--confdir", + str(verify_conf), + ] + verify_result = self._run_and_capture( + context, + "remote verify", + verify_command, + verify_stdout, + verify_stderr, + ) + + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + combined_output = ( + initial_result.stdout + + "\n" + + initial_result.stderr + + "\n" + + second_result.stdout + + "\n" + + second_result.stderr + ) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"initial_returncode={initial_result.returncode}", + f"second_returncode={second_result.returncode}", + f"verify_returncode={verify_result.returncode}", + f"valid_files={valid_files!r}", + f"invalid_files={invalid_files!r}", + ] + ) + + "\n", + ) + + artifacts = [ + str(initial_stdout), + str(initial_stderr), + str(second_stdout), + str(second_stderr), + str(verify_stdout), + str(verify_stderr), + str(remote_manifest_file), + str(metadata_file), + ] + details = { + "initial_returncode": initial_result.returncode, + "second_returncode": second_result.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + } + + for label, rc in [ + ("initial sync", initial_result.returncode), + ("second sync", second_result.returncode), + ("remote verification", verify_result.returncode), + ]: + if rc != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} failed with status {rc}", + artifacts, + details, + ) + + for expected in valid_files: + if expected not in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"Expected valid file missing remotely: {expected}", + artifacts, + details, + ) + + for unwanted in invalid_files: + if unwanted in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"Invalid filename was synchronised remotely: {unwanted}", + artifacts, + details, + ) + + crash_markers = [ + "Segmentation fault", + "Traceback", + "core dumped", + "std.conv.ConvException", + "std.utf.UTFException", + ] + for marker in crash_markers: + if marker in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Client output indicates crash or exception: {marker}", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From e0bd8b4256e71946d8950c885b1172ec0c11a16c Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 15:56:58 +1100 Subject: [PATCH 074/245] Update tc0025 Update tc0025 --- ci/e2e/run.py | 2 +- ...5_invalid_character_filename_validation.py | 51 +++++++++++++------ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index ef785d510..57037329a 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -66,7 +66,7 @@ def build_test_suite() -> list: #TestCase0021ResumableTransfersValidation(), #TestCase0022LocalFirstValidation(), #TestCase0023BypassDataPreservationValidation(), - TestCase0024BigDeleteSafeguardValidation(), + #TestCase0024BigDeleteSafeguardValidation(), TestCase0025InvalidCharacterFilenameValidation(), ] diff --git a/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py b/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py index 2de5010ab..7de24a7a1 100644 --- a/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py +++ b/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py @@ -15,11 +15,18 @@ class TestCase0025InvalidCharacterFilenameValidation(E2ETestCase): name = "invalid character filename validation" description = "Validate invalid filename characters are blocked while valid sibling files still synchronise" - def _write_config(self, config_path: Path) -> None: + def _write_config(self, config_path: Path, sync_dir: Path) -> None: write_text_file( config_path, - "# tc0025 config\n" - 'bypass_data_preservation = "true"\n', + "\n".join( + [ + "# tc0025 config", + f'sync_dir = "{sync_dir}"', + 'bypass_data_preservation = "true"', + 'classify_as_big_delete = "1000"', + ] + ) + + "\n", ) def _create_binary_file(self, path: Path, size_kb: int = 8) -> None: @@ -62,8 +69,8 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(confdir) context.bootstrap_config_dir(verify_conf) - self._write_config(confdir / "config") - self._write_config(verify_conf / "config") + self._write_config(confdir / "config", sync_root) + self._write_config(verify_conf / "config", verify_root) root_name = f"ZZ_E2E_TC0025_{context.run_id}_{os.getpid()}" @@ -82,10 +89,6 @@ def run(self, context: E2EContext) -> TestResult: f"{root_name}/includes * in the filename", ] - # Do not include ':' because POSIX filesystems allow it and Microsoft Graph / - # OneDrive handling is different enough that it is better validated separately. - # Start TC0025 with the clearly invalid cross-platform troublemakers first. - for rel_path in valid_files + invalid_files: self._create_binary_file(sync_root / rel_path, size_kb=8) @@ -108,8 +111,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--resync", "--resync-auth", - "--syncdir", - str(sync_root), "--confdir", str(confdir), ] @@ -126,8 +127,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--syncdir", - str(sync_root), "--confdir", str(confdir), ] @@ -147,8 +146,6 @@ def run(self, context: E2EContext) -> TestResult: "--download-only", "--resync", "--resync-auth", - "--syncdir", - str(verify_root), "--confdir", str(verify_conf), ] @@ -240,6 +237,30 @@ def run(self, context: E2EContext) -> TestResult: details, ) + expected_skip_markers = [ + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/includes < in the filename", + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/includes > in the filename", + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f'{root_name}/includes " in the filename', + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/includes | in the filename", + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/includes ? in the filename", + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/includes * in the filename", + ] + for marker in expected_skip_markers: + if marker not in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Expected invalid filename skip marker not found: {marker}", + artifacts, + details, + ) + crash_markers = [ "Segmentation fault", "Traceback", From 2cfcda6d09831020d0cad7ea7fb5ce952dc7ccd2 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 16:08:44 +1100 Subject: [PATCH 075/245] Add tc0026 Add tc0026 --- ci/e2e/run.py | 2 + .../tc0026_reserved_device_name_validation.py | 278 ++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 ci/e2e/testcases/tc0026_reserved_device_name_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 57037329a..9c667bafa 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -34,6 +34,7 @@ from testcases.tc0023_bypass_data_preservation_validation import TestCase0023BypassDataPreservationValidation from testcases.tc0024_big_delete_safeguard_validation import TestCase0024BigDeleteSafeguardValidation from testcases.tc0025_invalid_character_filename_validation import TestCase0025InvalidCharacterFilenameValidation +from testcases.tc0026_reserved_device_name_validation import TestCase0026ReservedDeviceNameValidation def build_test_suite() -> list: @@ -68,6 +69,7 @@ def build_test_suite() -> list: #TestCase0023BypassDataPreservationValidation(), #TestCase0024BigDeleteSafeguardValidation(), TestCase0025InvalidCharacterFilenameValidation(), + TestCase0026ReservedDeviceNameValidation(), ] diff --git a/ci/e2e/testcases/tc0026_reserved_device_name_validation.py b/ci/e2e/testcases/tc0026_reserved_device_name_validation.py new file mode 100644 index 000000000..2d6cebfb5 --- /dev/null +++ b/ci/e2e/testcases/tc0026_reserved_device_name_validation.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0026ReservedDeviceNameValidation(E2ETestCase): + case_id = "0026" + name = "reserved device name validation" + description = "Validate reserved Windows device names are blocked while valid lookalike names still synchronise" + + def _write_config(self, config_path: Path, sync_dir: Path) -> None: + write_text_file( + config_path, + "\n".join( + [ + "# tc0026 config", + f'sync_dir = "{sync_dir}"', + 'bypass_data_preservation = "true"', + 'classify_as_big_delete = "1000"', + ] + ) + + "\n", + ) + + def _create_binary_file(self, path: Path, size_kb: int = 8) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = os.urandom(size_kb * 1024) + path.write_bytes(payload) + + def _run_and_capture( + self, + context: E2EContext, + label: str, + command: list[str], + stdout_file: Path, + stderr_file: Path, + ): + context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + return result + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0026" + case_log_dir = context.logs_dir / "tc0026" + state_dir = context.state_dir / "tc0026" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + confdir = case_work_dir / "conf-main" + verify_conf = case_work_dir / "conf-verify" + + reset_directory(sync_root) + reset_directory(verify_root) + + context.bootstrap_config_dir(confdir) + context.bootstrap_config_dir(verify_conf) + + self._write_config(confdir / "config", sync_root) + self._write_config(verify_conf / "config", verify_root) + + root_name = f"ZZ_E2E_TC0026_{context.run_id}_{os.getpid()}" + + valid_files = [ + f"{root_name}/HelloCOM2.rar", + f"{root_name}/Report_CON_v2.txt", + f"{root_name}/LPT_notes.txt", + f"{root_name}/valid_subdir/nested_valid_file.dat", + ] + + invalid_files = [ + f"{root_name}/CON", + f"{root_name}/CON.text", + f"{root_name}/PRN", + f"{root_name}/AUX", + f"{root_name}/NUL", + f"{root_name}/COM1", + f"{root_name}/COM2", + f"{root_name}/COM3", + f"{root_name}/COM9", + f"{root_name}/LPT1", + f"{root_name}/LPT2", + f"{root_name}/LPT9", + ] + + for rel_path in valid_files + invalid_files: + self._create_binary_file(sync_root / rel_path, size_kb=8) + + initial_stdout = case_log_dir / "initial_sync_stdout.log" + initial_stderr = case_log_dir / "initial_sync_stderr.log" + + second_stdout = case_log_dir / "second_sync_stdout.log" + second_stderr = case_log_dir / "second_sync_stderr.log" + + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + initial_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--confdir", + str(confdir), + ] + initial_result = self._run_and_capture( + context, + "initial sync", + initial_command, + initial_stdout, + initial_stderr, + ) + + second_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--confdir", + str(confdir), + ] + second_result = self._run_and_capture( + context, + "second sync", + second_command, + second_stdout, + second_stderr, + ) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--confdir", + str(verify_conf), + ] + verify_result = self._run_and_capture( + context, + "remote verify", + verify_command, + verify_stdout, + verify_stderr, + ) + + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + combined_output = ( + initial_result.stdout + + "\n" + + initial_result.stderr + + "\n" + + second_result.stdout + + "\n" + + second_result.stderr + ) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"initial_returncode={initial_result.returncode}", + f"second_returncode={second_result.returncode}", + f"verify_returncode={verify_result.returncode}", + f"valid_files={valid_files!r}", + f"invalid_files={invalid_files!r}", + ] + ) + + "\n", + ) + + artifacts = [ + str(initial_stdout), + str(initial_stderr), + str(second_stdout), + str(second_stderr), + str(verify_stdout), + str(verify_stderr), + str(remote_manifest_file), + str(metadata_file), + ] + details = { + "initial_returncode": initial_result.returncode, + "second_returncode": second_result.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + } + + for label, rc in [ + ("initial sync", initial_result.returncode), + ("second sync", second_result.returncode), + ("remote verification", verify_result.returncode), + ]: + if rc != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} failed with status {rc}", + artifacts, + details, + ) + + for expected in valid_files: + if expected not in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"Expected valid file missing remotely: {expected}", + artifacts, + details, + ) + + for unwanted in invalid_files: + if unwanted in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"Reserved device name was synchronised remotely: {unwanted}", + artifacts, + details, + ) + + expected_skip_markers = [ + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + item + for item in invalid_files + ] + for marker in expected_skip_markers: + if marker not in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Expected reserved-name skip marker not found: {marker}", + artifacts, + details, + ) + + crash_markers = [ + "Segmentation fault", + "Traceback", + "core dumped", + "std.conv.ConvException", + "std.utf.UTFException", + ] + for marker in crash_markers: + if marker in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Client output indicates crash or exception: {marker}", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From f3cf328ca605211af2ed6372f1e81d3ce3d8efa7 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 16:41:54 +1100 Subject: [PATCH 076/245] code fix for tc0026 code fix for tc0026 - test update to isValidName() --- src/util.d | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/src/util.d b/src/util.d index 2e653a833..040404403 100644 --- a/src/util.d +++ b/src/util.d @@ -651,7 +651,7 @@ bool multiGlobMatch(const(char)[] path, const(char)[] pattern) { } // Does the path pass the Microsoft restriction and limitations about naming files and folders -bool isValidName(string path) { +bool isValidName_original(string path) { // Restriction and limitations about windows naming files and folders // https://msdn.microsoft.com/en-us/library/aa365247 // https://support.microsoft.com/en-us/help/3125202/restrictions-and-limitations-when-you-sync-files-and-folders @@ -707,6 +707,98 @@ bool isValidName(string path) { return true; } +// ========================================= + + +// Check if the provided item name is a reserved Microsoft / Windows device name +// This must catch both: +// - exact reserved names, e.g. "CON" +// - reserved names followed by an extension, e.g. "CON.txt", "NUL.tar.gz" +// Microsoft documents that reserved names remain invalid even when followed by an extension. +bool isReservedMicrosoftName(string itemName, const(bool[string]) disallowedSet) { + // Ensure case-insensitive comparisons + string candidate = itemName.toLower(); + + // Exact match + if (disallowedSet.get(candidate, false)) { + return true; + } + + // Reserved device names followed by an extension, e.g. "CON.txt" + auto firstDot = countUntil(candidate, "."); + if (firstDot > 0) { + string deviceRoot = candidate[0 .. firstDot]; + if (disallowedSet.get(deviceRoot, false)) { + return true; + } + } + + return false; +} + + +// Does the path pass the Microsoft restriction and limitations about naming files and folders +bool isValidName(string path) { + // Restriction and limitations about windows naming files and folders + // https://msdn.microsoft.com/en-us/library/aa365247 + // https://support.microsoft.com/en-us/help/3125202/restrictions-and-limitations-when-you-sync-files-and-folders + + if (path == ".") { + return true; + } + + string itemName = baseName(path).toLower(); // Ensure case-insensitivity + + // Check for explicitly disallowed names + // https://support.microsoft.com/en-us/office/restrictions-and-limitations-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa?ui=en-us&rs=en-us&ad=us#invalidfilefoldernames + string[] disallowedNames = [ + ".lock", "desktop.ini", "CON", "PRN", "AUX", "NUL", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + ]; + + // Creating an associative array for faster lookup + bool[string] disallowedSet; + foreach (name; disallowedNames) { + disallowedSet[name.toLower()] = true; // Normalise to lowercase + } + + if (isReservedMicrosoftName(itemName, disallowedSet) || itemName.startsWith("~$") || canFind(itemName, "_vti_")) { + return false; + } + + // Regular expression for invalid patterns + // https://support.microsoft.com/en-us/office/restrictions-and-limitations-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa?ui=en-us&rs=en-us&ad=us#invalidcharacters + // Leading whitespace and trailing whitespace + // Invalid characters + // Trailing dot '.' (not documented above) , however see issue https://github.com/abraunegg/onedrive/issues/2678 + + //auto invalidNameReg = ctRegex!(`^\s.*|^.*[\s\.]$|.*[<>:"\|\?*/\\].*`); - original to remove at some point + auto invalidNameReg = ctRegex!(`^\s+|\s$|\.$|[<>:"\|\?*/\\]`); // revised 25/3/2024 + // - ^\s+ matches one or more whitespace characters at the start of the string. The + ensures we match one or more whitespaces, making it more efficient than .* for detecting leading whitespaces. + // - \s$ matches a whitespace character at the end of the string. This is more precise than [\s\.]$ because we'll handle the dot separately. + // - \.$ specifically matches a dot character at the end of the string, addressing the requirement to catch trailing dots as invalid. + // - [<>:"\|\?*/\\] matches any single instance of the specified invalid characters: ", *, :, <, >, ?, /, \, | + + auto matchResult = match(itemName, invalidNameReg); + if (!matchResult.empty) { + return false; + } + + // Determine if the path is at the root level, if yes, check that 'forms' is not the first folder + auto segments = pathSplitter(path).array; + if (segments.length <= 2 && segments.back.toLower() == "forms") { // Check only the last segment, convert to lower as OneDrive is not POSIX compliant, easier to compare + return false; + } + + return true; +} + + + +// ========================================= + + // Does the path contain any bad whitespace characters bool containsBadWhiteSpace(string path) { // Check for null or empty string From 11d8230629a23675cec2b264d8d2abdb8e16bb6f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 16:54:58 +1100 Subject: [PATCH 077/245] revert code change revert code change - push through from 'master' --- src/util.d | 94 +----------------------------------------------------- 1 file changed, 1 insertion(+), 93 deletions(-) diff --git a/src/util.d b/src/util.d index 040404403..2e653a833 100644 --- a/src/util.d +++ b/src/util.d @@ -650,93 +650,6 @@ bool multiGlobMatch(const(char)[] path, const(char)[] pattern) { return false; } -// Does the path pass the Microsoft restriction and limitations about naming files and folders -bool isValidName_original(string path) { - // Restriction and limitations about windows naming files and folders - // https://msdn.microsoft.com/en-us/library/aa365247 - // https://support.microsoft.com/en-us/help/3125202/restrictions-and-limitations-when-you-sync-files-and-folders - - if (path == ".") { - return true; - } - - string itemName = baseName(path).toLower(); // Ensure case-insensitivity - - // Check for explicitly disallowed names - // https://support.microsoft.com/en-us/office/restrictions-and-limitations-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa?ui=en-us&rs=en-us&ad=us#invalidfilefoldernames - string[] disallowedNames = [ - ".lock", "desktop.ini", "CON", "PRN", "AUX", "NUL", - "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" - ]; - - // Creating an associative array for faster lookup - bool[string] disallowedSet; - foreach (name; disallowedNames) { - disallowedSet[name.toLower()] = true; // Normalise to lowercase - } - - if (disallowedSet.get(itemName, false) || itemName.startsWith("~$") || canFind(itemName, "_vti_")) { - return false; - } - - // Regular expression for invalid patterns - // https://support.microsoft.com/en-us/office/restrictions-and-limitations-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa?ui=en-us&rs=en-us&ad=us#invalidcharacters - // Leading whitespace and trailing whitespace - // Invalid characters - // Trailing dot '.' (not documented above) , however see issue https://github.com/abraunegg/onedrive/issues/2678 - - //auto invalidNameReg = ctRegex!(`^\s.*|^.*[\s\.]$|.*[<>:"\|\?*/\\].*`); - original to remove at some point - auto invalidNameReg = ctRegex!(`^\s+|\s$|\.$|[<>:"\|\?*/\\]`); // revised 25/3/2024 - // - ^\s+ matches one or more whitespace characters at the start of the string. The + ensures we match one or more whitespaces, making it more efficient than .* for detecting leading whitespaces. - // - \s$ matches a whitespace character at the end of the string. This is more precise than [\s\.]$ because we'll handle the dot separately. - // - \.$ specifically matches a dot character at the end of the string, addressing the requirement to catch trailing dots as invalid. - // - [<>:"\|\?*/\\] matches any single instance of the specified invalid characters: ", *, :, <, >, ?, /, \, | - - auto matchResult = match(itemName, invalidNameReg); - if (!matchResult.empty) { - return false; - } - - // Determine if the path is at the root level, if yes, check that 'forms' is not the first folder - auto segments = pathSplitter(path).array; - if (segments.length <= 2 && segments.back.toLower() == "forms") { // Check only the last segment, convert to lower as OneDrive is not POSIX compliant, easier to compare - return false; - } - - return true; -} - -// ========================================= - - -// Check if the provided item name is a reserved Microsoft / Windows device name -// This must catch both: -// - exact reserved names, e.g. "CON" -// - reserved names followed by an extension, e.g. "CON.txt", "NUL.tar.gz" -// Microsoft documents that reserved names remain invalid even when followed by an extension. -bool isReservedMicrosoftName(string itemName, const(bool[string]) disallowedSet) { - // Ensure case-insensitive comparisons - string candidate = itemName.toLower(); - - // Exact match - if (disallowedSet.get(candidate, false)) { - return true; - } - - // Reserved device names followed by an extension, e.g. "CON.txt" - auto firstDot = countUntil(candidate, "."); - if (firstDot > 0) { - string deviceRoot = candidate[0 .. firstDot]; - if (disallowedSet.get(deviceRoot, false)) { - return true; - } - } - - return false; -} - - // Does the path pass the Microsoft restriction and limitations about naming files and folders bool isValidName(string path) { // Restriction and limitations about windows naming files and folders @@ -763,7 +676,7 @@ bool isValidName(string path) { disallowedSet[name.toLower()] = true; // Normalise to lowercase } - if (isReservedMicrosoftName(itemName, disallowedSet) || itemName.startsWith("~$") || canFind(itemName, "_vti_")) { + if (disallowedSet.get(itemName, false) || itemName.startsWith("~$") || canFind(itemName, "_vti_")) { return false; } @@ -794,11 +707,6 @@ bool isValidName(string path) { return true; } - - -// ========================================= - - // Does the path contain any bad whitespace characters bool containsBadWhiteSpace(string path) { // Check for null or empty string From f47e474a0b8af23aa8221b22f3c6d5c30e9fd005 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 17:19:53 +1100 Subject: [PATCH 078/245] Add tc0027 Add tc0027 --- ci/e2e/run.py | 2 + ...0027_whitespace_trailing_dot_validation.py | 291 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 ci/e2e/testcases/tc0027_whitespace_trailing_dot_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 9c667bafa..e709e57d3 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -35,6 +35,7 @@ from testcases.tc0024_big_delete_safeguard_validation import TestCase0024BigDeleteSafeguardValidation from testcases.tc0025_invalid_character_filename_validation import TestCase0025InvalidCharacterFilenameValidation from testcases.tc0026_reserved_device_name_validation import TestCase0026ReservedDeviceNameValidation +from tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation def build_test_suite() -> list: @@ -70,6 +71,7 @@ def build_test_suite() -> list: #TestCase0024BigDeleteSafeguardValidation(), TestCase0025InvalidCharacterFilenameValidation(), TestCase0026ReservedDeviceNameValidation(), + TestCase0027WhitespaceTrailingDotValidation(), ] diff --git a/ci/e2e/testcases/tc0027_whitespace_trailing_dot_validation.py b/ci/e2e/testcases/tc0027_whitespace_trailing_dot_validation.py new file mode 100644 index 000000000..4784ab7bc --- /dev/null +++ b/ci/e2e/testcases/tc0027_whitespace_trailing_dot_validation.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0027WhitespaceTrailingDotValidation(E2ETestCase): + case_id = "0027" + name = "whitespace and trailing dot validation" + description = "Validate trailing whitespace and trailing dot names are blocked while valid sibling files still synchronise" + + def _write_config(self, config_path: Path, sync_dir: Path) -> None: + write_text_file( + config_path, + "\n".join( + [ + "# tc0027 config", + f'sync_dir = "{sync_dir}"', + 'bypass_data_preservation = "true"', + 'classify_as_big_delete = "1000"', + ] + ) + + "\n", + ) + + def _create_binary_file(self, path: Path, size_kb: int = 8) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = os.urandom(size_kb * 1024) + path.write_bytes(payload) + + def _run_and_capture( + self, + context: E2EContext, + label: str, + command: list[str], + stdout_file: Path, + stderr_file: Path, + ): + context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + return result + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0027" + case_log_dir = context.logs_dir / "tc0027" + state_dir = context.state_dir / "tc0027" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + confdir = case_work_dir / "conf-main" + verify_conf = case_work_dir / "conf-verify" + + reset_directory(sync_root) + reset_directory(verify_root) + + context.bootstrap_config_dir(confdir) + context.bootstrap_config_dir(verify_conf) + + self._write_config(confdir / "config", sync_root) + self._write_config(verify_conf / "config", verify_root) + + root_name = f"ZZ_E2E_TC0027_{context.run_id}_{os.getpid()}" + + valid_files = [ + f"{root_name}/valid_file_1.bin", + f"{root_name}/middle space valid.txt", + f"{root_name}/name.with.periods.txt", + f"{root_name}/valid_subdir/nested_valid_file.dat", + ] + + invalid_files = [ + f"{root_name}/trailing-space.txt ", + f"{root_name}/trailing-dot.txt.", + f"{root_name}/trailing-space-dir /child.txt", + f"{root_name}/trailing-dot-dir./child.txt", + ] + + for rel_path in valid_files + invalid_files: + self._create_binary_file(sync_root / rel_path, size_kb=8) + + initial_stdout = case_log_dir / "initial_sync_stdout.log" + initial_stderr = case_log_dir / "initial_sync_stderr.log" + + second_stdout = case_log_dir / "second_sync_stdout.log" + second_stderr = case_log_dir / "second_sync_stderr.log" + + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + initial_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--confdir", + str(confdir), + ] + initial_result = self._run_and_capture( + context, + "initial sync", + initial_command, + initial_stdout, + initial_stderr, + ) + + second_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--confdir", + str(confdir), + ] + second_result = self._run_and_capture( + context, + "second sync", + second_command, + second_stdout, + second_stderr, + ) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--confdir", + str(verify_conf), + ] + verify_result = self._run_and_capture( + context, + "remote verify", + verify_command, + verify_stdout, + verify_stderr, + ) + + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + combined_output = ( + initial_result.stdout + + "\n" + + initial_result.stderr + + "\n" + + second_result.stdout + + "\n" + + second_result.stderr + ) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"initial_returncode={initial_result.returncode}", + f"second_returncode={second_result.returncode}", + f"verify_returncode={verify_result.returncode}", + f"valid_files={valid_files!r}", + f"invalid_files={invalid_files!r}", + ] + ) + + "\n", + ) + + artifacts = [ + str(initial_stdout), + str(initial_stderr), + str(second_stdout), + str(second_stderr), + str(verify_stdout), + str(verify_stderr), + str(remote_manifest_file), + str(metadata_file), + ] + details = { + "initial_returncode": initial_result.returncode, + "second_returncode": second_result.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + } + + for label, rc in [ + ("initial sync", initial_result.returncode), + ("second sync", second_result.returncode), + ("remote verification", verify_result.returncode), + ]: + if rc != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} failed with status {rc}", + artifacts, + details, + ) + + for expected in valid_files: + if expected not in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"Expected valid file missing remotely: {expected}", + artifacts, + details, + ) + + for unwanted in invalid_files: + if unwanted in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"Invalid whitespace or trailing dot name was synchronised remotely: {unwanted}", + artifacts, + details, + ) + + expected_skip_markers = [ + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/trailing-space.txt ", + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/trailing-dot.txt.", + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/trailing-space-dir ", + 'Skipping item - invalid name (Microsoft Naming Convention): ./' + + f"{root_name}/trailing-dot-dir.", + ] + for marker in expected_skip_markers: + if marker not in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Expected whitespace/trailing-dot skip marker not found: {marker}", + artifacts, + details, + ) + + disallowed_remote_failure_markers = [ + "HTTP 400", + "The resource name is invalid", + "invalidRequest", + ] + for marker in disallowed_remote_failure_markers: + if marker in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Client attempted remote invalid-name operation instead of local skip: {marker}", + artifacts, + details, + ) + + crash_markers = [ + "Segmentation fault", + "Traceback", + "core dumped", + "std.conv.ConvException", + "std.utf.UTFException", + ] + for marker in crash_markers: + if marker in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Client output indicates crash or exception: {marker}", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From cc3ae5dcae54b8a160ce8598e277303829983e83 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 17:24:26 +1100 Subject: [PATCH 079/245] remove tc0027 remove tc0027 --- ci/e2e/run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index e709e57d3..e29ccac15 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -35,7 +35,7 @@ from testcases.tc0024_big_delete_safeguard_validation import TestCase0024BigDeleteSafeguardValidation from testcases.tc0025_invalid_character_filename_validation import TestCase0025InvalidCharacterFilenameValidation from testcases.tc0026_reserved_device_name_validation import TestCase0026ReservedDeviceNameValidation -from tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation +#from tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation def build_test_suite() -> list: @@ -71,7 +71,7 @@ def build_test_suite() -> list: #TestCase0024BigDeleteSafeguardValidation(), TestCase0025InvalidCharacterFilenameValidation(), TestCase0026ReservedDeviceNameValidation(), - TestCase0027WhitespaceTrailingDotValidation(), + #TestCase0027WhitespaceTrailingDotValidation(), ] From 05c2fb82a0c27106b7e9256d340da8a1951a5607 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 17:41:20 +1100 Subject: [PATCH 080/245] Update run.py import tc0027 --- ci/e2e/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index e29ccac15..de6b0939f 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -35,7 +35,7 @@ from testcases.tc0024_big_delete_safeguard_validation import TestCase0024BigDeleteSafeguardValidation from testcases.tc0025_invalid_character_filename_validation import TestCase0025InvalidCharacterFilenameValidation from testcases.tc0026_reserved_device_name_validation import TestCase0026ReservedDeviceNameValidation -#from tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation +from tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation def build_test_suite() -> list: From 6ebea18b8a436527509b90eb884d43f02ad15b88 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 18:49:17 +1100 Subject: [PATCH 081/245] Update run.py Fix import for tc0027 --- ci/e2e/run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index de6b0939f..daa6a3f4c 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -35,7 +35,7 @@ from testcases.tc0024_big_delete_safeguard_validation import TestCase0024BigDeleteSafeguardValidation from testcases.tc0025_invalid_character_filename_validation import TestCase0025InvalidCharacterFilenameValidation from testcases.tc0026_reserved_device_name_validation import TestCase0026ReservedDeviceNameValidation -from tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation +from testcases.tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation def build_test_suite() -> list: @@ -71,7 +71,7 @@ def build_test_suite() -> list: #TestCase0024BigDeleteSafeguardValidation(), TestCase0025InvalidCharacterFilenameValidation(), TestCase0026ReservedDeviceNameValidation(), - #TestCase0027WhitespaceTrailingDotValidation(), + TestCase0027WhitespaceTrailingDotValidation(), ] From b268d309c3b4c8376a41365eb1e2d52da9d68aa7 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 18:59:31 +1100 Subject: [PATCH 082/245] Add tc0028 Add tc0028 --- ci/e2e/run.py | 2 + ..._character_non_utf8_filename_validation.py | 387 ++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 ci/e2e/testcases/tc0028_control_character_non_utf8_filename_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index daa6a3f4c..1be17076d 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -36,6 +36,7 @@ from testcases.tc0025_invalid_character_filename_validation import TestCase0025InvalidCharacterFilenameValidation from testcases.tc0026_reserved_device_name_validation import TestCase0026ReservedDeviceNameValidation from testcases.tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation +from testcases.tc0028_control_character_non_utf8_filename_validation import TestCase0028ControlCharacterNonUtf8FilenameValidation def build_test_suite() -> list: @@ -72,6 +73,7 @@ def build_test_suite() -> list: TestCase0025InvalidCharacterFilenameValidation(), TestCase0026ReservedDeviceNameValidation(), TestCase0027WhitespaceTrailingDotValidation(), + TestCase0028ControlCharacterNonUtf8FilenameValidation(), ] diff --git a/ci/e2e/testcases/tc0028_control_character_non_utf8_filename_validation.py b/ci/e2e/testcases/tc0028_control_character_non_utf8_filename_validation.py new file mode 100644 index 000000000..56e5fc5ff --- /dev/null +++ b/ci/e2e/testcases/tc0028_control_character_non_utf8_filename_validation.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0028ControlCharacterNonUtf8FilenameValidation(E2ETestCase): + case_id = "0028" + name = "control character and non-UTF8 filename validation" + description = "Validate control character and non-UTF8 filenames are safely skipped without client crash while valid sibling files still synchronise" + + def _write_config(self, config_path: Path, sync_dir: Path) -> None: + write_text_file( + config_path, + "\n".join( + [ + "# tc0028 config", + f'sync_dir = "{sync_dir}"', + 'bypass_data_preservation = "true"', + 'classify_as_big_delete = "1000"', + ] + ) + + "\n", + ) + + def _create_binary_file(self, path: Path, size_kb: int = 8) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = os.urandom(size_kb * 1024) + path.write_bytes(payload) + + def _run_and_capture( + self, + context: E2EContext, + label: str, + command: list[str], + stdout_file: Path, + stderr_file: Path, + ): + context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + return result + + def _extract_bad_filename_archive( + self, + context: E2EContext, + archive_path: Path, + destination: Path, + stdout_file: Path, + stderr_file: Path, + ): + destination.mkdir(parents=True, exist_ok=True) + command = [ + "tar", + "-xJf", + str(archive_path), + "-C", + str(destination), + ] + return self._run_and_capture(context, "archive extract", command, stdout_file, stderr_file) + + def _collect_extracted_file_entries(self, root_name: str, extract_root: Path) -> list[str]: + extracted_files: list[str] = [] + + if not extract_root.exists(): + return extracted_files + + for current_root, _, filenames in os.walk(str(extract_root)): + for filename in filenames: + full_path = Path(current_root) / filename + relative_path = full_path.relative_to(extract_root) + extracted_files.append(f"{root_name}/archive_payload/{relative_path.as_posix()}") + + extracted_files.sort() + return extracted_files + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0028" + case_log_dir = context.logs_dir / "tc0028" + state_dir = context.state_dir / "tc0028" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + confdir = case_work_dir / "conf-main" + verify_conf = case_work_dir / "conf-verify" + + reset_directory(sync_root) + reset_directory(verify_root) + + context.bootstrap_config_dir(confdir) + context.bootstrap_config_dir(verify_conf) + + self._write_config(confdir / "config", sync_root) + self._write_config(verify_conf / "config", verify_root) + + root_name = f"ZZ_E2E_TC0028_{context.run_id}_{os.getpid()}" + archive_path = context.repo_root / "tests" / "bad-file-name.tar.xz" + + if not archive_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"Required archive not found: {archive_path}", + [], + {"archive_path": str(archive_path)}, + ) + + valid_files = [ + f"{root_name}/valid_file_1.bin", + f"{root_name}/valid_file_2.txt", + f"{root_name}/valid_subdir/nested_valid_file.dat", + ] + + for rel_path in valid_files: + self._create_binary_file(sync_root / rel_path, size_kb=8) + + archive_extract_root = sync_root / root_name / "archive_payload" + + extract_stdout = case_log_dir / "archive_extract_stdout.log" + extract_stderr = case_log_dir / "archive_extract_stderr.log" + + extract_result = self._extract_bad_filename_archive( + context, + archive_path, + archive_extract_root, + extract_stdout, + extract_stderr, + ) + + initial_stdout = case_log_dir / "initial_sync_stdout.log" + initial_stderr = case_log_dir / "initial_sync_stderr.log" + + second_stdout = case_log_dir / "second_sync_stdout.log" + second_stderr = case_log_dir / "second_sync_stderr.log" + + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + + remote_manifest_file = state_dir / "remote_verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + if extract_result.returncode != 0: + artifacts = [ + str(extract_stdout), + str(extract_stderr), + ] + details = { + "archive_path": str(archive_path), + "extract_returncode": extract_result.returncode, + } + return TestResult.fail_result( + self.case_id, + self.name, + f"Archive extraction failed with status {extract_result.returncode}", + artifacts, + details, + ) + + extracted_file_entries = self._collect_extracted_file_entries(root_name, archive_extract_root) + + initial_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--resync", + "--resync-auth", + "--confdir", + str(confdir), + ] + initial_result = self._run_and_capture( + context, + "initial sync", + initial_command, + initial_stdout, + initial_stderr, + ) + + second_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--confdir", + str(confdir), + ] + second_result = self._run_and_capture( + context, + "second sync", + second_command, + second_stdout, + second_stderr, + ) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--confdir", + str(verify_conf), + ] + verify_result = self._run_and_capture( + context, + "remote verify", + verify_command, + verify_stdout, + verify_stderr, + ) + + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + combined_output = ( + extract_result.stdout + + "\n" + + extract_result.stderr + + "\n" + + initial_result.stdout + + "\n" + + initial_result.stderr + + "\n" + + second_result.stdout + + "\n" + + second_result.stderr + ) + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"archive_path={archive_path}", + f"extract_returncode={extract_result.returncode}", + f"initial_returncode={initial_result.returncode}", + f"second_returncode={second_result.returncode}", + f"verify_returncode={verify_result.returncode}", + f"valid_files={valid_files!r}", + f"extracted_file_entries={extracted_file_entries!r}", + ] + ) + + "\n", + ) + + artifacts = [ + str(extract_stdout), + str(extract_stderr), + str(initial_stdout), + str(initial_stderr), + str(second_stdout), + str(second_stderr), + str(verify_stdout), + str(verify_stderr), + str(remote_manifest_file), + str(metadata_file), + ] + details = { + "extract_returncode": extract_result.returncode, + "initial_returncode": initial_result.returncode, + "second_returncode": second_result.returncode, + "verify_returncode": verify_result.returncode, + "root_name": root_name, + "archive_path": str(archive_path), + "extracted_file_count": len(extracted_file_entries), + } + + for label, rc in [ + ("archive extraction", extract_result.returncode), + ("initial sync", initial_result.returncode), + ("second sync", second_result.returncode), + ("remote verification", verify_result.returncode), + ]: + if rc != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} failed with status {rc}", + artifacts, + details, + ) + + for expected in valid_files: + if expected not in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"Expected valid file missing remotely: {expected}", + artifacts, + details, + ) + + for unwanted in extracted_file_entries: + if unwanted in remote_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + f"Control character or non-UTF8 filename was synchronised remotely: {unwanted!r}", + artifacts, + details, + ) + + if len(extracted_file_entries) == 0: + return TestResult.fail_result( + self.case_id, + self.name, + "Archive extraction produced no test files under archive_payload", + artifacts, + details, + ) + + if ( + f"./{root_name}/archive_payload" not in combined_output + and f"{root_name}/archive_payload" not in combined_output + ): + return TestResult.fail_result( + self.case_id, + self.name, + "Client output does not reference the extracted archive payload paths", + artifacts, + details, + ) + + skip_indicators = [ + "Skipping item - invalid name", + "Microsoft Naming Convention", + "Skipping item", + ] + if not any(indicator in combined_output for indicator in skip_indicators): + return TestResult.fail_result( + self.case_id, + self.name, + "Expected skip behaviour was not observed in client output", + artifacts, + details, + ) + + disallowed_remote_failure_markers = [ + "HTTP 400", + "The resource name is invalid", + "invalidRequest", + ] + for marker in disallowed_remote_failure_markers: + if marker in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Client attempted remote invalid-name operation instead of safe local skip: {marker}", + artifacts, + details, + ) + + crash_markers = [ + "Segmentation fault", + "Traceback", + "core dumped", + "std.conv.ConvException", + "std.utf.UTFException", + "UnicodeDecodeError", + "UnicodeEncodeError", + ] + for marker in crash_markers: + if marker in combined_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Client output indicates crash or exception: {marker}", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 21231da49cf647792b1516c022b92301e5c5949e Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 16 Mar 2026 19:08:19 +1100 Subject: [PATCH 083/245] full test tc0001 -> tc0028 full test tc0001 -> tc0028 --- ci/e2e/run.py | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 1be17076d..e88a2cc92 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -46,30 +46,30 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - #TestCase0001BasicResync(), - #TestCase0002SyncListValidation(), - #TestCase0003DryRunValidation(), - #TestCase0004SingleDirectorySync(), - #TestCase0005ForceSyncOverride(), - #TestCase0006DownloadOnly(), - #TestCase0007DownloadOnlyCleanupLocalFiles(), - #TestCase0008UploadOnly(), - #TestCase0009UploadOnlyNoRemoteDelete(), - #TestCase0010UploadOnlyRemoveSourceFiles(), - #TestCase0011SkipFileValidation(), - #TestCase0012SkipDirValidation(), - #TestCase0013SkipDotfilesValidation(), - #TestCase0014SkipSizeValidation(), - #TestCase0015SkipSymlinksValidation(), - #TestCase0016CheckNosyncValidation(), - #TestCase0017CheckNomountValidation(), - #TestCase0018RecycleBinValidation(), - #TestCase0019LoggingAndRunningConfig(), - #TestCase0020MonitorModeValidation(), - #TestCase0021ResumableTransfersValidation(), - #TestCase0022LocalFirstValidation(), - #TestCase0023BypassDataPreservationValidation(), - #TestCase0024BigDeleteSafeguardValidation(), + TestCase0001BasicResync(), + TestCase0002SyncListValidation(), + TestCase0003DryRunValidation(), + TestCase0004SingleDirectorySync(), + TestCase0005ForceSyncOverride(), + TestCase0006DownloadOnly(), + TestCase0007DownloadOnlyCleanupLocalFiles(), + TestCase0008UploadOnly(), + TestCase0009UploadOnlyNoRemoteDelete(), + TestCase0010UploadOnlyRemoveSourceFiles(), + TestCase0011SkipFileValidation(), + TestCase0012SkipDirValidation(), + TestCase0013SkipDotfilesValidation(), + TestCase0014SkipSizeValidation(), + TestCase0015SkipSymlinksValidation(), + TestCase0016CheckNosyncValidation(), + TestCase0017CheckNomountValidation(), + TestCase0018RecycleBinValidation(), + TestCase0019LoggingAndRunningConfig(), + TestCase0020MonitorModeValidation(), + TestCase0021ResumableTransfersValidation(), + TestCase0022LocalFirstValidation(), + TestCase0023BypassDataPreservationValidation(), + TestCase0024BigDeleteSafeguardValidation(), TestCase0025InvalidCharacterFilenameValidation(), TestCase0026ReservedDeviceNameValidation(), TestCase0027WhitespaceTrailingDotValidation(), From 4d2f116a72a161a9139c44e4a29c8371573b4dee Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 17 Mar 2026 05:40:34 +1100 Subject: [PATCH 084/245] Update tc0021_resumable_transfers_validation.py Update tc0021 --- .../tc0021_resumable_transfers_validation.py | 151 ++++++++++++++---- 1 file changed, 123 insertions(+), 28 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 6aed7d50c..ba9316f20 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -18,21 +18,46 @@ class TestCase0021ResumableTransfersValidation(E2ETestCase): name = "resumable transfers validation" description = "Validate interrupted upload recovery for a resumable session upload" - def _write_config(self, config_path: Path, app_log_dir: Path) -> None: + LARGE_FILE_SIZE = 5 * 1024 * 1024 + + def _write_config(self, config_path: Path, sync_dir: Path, app_log_dir: Path) -> None: write_text_file( config_path, - "# tc0021 config\n" - 'bypass_data_preservation = "true"\n' - 'enable_logging = "true"\n' - f'log_dir = "{app_log_dir}"\n' - 'force_session_upload = "true"\n' - 'rate_limit = "262144"\n', + ( + "# tc0021 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + 'enable_logging = "true"\n' + f'log_dir = "{app_log_dir}"\n' + 'force_session_upload = "true"\n' + 'rate_limit = "262144"\n' + ), ) + def _run_and_capture( + self, + context: E2EContext, + label: str, + command: list[str], + stdout_file: Path, + stderr_file: Path, + ): + context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + return result + + def _read_text_if_exists(self, path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8", errors="replace") + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0021" case_log_dir = context.logs_dir / "tc0021" state_dir = context.state_dir / "tc0021" + reset_directory(case_work_dir) reset_directory(case_log_dir) reset_directory(state_dir) @@ -42,16 +67,20 @@ def run(self, context: E2EContext) -> TestResult: confdir = case_work_dir / "conf-main" verify_root = case_work_dir / "verifyroot" verify_conf = case_work_dir / "conf-verify" + root_name = f"ZZ_E2E_TC0021_{context.run_id}_{os.getpid()}" app_log_dir = case_log_dir / "app-logs" + app_log_file = app_log_dir / "root.onedrive.log" + large_file = sync_root / root_name / "session-large.bin" large_file.parent.mkdir(parents=True, exist_ok=True) - large_file.write_bytes(b"R" * (5 * 1024 * 1024)) + large_file.write_bytes(b"R" * self.LARGE_FILE_SIZE) context.bootstrap_config_dir(confdir) - self._write_config(confdir / "config", app_log_dir) + self._write_config(confdir / "config", sync_root, app_log_dir) + context.bootstrap_config_dir(verify_conf) - write_text_file(verify_conf / "config", "# tc0021 verify\n" 'bypass_data_preservation = "true"\n') + self._write_config(verify_conf / "config", verify_root, app_log_dir) phase1_stdout = case_log_dir / "phase1_stdout.log" phase1_stderr = case_log_dir / "phase1_stderr.log" @@ -62,7 +91,7 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" - command = [ + upload_command = [ context.onedrive_bin, "--display-running-config", "--sync", @@ -72,16 +101,16 @@ def run(self, context: E2EContext) -> TestResult: "--resync-auth", "--single-directory", root_name, - "--syncdir", - str(sync_root), "--confdir", str(confdir), ] - context.log(f"Executing Test Case {self.case_id} phase 1: {command_to_string(command)}") - with phase1_stdout.open("w", encoding="utf-8") as stdout_fp, phase1_stderr.open("w", encoding="utf-8") as stderr_fp: + context.log(f"Executing Test Case {self.case_id} phase 1: {command_to_string(upload_command)}") + with phase1_stdout.open("w", encoding="utf-8") as stdout_fp, phase1_stderr.open( + "w", encoding="utf-8" + ) as stderr_fp: process = subprocess.Popen( - command, + upload_command, cwd=str(context.repo_root), stdout=stdout_fp, stderr=stderr_fp, @@ -95,8 +124,12 @@ def run(self, context: E2EContext) -> TestResult: process.kill() process.wait(timeout=30) - context.log(f"Executing Test Case {self.case_id} phase 2: {command_to_string(command)}") - phase2_result = run_command(command, cwd=context.repo_root) + phase1_stdout_text = self._read_text_if_exists(phase1_stdout) + phase1_stderr_text = self._read_text_if_exists(phase1_stderr) + app_log_text_after_phase1 = self._read_text_if_exists(app_log_file) + + context.log(f"Executing Test Case {self.case_id} phase 2: {command_to_string(upload_command)}") + phase2_result = run_command(upload_command, cwd=context.repo_root) write_text_file(phase2_stdout, phase2_result.stdout) write_text_file(phase2_stderr, phase2_result.stderr) @@ -110,17 +143,18 @@ def run(self, context: E2EContext) -> TestResult: "--resync-auth", "--single-directory", root_name, - "--syncdir", - str(verify_root), "--confdir", str(verify_conf), ] verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) + remote_manifest = build_manifest(verify_root) write_manifest(remote_manifest_file, remote_manifest) + current_large_file_exists = large_file.exists() + write_text_file( metadata_file, "\n".join( @@ -130,27 +164,88 @@ def run(self, context: E2EContext) -> TestResult: f"phase1_returncode={process.returncode}", f"phase2_returncode={phase2_result.returncode}", f"verify_returncode={verify_result.returncode}", - f"large_size={large_file.stat().st_size}", + f"large_size={self.LARGE_FILE_SIZE}", + f"large_file_exists_after_recovery={current_large_file_exists}", + f"app_log_file={app_log_file}", ] - ) + "\n", + ) + + "\n", ) - artifacts = [str(phase1_stdout), str(phase1_stderr), str(phase2_stdout), str(phase2_stderr), str(verify_stdout), str(verify_stderr), str(remote_manifest_file), str(metadata_file)] + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(verify_stdout), + str(verify_stderr), + str(remote_manifest_file), + str(metadata_file), + ] if app_log_dir.exists(): artifacts.append(str(app_log_dir)) + details = { "phase1_returncode": process.returncode, "phase2_returncode": phase2_result.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name, - "large_size": large_file.stat().st_size, + "large_size": self.LARGE_FILE_SIZE, + "large_file_exists_after_recovery": current_large_file_exists, } + crash_markers = [ + "Segmentation fault", + "core dumped", + "SIGSEGV", + "std.conv.ConvException", + "std.utf.UTFException", + "Traceback", + ] + + combined_phase1_output = ( + phase1_stdout_text + + "\n" + + phase1_stderr_text + + "\n" + + app_log_text_after_phase1 + ) + + for marker in crash_markers: + if marker in combined_phase1_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Interrupted upload phase triggered client crash or exception: {marker}", + artifacts, + details, + ) + if phase2_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Resumable upload recovery phase failed with status {phase2_result.returncode}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"Resumable upload recovery phase failed with status {phase2_result.returncode}", + artifacts, + details, + ) + if verify_result.returncode != 0: - return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"Remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + if f"{root_name}/session-large.bin" not in remote_manifest: - return TestResult.fail_result(self.case_id, self.name, "Interrupted resumable upload did not complete successfully on the subsequent run", artifacts, details) + return TestResult.fail_result( + self.case_id, + self.name, + "Interrupted resumable upload did not complete successfully on the subsequent run", + artifacts, + details, + ) - return TestResult.pass_result(self.case_id, self.name, artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 43b0221ed2fb5488532b8303d59748c4f2842ced Mon Sep 17 00:00:00 2001 From: abraunegg Date: Thu, 19 Mar 2026 05:51:30 +1100 Subject: [PATCH 085/245] Update tc0021_resumable_transfers_validation.py Update tc0021 --- .../tc0021_resumable_transfers_validation.py | 213 ++++++++++++------ 1 file changed, 145 insertions(+), 68 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index ba9316f20..2e85e6d65 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -18,7 +18,8 @@ class TestCase0021ResumableTransfersValidation(E2ETestCase): name = "resumable transfers validation" description = "Validate interrupted upload recovery for a resumable session upload" - LARGE_FILE_SIZE = 5 * 1024 * 1024 + # Use a larger file so the transfer is definitely active when interrupted. + LARGE_FILE_SIZE = 100 * 1024 * 1024 def _write_config(self, config_path: Path, sync_dir: Path, app_log_dir: Path) -> None: write_text_file( @@ -34,25 +35,15 @@ def _write_config(self, config_path: Path, sync_dir: Path, app_log_dir: Path) -> ), ) - def _run_and_capture( - self, - context: E2EContext, - label: str, - command: list[str], - stdout_file: Path, - stderr_file: Path, - ): - context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") - result = run_command(command, cwd=context.repo_root) - write_text_file(stdout_file, result.stdout) - write_text_file(stderr_file, result.stderr) - return result - def _read_text_if_exists(self, path: Path) -> str: if not path.exists(): return "" return path.read_text(encoding="utf-8", errors="replace") + def _append_if_exists(self, artifacts: list[str], path: Path) -> None: + if path.exists(): + artifacts.append(str(path)) + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0021" case_log_dir = context.logs_dir / "tc0021" @@ -74,7 +65,12 @@ def run(self, context: E2EContext) -> TestResult: large_file = sync_root / root_name / "session-large.bin" large_file.parent.mkdir(parents=True, exist_ok=True) - large_file.write_bytes(b"R" * self.LARGE_FILE_SIZE) + + # Create a deterministic large file without huge memory allocation. + chunk = b"R" * (1024 * 1024) + with large_file.open("wb") as fp: + for _ in range(self.LARGE_FILE_SIZE // len(chunk)): + fp.write(chunk) context.bootstrap_config_dir(confdir) self._write_config(confdir / "config", sync_root, app_log_dir) @@ -90,6 +86,22 @@ def run(self, context: E2EContext) -> TestResult: verify_stderr = case_log_dir / "verify_stderr.log" remote_manifest_file = state_dir / "remote_verify_manifest.txt" metadata_file = state_dir / "metadata.txt" + local_tree_before_phase1 = state_dir / "local_tree_before_phase1.txt" + local_tree_after_phase1 = state_dir / "local_tree_after_phase1.txt" + local_tree_after_phase2 = state_dir / "local_tree_after_phase2.txt" + + def snapshot_tree(root: Path, output: Path) -> None: + lines: list[str] = [] + if root.exists(): + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + lines.append(rel + "/") + else: + lines.append(rel) + write_text_file(output, "\n".join(lines) + ("\n" if lines else "")) + + snapshot_tree(sync_root, local_tree_before_phase1) upload_command = [ context.onedrive_bin, @@ -119,20 +131,96 @@ def run(self, context: E2EContext) -> TestResult: time.sleep(5) process.send_signal(signal.SIGINT) try: - process.wait(timeout=30) + process.wait(timeout=60) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=30) + snapshot_tree(sync_root, local_tree_after_phase1) + phase1_stdout_text = self._read_text_if_exists(phase1_stdout) phase1_stderr_text = self._read_text_if_exists(phase1_stderr) app_log_text_after_phase1 = self._read_text_if_exists(app_log_file) + combined_phase1_output = ( + phase1_stdout_text + + "\n" + + phase1_stderr_text + + "\n" + + app_log_text_after_phase1 + ) + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(local_tree_before_phase1), + str(local_tree_after_phase1), + ] + self._append_if_exists(artifacts, app_log_dir) + + details = { + "phase1_returncode": process.returncode, + "root_name": root_name, + "large_size": self.LARGE_FILE_SIZE, + } + + crash_markers = [ + "Segmentation fault", + "core dumped", + "SIGSEGV", + "std.conv.ConvException", + "std.utf.UTFException", + "Traceback", + ] + for marker in crash_markers: + if marker in combined_phase1_output: + return TestResult.fail_result( + self.case_id, + self.name, + f"Interrupted upload phase triggered client crash or exception: {marker}", + artifacts, + details, + ) + + expected_shutdown_markers = [ + "Received termination signal", + "attempting to cleanly shutdown application", + ] + if not any(marker in combined_phase1_output for marker in expected_shutdown_markers): + return TestResult.fail_result( + self.case_id, + self.name, + "Interrupted upload phase did not show clean shutdown handling after SIGINT", + artifacts, + details, + ) + + if not large_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + "Source file no longer exists after interrupted upload; resumable transfer continuity was broken", + artifacts, + details, + ) + + safe_backup_matches = list(large_file.parent.glob("session-large-safeBackup-*")) + if safe_backup_matches: + return TestResult.fail_result( + self.case_id, + self.name, + f"Source file was renamed to safe-backup during interrupted upload: {safe_backup_matches[0].name}", + artifacts, + details, + ) + context.log(f"Executing Test Case {self.case_id} phase 2: {command_to_string(upload_command)}") phase2_result = run_command(upload_command, cwd=context.repo_root) write_text_file(phase2_stdout, phase2_result.stdout) write_text_file(phase2_stderr, phase2_result.stderr) + snapshot_tree(sync_root, local_tree_after_phase2) + verify_command = [ context.onedrive_bin, "--display-running-config", @@ -153,7 +241,28 @@ def run(self, context: E2EContext) -> TestResult: remote_manifest = build_manifest(verify_root) write_manifest(remote_manifest_file, remote_manifest) - current_large_file_exists = large_file.exists() + artifacts.extend( + [ + str(phase2_stdout), + str(phase2_stderr), + str(verify_stdout), + str(verify_stderr), + str(remote_manifest_file), + str(local_tree_after_phase2), + ] + ) + + phase2_stdout_text = self._read_text_if_exists(phase2_stdout) + phase2_stderr_text = self._read_text_if_exists(phase2_stderr) + combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + + details.update( + { + "phase2_returncode": phase2_result.returncode, + "verify_returncode": verify_result.returncode, + "large_file_exists_after_phase2": large_file.exists(), + } + ) write_text_file( metadata_file, @@ -165,61 +274,14 @@ def run(self, context: E2EContext) -> TestResult: f"phase2_returncode={phase2_result.returncode}", f"verify_returncode={verify_result.returncode}", f"large_size={self.LARGE_FILE_SIZE}", - f"large_file_exists_after_recovery={current_large_file_exists}", + f"large_file_exists_after_phase1={large_file.exists()}", + f"safe_backup_count_after_phase1={len(safe_backup_matches)}", f"app_log_file={app_log_file}", ] ) + "\n", ) - - artifacts = [ - str(phase1_stdout), - str(phase1_stderr), - str(phase2_stdout), - str(phase2_stderr), - str(verify_stdout), - str(verify_stderr), - str(remote_manifest_file), - str(metadata_file), - ] - if app_log_dir.exists(): - artifacts.append(str(app_log_dir)) - - details = { - "phase1_returncode": process.returncode, - "phase2_returncode": phase2_result.returncode, - "verify_returncode": verify_result.returncode, - "root_name": root_name, - "large_size": self.LARGE_FILE_SIZE, - "large_file_exists_after_recovery": current_large_file_exists, - } - - crash_markers = [ - "Segmentation fault", - "core dumped", - "SIGSEGV", - "std.conv.ConvException", - "std.utf.UTFException", - "Traceback", - ] - - combined_phase1_output = ( - phase1_stdout_text - + "\n" - + phase1_stderr_text - + "\n" - + app_log_text_after_phase1 - ) - - for marker in crash_markers: - if marker in combined_phase1_output: - return TestResult.fail_result( - self.case_id, - self.name, - f"Interrupted upload phase triggered client crash or exception: {marker}", - artifacts, - details, - ) + artifacts.append(str(metadata_file)) if phase2_result.returncode != 0: return TestResult.fail_result( @@ -239,6 +301,21 @@ def run(self, context: E2EContext) -> TestResult: details, ) + resume_markers = [ + "There are interrupted session uploads that need to be resumed", + "Attempting to restore file upload session", + "attempting to resume upload session", + "resume upload session", + ] + if not any(marker in combined_phase2_output or marker in app_log_text_after_phase1 for marker in resume_markers): + return TestResult.fail_result( + self.case_id, + self.name, + "Subsequent run did not show evidence of resumable session recovery", + artifacts, + details, + ) + if f"{root_name}/session-large.bin" not in remote_manifest: return TestResult.fail_result( self.case_id, From 016877b4593365cb843bf0b4784b26c5e86808a3 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Thu, 19 Mar 2026 06:23:33 +1100 Subject: [PATCH 086/245] Update tc0021 Update tc0021 --- ci/e2e/run.py | 54 +- .../tc0021_resumable_transfers_validation.py | 870 ++++++++++++++---- 2 files changed, 720 insertions(+), 204 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index e88a2cc92..98b4cf880 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -46,34 +46,34 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - TestCase0001BasicResync(), - TestCase0002SyncListValidation(), - TestCase0003DryRunValidation(), - TestCase0004SingleDirectorySync(), - TestCase0005ForceSyncOverride(), - TestCase0006DownloadOnly(), - TestCase0007DownloadOnlyCleanupLocalFiles(), - TestCase0008UploadOnly(), - TestCase0009UploadOnlyNoRemoteDelete(), - TestCase0010UploadOnlyRemoveSourceFiles(), - TestCase0011SkipFileValidation(), - TestCase0012SkipDirValidation(), - TestCase0013SkipDotfilesValidation(), - TestCase0014SkipSizeValidation(), - TestCase0015SkipSymlinksValidation(), - TestCase0016CheckNosyncValidation(), - TestCase0017CheckNomountValidation(), - TestCase0018RecycleBinValidation(), - TestCase0019LoggingAndRunningConfig(), - TestCase0020MonitorModeValidation(), + #TestCase0001BasicResync(), + #TestCase0002SyncListValidation(), + #TestCase0003DryRunValidation(), + #TestCase0004SingleDirectorySync(), + #TestCase0005ForceSyncOverride(), + #TestCase0006DownloadOnly(), + #TestCase0007DownloadOnlyCleanupLocalFiles(), + #TestCase0008UploadOnly(), + #TestCase0009UploadOnlyNoRemoteDelete(), + #TestCase0010UploadOnlyRemoveSourceFiles(), + #TestCase0011SkipFileValidation(), + #TestCase0012SkipDirValidation(), + #TestCase0013SkipDotfilesValidation(), + #TestCase0014SkipSizeValidation(), + #TestCase0015SkipSymlinksValidation(), + #TestCase0016CheckNosyncValidation(), + #TestCase0017CheckNomountValidation(), + #TestCase0018RecycleBinValidation(), + #TestCase0019LoggingAndRunningConfig(), + #TestCase0020MonitorModeValidation(), TestCase0021ResumableTransfersValidation(), - TestCase0022LocalFirstValidation(), - TestCase0023BypassDataPreservationValidation(), - TestCase0024BigDeleteSafeguardValidation(), - TestCase0025InvalidCharacterFilenameValidation(), - TestCase0026ReservedDeviceNameValidation(), - TestCase0027WhitespaceTrailingDotValidation(), - TestCase0028ControlCharacterNonUtf8FilenameValidation(), + #TestCase0022LocalFirstValidation(), + #TestCase0023BypassDataPreservationValidation(), + #TestCase0024BigDeleteSafeguardValidation(), + #TestCase0025InvalidCharacterFilenameValidation(), + #TestCase0026ReservedDeviceNameValidation(), + #TestCase0027WhitespaceTrailingDotValidation(), + #TestCase0028ControlCharacterNonUtf8FilenameValidation(), ] diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 2e85e6d65..ba3d78f51 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -4,6 +4,7 @@ import signal import subprocess import time +from dataclasses import dataclass from pathlib import Path from framework.base import E2ETestCase @@ -13,27 +14,42 @@ from framework.utils import command_to_string, reset_directory, run_command, write_text_file +@dataclass +class ScenarioResult: + scenario_id: str + description: str + passed: bool + failure_message: str = "" + artifacts: list[str] | None = None + details: dict | None = None + + class TestCase0021ResumableTransfersValidation(E2ETestCase): case_id = "0021" name = "resumable transfers validation" - description = "Validate interrupted upload recovery for a resumable session upload" + description = "Validate interrupted upload and download recovery for resumable transfers" - # Use a larger file so the transfer is definitely active when interrupted. LARGE_FILE_SIZE = 100 * 1024 * 1024 - - def _write_config(self, config_path: Path, sync_dir: Path, app_log_dir: Path) -> None: - write_text_file( - config_path, - ( - "# tc0021 config\n" - f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n' - 'enable_logging = "true"\n' - f'log_dir = "{app_log_dir}"\n' - 'force_session_upload = "true"\n' - 'rate_limit = "262144"\n' - ), - ) + RATE_LIMIT = "262144" + + def _write_config( + self, + config_path: Path, + sync_dir: Path, + app_log_dir: Path, + force_session_upload: bool = False, + ) -> None: + lines = [ + "# tc0021 config", + f'sync_dir = "{sync_dir}"', + 'bypass_data_preservation = "true"', + 'enable_logging = "true"', + f'log_dir = "{app_log_dir}"', + f'rate_limit = "{self.RATE_LIMIT}"', + ] + if force_session_upload: + lines.append('force_session_upload = "true"') + write_text_file(config_path, "\n".join(lines) + "\n") def _read_text_if_exists(self, path: Path) -> str: if not path.exists(): @@ -44,64 +60,155 @@ def _append_if_exists(self, artifacts: list[str], path: Path) -> None: if path.exists(): artifacts.append(str(path)) - def run(self, context: E2EContext) -> TestResult: - case_work_dir = context.work_root / "tc0021" - case_log_dir = context.logs_dir / "tc0021" - state_dir = context.state_dir / "tc0021" + def _snapshot_tree(self, root: Path, output: Path) -> None: + lines: list[str] = [] + if root.exists(): + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + lines.append(rel + "/") + else: + lines.append(rel) + write_text_file(output, "\n".join(lines) + ("\n" if lines else "")) + + def _create_large_file(self, path: Path, size_bytes: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + chunk = b"R" * (1024 * 1024) + chunk_count = size_bytes // len(chunk) + with path.open("wb") as fp: + for _ in range(chunk_count): + fp.write(chunk) + remainder = size_bytes % len(chunk) + if remainder: + fp.write(chunk[:remainder]) + + def _interrupt_process_and_capture( + self, + context: E2EContext, + label: str, + command: list[str], + stdout_file: Path, + stderr_file: Path, + interrupt_delay: int = 5, + wait_timeout: int = 60, + ) -> tuple[int, str, str]: + context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + + with stdout_file.open("w", encoding="utf-8") as stdout_fp, stderr_file.open( + "w", encoding="utf-8" + ) as stderr_fp: + process = subprocess.Popen( + command, + cwd=str(context.repo_root), + stdout=stdout_fp, + stderr=stderr_fp, + text=True, + ) + time.sleep(interrupt_delay) + process.send_signal(signal.SIGINT) + try: + process.wait(timeout=wait_timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=30) - reset_directory(case_work_dir) - reset_directory(case_log_dir) - reset_directory(state_dir) - context.ensure_refresh_token_available() + stdout_text = self._read_text_if_exists(stdout_file) + stderr_text = self._read_text_if_exists(stderr_file) + return process.returncode, stdout_text, stderr_text + + def _run_and_capture( + self, + context: E2EContext, + label: str, + command: list[str], + stdout_file: Path, + stderr_file: Path, + ): + context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_file, result.stdout) + write_text_file(stderr_file, result.stderr) + return result + + def _contains_any_marker(self, text: str, markers: list[str]) -> bool: + return any(marker in text for marker in markers) + + def _scenario_fail( + self, + scenario_id: str, + description: str, + message: str, + artifacts: list[str], + details: dict, + ) -> ScenarioResult: + return ScenarioResult( + scenario_id=scenario_id, + description=description, + passed=False, + failure_message=message, + artifacts=artifacts, + details=details, + ) - sync_root = case_work_dir / "syncroot" - confdir = case_work_dir / "conf-main" - verify_root = case_work_dir / "verifyroot" - verify_conf = case_work_dir / "conf-verify" + def _scenario_pass( + self, + scenario_id: str, + description: str, + artifacts: list[str], + details: dict, + ) -> ScenarioResult: + return ScenarioResult( + scenario_id=scenario_id, + description=description, + passed=True, + artifacts=artifacts, + details=details, + ) - root_name = f"ZZ_E2E_TC0021_{context.run_id}_{os.getpid()}" - app_log_dir = case_log_dir / "app-logs" + def _run_upload_resume_scenario( + self, + context: E2EContext, + root_name: str, + sync_root: Path, + verify_root: Path, + scenario_work_dir: Path, + scenario_log_dir: Path, + scenario_state_dir: Path, + ) -> ScenarioResult: + scenario_id = "RT-0001" + description = "resumable upload" + + conf_main = scenario_work_dir / "conf-main" + conf_verify = scenario_work_dir / "conf-verify" + app_log_dir = scenario_log_dir / "app-logs" app_log_file = app_log_dir / "root.onedrive.log" - large_file = sync_root / root_name / "session-large.bin" - large_file.parent.mkdir(parents=True, exist_ok=True) + reset_directory(conf_main) + reset_directory(conf_verify) + context.bootstrap_config_dir(conf_main) + context.bootstrap_config_dir(conf_verify) - # Create a deterministic large file without huge memory allocation. - chunk = b"R" * (1024 * 1024) - with large_file.open("wb") as fp: - for _ in range(self.LARGE_FILE_SIZE // len(chunk)): - fp.write(chunk) + self._write_config(conf_main / "config", sync_root, app_log_dir, force_session_upload=True) + self._write_config(conf_verify / "config", verify_root, app_log_dir, force_session_upload=False) + + relative_path = f"{root_name}/{scenario_id}/session-large.bin" + local_file = sync_root / relative_path + self._create_large_file(local_file, self.LARGE_FILE_SIZE) + + phase1_stdout = scenario_log_dir / "phase1_stdout.log" + phase1_stderr = scenario_log_dir / "phase1_stderr.log" + phase2_stdout = scenario_log_dir / "phase2_stdout.log" + phase2_stderr = scenario_log_dir / "phase2_stderr.log" + verify_stdout = scenario_log_dir / "verify_stdout.log" + verify_stderr = scenario_log_dir / "verify_stderr.log" - context.bootstrap_config_dir(confdir) - self._write_config(confdir / "config", sync_root, app_log_dir) - - context.bootstrap_config_dir(verify_conf) - self._write_config(verify_conf / "config", verify_root, app_log_dir) - - phase1_stdout = case_log_dir / "phase1_stdout.log" - phase1_stderr = case_log_dir / "phase1_stderr.log" - phase2_stdout = case_log_dir / "phase2_stdout.log" - phase2_stderr = case_log_dir / "phase2_stderr.log" - verify_stdout = case_log_dir / "verify_stdout.log" - verify_stderr = case_log_dir / "verify_stderr.log" - remote_manifest_file = state_dir / "remote_verify_manifest.txt" - metadata_file = state_dir / "metadata.txt" - local_tree_before_phase1 = state_dir / "local_tree_before_phase1.txt" - local_tree_after_phase1 = state_dir / "local_tree_after_phase1.txt" - local_tree_after_phase2 = state_dir / "local_tree_after_phase2.txt" - - def snapshot_tree(root: Path, output: Path) -> None: - lines: list[str] = [] - if root.exists(): - for path in sorted(root.rglob("*")): - rel = path.relative_to(root).as_posix() - if path.is_dir(): - lines.append(rel + "/") - else: - lines.append(rel) - write_text_file(output, "\n".join(lines) + ("\n" if lines else "")) - - snapshot_tree(sync_root, local_tree_before_phase1) + local_tree_before = scenario_state_dir / "local_tree_before_phase1.txt" + local_tree_after_phase1 = scenario_state_dir / "local_tree_after_phase1.txt" + local_tree_after_phase2 = scenario_state_dir / "local_tree_after_phase2.txt" + remote_manifest_file = scenario_state_dir / "remote_verify_manifest.txt" + metadata_file = scenario_state_dir / "metadata.txt" + + self._snapshot_tree(sync_root, local_tree_before) upload_command = [ context.onedrive_bin, @@ -112,58 +219,122 @@ def snapshot_tree(root: Path, output: Path) -> None: "--resync", "--resync-auth", "--single-directory", - root_name, + f"{root_name}/{scenario_id}", "--confdir", - str(confdir), + str(conf_main), ] - context.log(f"Executing Test Case {self.case_id} phase 1: {command_to_string(upload_command)}") - with phase1_stdout.open("w", encoding="utf-8") as stdout_fp, phase1_stderr.open( - "w", encoding="utf-8" - ) as stderr_fp: - process = subprocess.Popen( - upload_command, - cwd=str(context.repo_root), - stdout=stdout_fp, - stderr=stderr_fp, - text=True, - ) - time.sleep(5) - process.send_signal(signal.SIGINT) - try: - process.wait(timeout=60) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=30) - - snapshot_tree(sync_root, local_tree_after_phase1) + phase1_returncode, phase1_stdout_text, phase1_stderr_text = self._interrupt_process_and_capture( + context, + f"{scenario_id} phase 1", + upload_command, + phase1_stdout, + phase1_stderr, + ) - phase1_stdout_text = self._read_text_if_exists(phase1_stdout) - phase1_stderr_text = self._read_text_if_exists(phase1_stderr) - app_log_text_after_phase1 = self._read_text_if_exists(app_log_file) + self._snapshot_tree(sync_root, local_tree_after_phase1) + app_log_after_phase1 = self._read_text_if_exists(app_log_file) combined_phase1_output = ( phase1_stdout_text + "\n" + phase1_stderr_text + "\n" - + app_log_text_after_phase1 + + app_log_after_phase1 + ) + + phase2_result = self._run_and_capture( + context, + f"{scenario_id} phase 2", + upload_command, + phase2_stdout, + phase2_stderr, + ) + + phase2_stdout_text = self._read_text_if_exists(phase2_stdout) + phase2_stderr_text = self._read_text_if_exists(phase2_stderr) + app_log_after_phase2 = self._read_text_if_exists(app_log_file) + combined_phase2_output = ( + phase2_stdout_text + + "\n" + + phase2_stderr_text + + "\n" + + app_log_after_phase2 ) + self._snapshot_tree(sync_root, local_tree_after_phase2) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + f"{root_name}/{scenario_id}", + "--confdir", + str(conf_verify), + ] + + verify_result = self._run_and_capture( + context, + f"{scenario_id} verify", + verify_command, + verify_stdout, + verify_stderr, + ) + + remote_manifest = build_manifest(verify_root) + write_manifest(remote_manifest_file, remote_manifest) + + safe_backup_matches = list(local_file.parent.glob("session-large-safeBackup-*")) + artifacts = [ str(phase1_stdout), str(phase1_stderr), - str(local_tree_before_phase1), + str(phase2_stdout), + str(phase2_stderr), + str(verify_stdout), + str(verify_stderr), + str(local_tree_before), str(local_tree_after_phase1), + str(local_tree_after_phase2), + str(remote_manifest_file), + str(metadata_file), ] self._append_if_exists(artifacts, app_log_dir) details = { - "phase1_returncode": process.returncode, - "root_name": root_name, + "scenario_id": scenario_id, + "phase1_returncode": phase1_returncode, + "phase2_returncode": phase2_result.returncode, + "verify_returncode": verify_result.returncode, + "relative_path": relative_path, "large_size": self.LARGE_FILE_SIZE, + "local_file_exists_after_phase1": local_file.exists(), + "safe_backup_count_after_phase1": len(safe_backup_matches), } + write_text_file( + metadata_file, + "\n".join( + [ + f"scenario_id={scenario_id}", + f"phase1_returncode={phase1_returncode}", + f"phase2_returncode={phase2_result.returncode}", + f"verify_returncode={verify_result.returncode}", + f"relative_path={relative_path}", + f"large_size={self.LARGE_FILE_SIZE}", + f"local_file_exists_after_phase1={local_file.exists()}", + f"safe_backup_count_after_phase1={len(safe_backup_matches)}", + f"app_log_file={app_log_file}", + ] + ) + + "\n", + ) + crash_markers = [ "Segmentation fault", "core dumped", @@ -172,56 +343,188 @@ def snapshot_tree(root: Path, output: Path) -> None: "std.utf.UTFException", "Traceback", ] - for marker in crash_markers: - if marker in combined_phase1_output: - return TestResult.fail_result( - self.case_id, - self.name, - f"Interrupted upload phase triggered client crash or exception: {marker}", - artifacts, - details, - ) - - expected_shutdown_markers = [ + if self._contains_any_marker(combined_phase1_output, crash_markers): + for marker in crash_markers: + if marker in combined_phase1_output: + return self._scenario_fail( + scenario_id, + description, + f"Interrupted upload phase triggered client crash or exception: {marker}", + artifacts, + details, + ) + + clean_shutdown_markers = [ "Received termination signal", "attempting to cleanly shutdown application", ] - if not any(marker in combined_phase1_output for marker in expected_shutdown_markers): - return TestResult.fail_result( - self.case_id, - self.name, + if not self._contains_any_marker(combined_phase1_output, clean_shutdown_markers): + return self._scenario_fail( + scenario_id, + description, "Interrupted upload phase did not show clean shutdown handling after SIGINT", artifacts, details, ) - if not large_file.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - "Source file no longer exists after interrupted upload; resumable transfer continuity was broken", + if not local_file.exists(): + return self._scenario_fail( + scenario_id, + description, + "Source file no longer exists after interrupted upload; resumable upload continuity was broken", artifacts, details, ) - safe_backup_matches = list(large_file.parent.glob("session-large-safeBackup-*")) if safe_backup_matches: - return TestResult.fail_result( - self.case_id, - self.name, + return self._scenario_fail( + scenario_id, + description, f"Source file was renamed to safe-backup during interrupted upload: {safe_backup_matches[0].name}", artifacts, details, ) - context.log(f"Executing Test Case {self.case_id} phase 2: {command_to_string(upload_command)}") - phase2_result = run_command(upload_command, cwd=context.repo_root) - write_text_file(phase2_stdout, phase2_result.stdout) - write_text_file(phase2_stderr, phase2_result.stderr) + if phase2_result.returncode != 0: + return self._scenario_fail( + scenario_id, + description, + f"Resumable upload recovery phase failed with status {phase2_result.returncode}", + artifacts, + details, + ) - snapshot_tree(sync_root, local_tree_after_phase2) + if verify_result.returncode != 0: + return self._scenario_fail( + scenario_id, + description, + f"Remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) - verify_command = [ + upload_resume_markers = [ + "There are interrupted session uploads that need to be resumed", + "Attempting to restore file upload session", + "resume upload session", + "resumed_upload", + ] + if not self._contains_any_marker(combined_phase2_output, upload_resume_markers): + return self._scenario_fail( + scenario_id, + description, + "Subsequent upload run did not show evidence of resumable upload recovery", + artifacts, + details, + ) + + if relative_path not in remote_manifest: + return self._scenario_fail( + scenario_id, + description, + "Interrupted resumable upload did not complete successfully on the subsequent run", + artifacts, + details, + ) + + return self._scenario_pass(scenario_id, description, artifacts, details) + + def _run_download_resume_scenario( + self, + context: E2EContext, + root_name: str, + scenario_work_dir: Path, + scenario_log_dir: Path, + scenario_state_dir: Path, + ) -> ScenarioResult: + scenario_id = "RT-0002" + description = "resumable download" + + seed_root = scenario_work_dir / "seedroot" + download_root = scenario_work_dir / "downloadroot" + verify_root = scenario_work_dir / "verifyroot" + + conf_seed = scenario_work_dir / "conf-seed" + conf_download = scenario_work_dir / "conf-download" + conf_verify = scenario_work_dir / "conf-verify" + + app_log_dir = scenario_log_dir / "app-logs" + app_log_file = app_log_dir / "root.onedrive.log" + + reset_directory(seed_root) + reset_directory(download_root) + reset_directory(verify_root) + reset_directory(conf_seed) + reset_directory(conf_download) + reset_directory(conf_verify) + + context.bootstrap_config_dir(conf_seed) + context.bootstrap_config_dir(conf_download) + context.bootstrap_config_dir(conf_verify) + + self._write_config(conf_seed / "config", seed_root, app_log_dir, force_session_upload=True) + self._write_config(conf_download / "config", download_root, app_log_dir, force_session_upload=False) + self._write_config(conf_verify / "config", verify_root, app_log_dir, force_session_upload=False) + + relative_path = f"{root_name}/{scenario_id}/session-large.bin" + seed_file = seed_root / relative_path + self._create_large_file(seed_file, self.LARGE_FILE_SIZE) + + seed_stdout = scenario_log_dir / "seed_stdout.log" + seed_stderr = scenario_log_dir / "seed_stderr.log" + phase1_stdout = scenario_log_dir / "phase1_stdout.log" + phase1_stderr = scenario_log_dir / "phase1_stderr.log" + phase2_stdout = scenario_log_dir / "phase2_stdout.log" + phase2_stderr = scenario_log_dir / "phase2_stderr.log" + verify_stdout = scenario_log_dir / "verify_stdout.log" + verify_stderr = scenario_log_dir / "verify_stderr.log" + + local_tree_before = scenario_state_dir / "local_tree_before_phase1.txt" + local_tree_after_phase1 = scenario_state_dir / "local_tree_after_phase1.txt" + local_tree_after_phase2 = scenario_state_dir / "local_tree_after_phase2.txt" + local_tree_after_verify = scenario_state_dir / "local_tree_after_verify.txt" + verify_manifest_file = scenario_state_dir / "verify_manifest.txt" + metadata_file = scenario_state_dir / "metadata.txt" + + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--upload-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + f"{root_name}/{scenario_id}", + "--confdir", + str(conf_seed), + ] + seed_result = self._run_and_capture( + context, + f"{scenario_id} seed", + seed_command, + seed_stdout, + seed_stderr, + ) + + if seed_result.returncode != 0: + artifacts = [str(seed_stdout), str(seed_stderr)] + details = { + "scenario_id": scenario_id, + "seed_returncode": seed_result.returncode, + "relative_path": relative_path, + } + return self._scenario_fail( + scenario_id, + description, + f"Seed upload phase failed with status {seed_result.returncode}", + artifacts, + details, + ) + + self._snapshot_tree(download_root, local_tree_before) + + download_command = [ context.onedrive_bin, "--display-running-config", "--sync", @@ -230,99 +533,312 @@ def snapshot_tree(root: Path, output: Path) -> None: "--resync", "--resync-auth", "--single-directory", - root_name, + f"{root_name}/{scenario_id}", "--confdir", - str(verify_conf), + str(conf_download), ] - verify_result = run_command(verify_command, cwd=context.repo_root) - write_text_file(verify_stdout, verify_result.stdout) - write_text_file(verify_stderr, verify_result.stderr) - remote_manifest = build_manifest(verify_root) - write_manifest(remote_manifest_file, remote_manifest) + phase1_returncode, phase1_stdout_text, phase1_stderr_text = self._interrupt_process_and_capture( + context, + f"{scenario_id} phase 1", + download_command, + phase1_stdout, + phase1_stderr, + ) - artifacts.extend( - [ - str(phase2_stdout), - str(phase2_stderr), - str(verify_stdout), - str(verify_stderr), - str(remote_manifest_file), - str(local_tree_after_phase2), - ] + self._snapshot_tree(download_root, local_tree_after_phase1) + + app_log_after_phase1 = self._read_text_if_exists(app_log_file) + combined_phase1_output = ( + phase1_stdout_text + + "\n" + + phase1_stderr_text + + "\n" + + app_log_after_phase1 + ) + + phase2_result = self._run_and_capture( + context, + f"{scenario_id} phase 2", + download_command, + phase2_stdout, + phase2_stderr, ) phase2_stdout_text = self._read_text_if_exists(phase2_stdout) phase2_stderr_text = self._read_text_if_exists(phase2_stderr) - combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + app_log_after_phase2 = self._read_text_if_exists(app_log_file) + combined_phase2_output = ( + phase2_stdout_text + + "\n" + + phase2_stderr_text + + "\n" + + app_log_after_phase2 + ) - details.update( - { - "phase2_returncode": phase2_result.returncode, - "verify_returncode": verify_result.returncode, - "large_file_exists_after_phase2": large_file.exists(), - } + self._snapshot_tree(download_root, local_tree_after_phase2) + + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + f"{root_name}/{scenario_id}", + "--confdir", + str(conf_verify), + ] + verify_result = self._run_and_capture( + context, + f"{scenario_id} verify", + verify_command, + verify_stdout, + verify_stderr, ) + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + self._snapshot_tree(verify_root, local_tree_after_verify) + + downloaded_file = download_root / relative_path + + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(verify_stdout), + str(verify_stderr), + str(local_tree_before), + str(local_tree_after_phase1), + str(local_tree_after_phase2), + str(local_tree_after_verify), + str(verify_manifest_file), + str(metadata_file), + ] + self._append_if_exists(artifacts, app_log_dir) + + details = { + "scenario_id": scenario_id, + "seed_returncode": seed_result.returncode, + "phase1_returncode": phase1_returncode, + "phase2_returncode": phase2_result.returncode, + "verify_returncode": verify_result.returncode, + "relative_path": relative_path, + "large_size": self.LARGE_FILE_SIZE, + "downloaded_file_exists_after_phase2": downloaded_file.exists(), + } + write_text_file( metadata_file, "\n".join( [ - f"case_id={self.case_id}", - f"root_name={root_name}", - f"phase1_returncode={process.returncode}", + f"scenario_id={scenario_id}", + f"seed_returncode={seed_result.returncode}", + f"phase1_returncode={phase1_returncode}", f"phase2_returncode={phase2_result.returncode}", f"verify_returncode={verify_result.returncode}", + f"relative_path={relative_path}", f"large_size={self.LARGE_FILE_SIZE}", - f"large_file_exists_after_phase1={large_file.exists()}", - f"safe_backup_count_after_phase1={len(safe_backup_matches)}", + f"downloaded_file_exists_after_phase2={downloaded_file.exists()}", f"app_log_file={app_log_file}", ] ) + "\n", ) - artifacts.append(str(metadata_file)) + + crash_markers = [ + "Segmentation fault", + "core dumped", + "SIGSEGV", + "std.conv.ConvException", + "std.utf.UTFException", + "Traceback", + ] + if self._contains_any_marker(combined_phase1_output, crash_markers): + for marker in crash_markers: + if marker in combined_phase1_output: + return self._scenario_fail( + scenario_id, + description, + f"Interrupted download phase triggered client crash or exception: {marker}", + artifacts, + details, + ) + + clean_shutdown_markers = [ + "Received termination signal", + "attempting to cleanly shutdown application", + ] + if not self._contains_any_marker(combined_phase1_output, clean_shutdown_markers): + return self._scenario_fail( + scenario_id, + description, + "Interrupted download phase did not show clean shutdown handling after SIGINT", + artifacts, + details, + ) if phase2_result.returncode != 0: - return TestResult.fail_result( - self.case_id, - self.name, - f"Resumable upload recovery phase failed with status {phase2_result.returncode}", + return self._scenario_fail( + scenario_id, + description, + f"Resumable download recovery phase failed with status {phase2_result.returncode}", artifacts, details, ) if verify_result.returncode != 0: - return TestResult.fail_result( - self.case_id, - self.name, - f"Remote verification failed with status {verify_result.returncode}", + return self._scenario_fail( + scenario_id, + description, + f"Download verification phase failed with status {verify_result.returncode}", artifacts, details, ) - resume_markers = [ - "There are interrupted session uploads that need to be resumed", - "Attempting to restore file upload session", - "attempting to resume upload session", - "resume upload session", + download_resume_markers = [ + "There are interrupted downloads that need to be resumed", + "Attempting to resume file download using this 'resumable data' file", + "resume file download", + "resumed_download", ] - if not any(marker in combined_phase2_output or marker in app_log_text_after_phase1 for marker in resume_markers): - return TestResult.fail_result( - self.case_id, - self.name, - "Subsequent run did not show evidence of resumable session recovery", + if not self._contains_any_marker(combined_phase2_output, download_resume_markers): + return self._scenario_fail( + scenario_id, + description, + "Subsequent download run did not show evidence of resumable download recovery", artifacts, details, ) - if f"{root_name}/session-large.bin" not in remote_manifest: + if not downloaded_file.exists(): + return self._scenario_fail( + scenario_id, + description, + "Interrupted resumable download did not produce the expected local file on the subsequent run", + artifacts, + details, + ) + + if downloaded_file.stat().st_size != self.LARGE_FILE_SIZE: + return self._scenario_fail( + scenario_id, + description, + "Downloaded file size after resumed download did not match expected size", + artifacts, + details, + ) + + if relative_path not in verify_manifest: + return self._scenario_fail( + scenario_id, + description, + "Verification download did not contain the expected remote file", + artifacts, + details, + ) + + return self._scenario_pass(scenario_id, description, artifacts, details) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0021" + case_log_dir = context.logs_dir / "tc0021" + state_dir = context.state_dir / "tc0021" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + root_name = f"ZZ_E2E_TC0021_{context.run_id}_{os.getpid()}" + + upload_sync_root = case_work_dir / "upload-syncroot" + upload_verify_root = case_work_dir / "upload-verifyroot" + upload_work_dir = case_work_dir / "rt0001-upload" + upload_log_dir = case_log_dir / "rt0001-upload" + upload_state_dir = state_dir / "rt0001-upload" + + reset_directory(upload_sync_root) + reset_directory(upload_verify_root) + reset_directory(upload_work_dir) + reset_directory(upload_log_dir) + reset_directory(upload_state_dir) + + results: list[ScenarioResult] = [] + + results.append( + self._run_upload_resume_scenario( + context, + root_name, + upload_sync_root, + upload_verify_root, + upload_work_dir, + upload_log_dir, + upload_state_dir, + ) + ) + + download_work_dir = case_work_dir / "rt0002-download" + download_log_dir = case_log_dir / "rt0002-download" + download_state_dir = state_dir / "rt0002-download" + + reset_directory(download_work_dir) + reset_directory(download_log_dir) + reset_directory(download_state_dir) + + results.append( + self._run_download_resume_scenario( + context, + root_name, + download_work_dir, + download_log_dir, + download_state_dir, + ) + ) + + failed = [result for result in results if not result.passed] + artifacts: list[str] = [] + details: dict = {"root_name": root_name, "scenario_results": {}} + + for result in results: + if result.artifacts: + artifacts.extend(result.artifacts) + if result.details: + details["scenario_results"][result.scenario_id] = result.details + + deduped_artifacts = [] + seen = set() + for artifact in artifacts: + if artifact not in seen: + deduped_artifacts.append(artifact) + seen.add(artifact) + + summary_file = state_dir / "scenario_summary.txt" + summary_lines = [] + for result in results: + status = "PASS" if result.passed else "FAIL" + line = f"{result.scenario_id} [{status}] {result.description}" + if result.failure_message: + line += f" — {result.failure_message}" + summary_lines.append(line) + write_text_file(summary_file, "\n".join(summary_lines) + "\n") + deduped_artifacts.append(str(summary_file)) + + if failed: + failed_ids = ", ".join(result.scenario_id for result in failed) + first_failure = failed[0].failure_message or "scenario failure" return TestResult.fail_result( self.case_id, self.name, - "Interrupted resumable upload did not complete successfully on the subsequent run", - artifacts, + f"{len(failed)} of {len(results)} resumable transfer scenarios failed: {failed_ids} — {first_failure}", + deduped_artifacts, details, ) - return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file + return TestResult.pass_result(self.case_id, self.name, deduped_artifacts, details) \ No newline at end of file From 26be0c51c9209a5773ba34a4147ed66dd511dee6 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Thu, 19 Mar 2026 17:45:56 +1100 Subject: [PATCH 087/245] Update utils.py Update perform_full_account_cleanup() --- ci/e2e/framework/utils.py | 68 +++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/ci/e2e/framework/utils.py b/ci/e2e/framework/utils.py index 839563fff..407846dd5 100644 --- a/ci/e2e/framework/utils.py +++ b/ci/e2e/framework/utils.py @@ -121,13 +121,13 @@ def perform_full_account_cleanup( ) -> tuple[bool, str, list[str], dict]: """ Clean the entire account by: - 1. full resync/resync-auth - 2. deleting everything locally - 3. running sync to push deletes online - 4. running sync again to confirm no further transfers + 1. Discovering and materialising remote state locally without uploading anything + 2. Deleting everything locally + 3. Running sync to push deletes online + 4. Running download-only sync to confirm the remote side is empty Returns: - (success, reason, artifacts, details) + (success, reason, artifacts, details) """ ensure_directory(log_dir) ensure_directory(sync_dir) @@ -150,12 +150,17 @@ def perform_full_account_cleanup( str(phase4_stderr), ] + # Phase 1: + # Discover remote state only. Do not upload anything. Do not fail because + # stale remote testcase artefacts trigger download-integrity validation. phase1_command = [ onedrive_bin, "--sync", "--verbose", + "--download-only", "--resync", "--resync-auth", + "--disable-download-validation", "--confdir", str(config_dir), ] @@ -170,14 +175,17 @@ def perform_full_account_cleanup( False, f"Cleanup phase 1 failed with status {phase1.returncode}", artifacts, - {"phase1_returncode": phase1.returncode}, + { + "phase1_returncode": phase1.returncode, + "phase1_command": command_to_string(phase1_command), + }, ) + # Phase 2: + # Purge the entire local sync root. Cleanup is destructive by design. purge_directory_contents(sync_dir) - remaining_after_purge = [] - for child in sync_dir.iterdir(): - remaining_after_purge.append(str(child)) + remaining_after_purge = [str(child) for child in sync_dir.iterdir()] write_text_file( phase2_state, "\n".join(remaining_after_purge) + ("\n" if remaining_after_purge else ""), @@ -191,6 +199,8 @@ def perform_full_account_cleanup( {"remaining_after_purge": remaining_after_purge}, ) + # Phase 3: + # Push local deletions online. phase3_command = [ onedrive_bin, "--sync", @@ -209,13 +219,21 @@ def perform_full_account_cleanup( False, f"Cleanup phase 3 failed with status {phase3.returncode}", artifacts, - {"phase3_returncode": phase3.returncode}, + { + "phase3_returncode": phase3.returncode, + "phase3_command": command_to_string(phase3_command), + }, ) + # Phase 4: + # Verify emptiness by pulling from remote only. + # If anything still exists online, it will be downloaded back locally. phase4_command = [ onedrive_bin, "--sync", "--verbose", + "--download-only", + "--disable-download-validation", "--confdir", str(config_dir), ] @@ -230,36 +248,21 @@ def perform_full_account_cleanup( False, f"Cleanup phase 4 failed with status {phase4.returncode}", artifacts, - {"phase4_returncode": phase4.returncode}, + { + "phase4_returncode": phase4.returncode, + "phase4_command": command_to_string(phase4_command), + }, ) - remaining_after_verify = [] - for child in sync_dir.iterdir(): - remaining_after_verify.append(str(child)) - + remaining_after_verify = [str(child) for child in sync_dir.iterdir()] if remaining_after_verify: return ( False, - "Cleanup verification failed: local sync directory is not empty after final sync", + "Cleanup verification failed: remote content still exists after delete propagation", artifacts, {"remaining_after_verify": remaining_after_verify}, ) - phase4_output = phase4.stdout + "\n" + phase4.stderr - suspicious_markers = [ - "Downloading ", - "Creating local directory:", - "Uploading ", - ] - detected_markers = [marker for marker in suspicious_markers if marker in phase4_output] - if detected_markers: - return ( - False, - "Cleanup verification failed: final sync still performed transfer activity", - artifacts, - {"detected_markers": detected_markers}, - ) - return ( True, "", @@ -268,5 +271,8 @@ def perform_full_account_cleanup( "phase1_returncode": phase1.returncode, "phase3_returncode": phase3.returncode, "phase4_returncode": phase4.returncode, + "phase1_command": command_to_string(phase1_command), + "phase3_command": command_to_string(phase3_command), + "phase4_command": command_to_string(phase4_command), }, ) \ No newline at end of file From ddd09878137c0510a8f03844b54afa0a4cb57644 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 20 Mar 2026 14:02:19 +1100 Subject: [PATCH 088/245] Update cleanup() * Update cleanup() --- ci/e2e/framework/utils.py | 68 ++++++++------------------------------- ci/e2e/run.py | 2 +- 2 files changed, 15 insertions(+), 55 deletions(-) diff --git a/ci/e2e/framework/utils.py b/ci/e2e/framework/utils.py index 407846dd5..749f820ca 100644 --- a/ci/e2e/framework/utils.py +++ b/ci/e2e/framework/utils.py @@ -120,11 +120,11 @@ def perform_full_account_cleanup( log_dir: Path, ) -> tuple[bool, str, list[str], dict]: """ - Clean the entire account by: - 1. Discovering and materialising remote state locally without uploading anything + Clean the account by: + 1. Running a full resync to establish local state from remote 2. Deleting everything locally - 3. Running sync to push deletes online - 4. Running download-only sync to confirm the remote side is empty + 3. Running a normal sync to propagate deletions online + 4. Validating that the local sync directory is empty Returns: (success, reason, artifacts, details) @@ -135,10 +135,8 @@ def perform_full_account_cleanup( phase1_stdout = log_dir / "cleanup_phase1_resync_stdout.log" phase1_stderr = log_dir / "cleanup_phase1_resync_stderr.log" phase2_state = log_dir / "cleanup_phase2_local_purge_state.txt" - phase3_stdout = log_dir / "cleanup_phase3_push_deletes_stdout.log" - phase3_stderr = log_dir / "cleanup_phase3_push_deletes_stderr.log" - phase4_stdout = log_dir / "cleanup_phase4_verify_empty_stdout.log" - phase4_stderr = log_dir / "cleanup_phase4_verify_empty_stderr.log" + phase3_stdout = log_dir / "cleanup_phase3_sync_stdout.log" + phase3_stderr = log_dir / "cleanup_phase3_sync_stderr.log" artifacts = [ str(phase1_stdout), @@ -146,21 +144,15 @@ def perform_full_account_cleanup( str(phase2_state), str(phase3_stdout), str(phase3_stderr), - str(phase4_stdout), - str(phase4_stderr), ] - # Phase 1: - # Discover remote state only. Do not upload anything. Do not fail because - # stale remote testcase artefacts trigger download-integrity validation. + # Phase 1: establish local state from remote phase1_command = [ onedrive_bin, "--sync", "--verbose", - "--download-only", "--resync", "--resync-auth", - "--disable-download-validation", "--confdir", str(config_dir), ] @@ -181,8 +173,7 @@ def perform_full_account_cleanup( }, ) - # Phase 2: - # Purge the entire local sync root. Cleanup is destructive by design. + # Phase 2: delete everything locally purge_directory_contents(sync_dir) remaining_after_purge = [str(child) for child in sync_dir.iterdir()] @@ -199,8 +190,7 @@ def perform_full_account_cleanup( {"remaining_after_purge": remaining_after_purge}, ) - # Phase 3: - # Push local deletions online. + # Phase 3: propagate deletions online phase3_command = [ onedrive_bin, "--sync", @@ -225,42 +215,14 @@ def perform_full_account_cleanup( }, ) - # Phase 4: - # Verify emptiness by pulling from remote only. - # If anything still exists online, it will be downloaded back locally. - phase4_command = [ - onedrive_bin, - "--sync", - "--verbose", - "--download-only", - "--disable-download-validation", - "--confdir", - str(config_dir), - ] - phase4 = run_command_logged( - phase4_command, - stdout_file=phase4_stdout, - stderr_file=phase4_stderr, - cwd=repo_root, - ) - if phase4.returncode != 0: - return ( - False, - f"Cleanup phase 4 failed with status {phase4.returncode}", - artifacts, - { - "phase4_returncode": phase4.returncode, - "phase4_command": command_to_string(phase4_command), - }, - ) - - remaining_after_verify = [str(child) for child in sync_dir.iterdir()] - if remaining_after_verify: + # Phase 4: validate local is empty + remaining_after_sync = [str(child) for child in sync_dir.iterdir()] + if remaining_after_sync: return ( False, - "Cleanup verification failed: remote content still exists after delete propagation", + "Cleanup phase 4 failed: local sync directory is not empty after sync", artifacts, - {"remaining_after_verify": remaining_after_verify}, + {"remaining_after_sync": remaining_after_sync}, ) return ( @@ -270,9 +232,7 @@ def perform_full_account_cleanup( { "phase1_returncode": phase1.returncode, "phase3_returncode": phase3.returncode, - "phase4_returncode": phase4.returncode, "phase1_command": command_to_string(phase1_command), "phase3_command": command_to_string(phase3_command), - "phase4_command": command_to_string(phase4_command), }, ) \ No newline at end of file diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 98b4cf880..e9a467038 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -66,7 +66,7 @@ def build_test_suite() -> list: #TestCase0018RecycleBinValidation(), #TestCase0019LoggingAndRunningConfig(), #TestCase0020MonitorModeValidation(), - TestCase0021ResumableTransfersValidation(), + #TestCase0021ResumableTransfersValidation(), #TestCase0022LocalFirstValidation(), #TestCase0023BypassDataPreservationValidation(), #TestCase0024BigDeleteSafeguardValidation(), From af6dc17e3912fe8dab67acbda38889cd7f0e3ec2 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 20 Mar 2026 14:36:48 +1100 Subject: [PATCH 089/245] switch to debug logging for tc0021 * switch to debug logging for tc0021 --- ci/e2e/run.py | 2 +- ci/e2e/testcases/tc0021_resumable_transfers_validation.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index e9a467038..98b4cf880 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -66,7 +66,7 @@ def build_test_suite() -> list: #TestCase0018RecycleBinValidation(), #TestCase0019LoggingAndRunningConfig(), #TestCase0020MonitorModeValidation(), - #TestCase0021ResumableTransfersValidation(), + TestCase0021ResumableTransfersValidation(), #TestCase0022LocalFirstValidation(), #TestCase0023BypassDataPreservationValidation(), #TestCase0024BigDeleteSafeguardValidation(), diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index ba3d78f51..6d5cf595a 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -216,6 +216,7 @@ def _run_upload_resume_scenario( "--sync", "--upload-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -269,6 +270,7 @@ def _run_upload_resume_scenario( "--display-running-config", "--sync", "--verbose", + "--verbose", "--download-only", "--resync", "--resync-auth", @@ -492,6 +494,7 @@ def _run_download_resume_scenario( "--sync", "--upload-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -529,6 +532,7 @@ def _run_download_resume_scenario( "--display-running-config", "--sync", "--verbose", + "--verbose", "--download-only", "--resync", "--resync-auth", @@ -583,6 +587,7 @@ def _run_download_resume_scenario( "--display-running-config", "--sync", "--verbose", + "--verbose", "--download-only", "--resync", "--resync-auth", From 84b12ab3a20728dd9eda78bdd3c05a3472583dae Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 20 Mar 2026 15:01:39 +1100 Subject: [PATCH 090/245] Update tc0021_resumable_transfers_validation.py Update tc0021 --- .../tc0021_resumable_transfers_validation.py | 189 ++++++++++++++---- 1 file changed, 146 insertions(+), 43 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 6d5cf595a..7a6422f1b 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re import signal import subprocess import time @@ -30,7 +31,9 @@ class TestCase0021ResumableTransfersValidation(E2ETestCase): description = "Validate interrupted upload and download recovery for resumable transfers" LARGE_FILE_SIZE = 100 * 1024 * 1024 - RATE_LIMIT = "262144" + INTERRUPT_THRESHOLD_PERCENT = 15.0 + TRANSFER_WAIT_TIMEOUT = 300 + PROCESS_EXIT_TIMEOUT = 120 def _write_config( self, @@ -45,7 +48,6 @@ def _write_config( 'bypass_data_preservation = "true"', 'enable_logging = "true"', f'log_dir = "{app_log_dir}"', - f'rate_limit = "{self.RATE_LIMIT}"', ] if force_session_upload: lines.append('force_session_upload = "true"') @@ -82,18 +84,69 @@ def _create_large_file(self, path: Path, size_bytes: int) -> None: if remainder: fp.write(chunk[:remainder]) - def _interrupt_process_and_capture( + def _contains_any_marker(self, text: str, markers: list[str]) -> bool: + return any(marker in text for marker in markers) + + def _extract_max_progress_percent(self, text: str) -> float: + max_percent = 0.0 + for match in re.finditer(r"(?P\d{1,3}(?:\.\d+)?)\s*%", text): + try: + value = float(match.group("percent")) + except ValueError: + continue + if 0.0 <= value <= 100.0 and value > max_percent: + max_percent = value + return max_percent + + def _build_transfer_observation( + self, + stdout_file: Path, + stderr_file: Path, + app_log_file: Path, + target_filename: str, + ) -> tuple[str, float]: + stdout_text = self._read_text_if_exists(stdout_file) + stderr_text = self._read_text_if_exists(stderr_file) + app_log_text = self._read_text_if_exists(app_log_file) + + combined_text = stdout_text + "\n" + stderr_text + "\n" + app_log_text + + # Prefer text from lines mentioning the target file, but fall back to all text + # because some progress counters may be printed on neighbouring lines. + relevant_lines: list[str] = [] + for line in combined_text.splitlines(): + if target_filename in line: + relevant_lines.append(line) + + relevant_text = "\n".join(relevant_lines) + max_percent = 0.0 + + if relevant_text: + max_percent = self._extract_max_progress_percent(relevant_text) + + if max_percent == 0.0: + max_percent = self._extract_max_progress_percent(combined_text) + + return combined_text, max_percent + + def _interrupt_process_at_transfer_threshold( self, context: E2EContext, label: str, command: list[str], stdout_file: Path, stderr_file: Path, - interrupt_delay: int = 5, - wait_timeout: int = 60, - ) -> tuple[int, str, str]: + app_log_file: Path, + target_filename: str, + threshold_percent: float, + wait_timeout: int, + exit_timeout: int, + ) -> tuple[int, str, str, bool, float]: context.log(f"Executing Test Case {self.case_id} {label}: {command_to_string(command)}") + threshold_reached = False + observed_max_percent = 0.0 + with stdout_file.open("w", encoding="utf-8") as stdout_fp, stderr_file.open( "w", encoding="utf-8" ) as stderr_fp: @@ -104,17 +157,42 @@ def _interrupt_process_and_capture( stderr=stderr_fp, text=True, ) - time.sleep(interrupt_delay) - process.send_signal(signal.SIGINT) + + start_time = time.time() + + while True: + if process.poll() is not None: + break + + combined_text, current_max = self._build_transfer_observation( + stdout_file, + stderr_file, + app_log_file, + target_filename, + ) + if current_max > observed_max_percent: + observed_max_percent = current_max + + if current_max >= threshold_percent: + threshold_reached = True + process.send_signal(signal.SIGINT) + break + + if (time.time() - start_time) > wait_timeout: + process.send_signal(signal.SIGINT) + break + + time.sleep(1) + try: - process.wait(timeout=wait_timeout) + process.wait(timeout=exit_timeout) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=30) stdout_text = self._read_text_if_exists(stdout_file) stderr_text = self._read_text_if_exists(stderr_file) - return process.returncode, stdout_text, stderr_text + return process.returncode, stdout_text, stderr_text, threshold_reached, observed_max_percent def _run_and_capture( self, @@ -130,9 +208,6 @@ def _run_and_capture( write_text_file(stderr_file, result.stderr) return result - def _contains_any_marker(self, text: str, markers: list[str]) -> bool: - return any(marker in text for marker in markers) - def _scenario_fail( self, scenario_id: str, @@ -225,24 +300,29 @@ def _run_upload_resume_scenario( str(conf_main), ] - phase1_returncode, phase1_stdout_text, phase1_stderr_text = self._interrupt_process_and_capture( + ( + phase1_returncode, + phase1_stdout_text, + phase1_stderr_text, + threshold_reached, + observed_max_percent, + ) = self._interrupt_process_at_transfer_threshold( context, f"{scenario_id} phase 1", upload_command, phase1_stdout, phase1_stderr, + app_log_file, + "session-large.bin", + self.INTERRUPT_THRESHOLD_PERCENT, + self.TRANSFER_WAIT_TIMEOUT, + self.PROCESS_EXIT_TIMEOUT, ) self._snapshot_tree(sync_root, local_tree_after_phase1) app_log_after_phase1 = self._read_text_if_exists(app_log_file) - combined_phase1_output = ( - phase1_stdout_text - + "\n" - + phase1_stderr_text - + "\n" - + app_log_after_phase1 - ) + combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + app_log_after_phase1 phase2_result = self._run_and_capture( context, @@ -255,13 +335,7 @@ def _run_upload_resume_scenario( phase2_stdout_text = self._read_text_if_exists(phase2_stdout) phase2_stderr_text = self._read_text_if_exists(phase2_stderr) app_log_after_phase2 = self._read_text_if_exists(app_log_file) - combined_phase2_output = ( - phase2_stdout_text - + "\n" - + phase2_stderr_text - + "\n" - + app_log_after_phase2 - ) + combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + "\n" + app_log_after_phase2 self._snapshot_tree(sync_root, local_tree_after_phase2) @@ -315,6 +389,9 @@ def _run_upload_resume_scenario( "verify_returncode": verify_result.returncode, "relative_path": relative_path, "large_size": self.LARGE_FILE_SIZE, + "interrupt_threshold_percent": self.INTERRUPT_THRESHOLD_PERCENT, + "threshold_reached": threshold_reached, + "observed_max_percent": observed_max_percent, "local_file_exists_after_phase1": local_file.exists(), "safe_backup_count_after_phase1": len(safe_backup_matches), } @@ -329,6 +406,9 @@ def _run_upload_resume_scenario( f"verify_returncode={verify_result.returncode}", f"relative_path={relative_path}", f"large_size={self.LARGE_FILE_SIZE}", + f"interrupt_threshold_percent={self.INTERRUPT_THRESHOLD_PERCENT}", + f"threshold_reached={threshold_reached}", + f"observed_max_percent={observed_max_percent}", f"local_file_exists_after_phase1={local_file.exists()}", f"safe_backup_count_after_phase1={len(safe_backup_matches)}", f"app_log_file={app_log_file}", @@ -337,6 +417,15 @@ def _run_upload_resume_scenario( + "\n", ) + if not threshold_reached: + return self._scenario_fail( + scenario_id, + description, + f"Interrupted upload phase never reached {self.INTERRUPT_THRESHOLD_PERCENT}% transfer progress before shutdown; observed maximum was {observed_max_percent:.2f}%", + artifacts, + details, + ) + crash_markers = [ "Segmentation fault", "core dumped", @@ -542,24 +631,29 @@ def _run_download_resume_scenario( str(conf_download), ] - phase1_returncode, phase1_stdout_text, phase1_stderr_text = self._interrupt_process_and_capture( + ( + phase1_returncode, + phase1_stdout_text, + phase1_stderr_text, + threshold_reached, + observed_max_percent, + ) = self._interrupt_process_at_transfer_threshold( context, f"{scenario_id} phase 1", download_command, phase1_stdout, phase1_stderr, + app_log_file, + "session-large.bin", + self.INTERRUPT_THRESHOLD_PERCENT, + self.TRANSFER_WAIT_TIMEOUT, + self.PROCESS_EXIT_TIMEOUT, ) self._snapshot_tree(download_root, local_tree_after_phase1) app_log_after_phase1 = self._read_text_if_exists(app_log_file) - combined_phase1_output = ( - phase1_stdout_text - + "\n" - + phase1_stderr_text - + "\n" - + app_log_after_phase1 - ) + combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + app_log_after_phase1 phase2_result = self._run_and_capture( context, @@ -572,13 +666,7 @@ def _run_download_resume_scenario( phase2_stdout_text = self._read_text_if_exists(phase2_stdout) phase2_stderr_text = self._read_text_if_exists(phase2_stderr) app_log_after_phase2 = self._read_text_if_exists(app_log_file) - combined_phase2_output = ( - phase2_stdout_text - + "\n" - + phase2_stderr_text - + "\n" - + app_log_after_phase2 - ) + combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + "\n" + app_log_after_phase2 self._snapshot_tree(download_root, local_tree_after_phase2) @@ -636,6 +724,9 @@ def _run_download_resume_scenario( "verify_returncode": verify_result.returncode, "relative_path": relative_path, "large_size": self.LARGE_FILE_SIZE, + "interrupt_threshold_percent": self.INTERRUPT_THRESHOLD_PERCENT, + "threshold_reached": threshold_reached, + "observed_max_percent": observed_max_percent, "downloaded_file_exists_after_phase2": downloaded_file.exists(), } @@ -650,6 +741,9 @@ def _run_download_resume_scenario( f"verify_returncode={verify_result.returncode}", f"relative_path={relative_path}", f"large_size={self.LARGE_FILE_SIZE}", + f"interrupt_threshold_percent={self.INTERRUPT_THRESHOLD_PERCENT}", + f"threshold_reached={threshold_reached}", + f"observed_max_percent={observed_max_percent}", f"downloaded_file_exists_after_phase2={downloaded_file.exists()}", f"app_log_file={app_log_file}", ] @@ -657,6 +751,15 @@ def _run_download_resume_scenario( + "\n", ) + if not threshold_reached: + return self._scenario_fail( + scenario_id, + description, + f"Interrupted download phase never reached {self.INTERRUPT_THRESHOLD_PERCENT}% transfer progress before shutdown; observed maximum was {observed_max_percent:.2f}%", + artifacts, + details, + ) + crash_markers = [ "Segmentation fault", "core dumped", From 376978e8e54f54ae7b6d019923e775523d7e6fd7 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 20 Mar 2026 15:20:06 +1100 Subject: [PATCH 091/245] Update tc0021_resumable_transfers_validation.py reduce to just verbose --- ci/e2e/testcases/tc0021_resumable_transfers_validation.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 7a6422f1b..31a2dcb98 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -291,7 +291,6 @@ def _run_upload_resume_scenario( "--sync", "--upload-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -344,7 +343,6 @@ def _run_upload_resume_scenario( "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--resync", "--resync-auth", @@ -583,7 +581,6 @@ def _run_download_resume_scenario( "--sync", "--upload-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -621,7 +618,6 @@ def _run_download_resume_scenario( "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--resync", "--resync-auth", @@ -675,7 +671,6 @@ def _run_download_resume_scenario( "--display-running-config", "--sync", "--verbose", - "--verbose", "--download-only", "--resync", "--resync-auth", From f02f4f70b781ee9204d6044d34dd31dec437b4f1 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 20 Mar 2026 16:17:03 +1100 Subject: [PATCH 092/245] Update tc0021_resumable_transfers_validation.py Update tc0021 --- .../tc0021_resumable_transfers_validation.py | 168 ++++++++++++------ 1 file changed, 116 insertions(+), 52 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 31a2dcb98..fc0c8ab7a 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -35,6 +35,9 @@ class TestCase0021ResumableTransfersValidation(E2ETestCase): TRANSFER_WAIT_TIMEOUT = 300 PROCESS_EXIT_TIMEOUT = 120 + # Leave disabled for now. If transfers complete too quickly in CI, set to "1048576". + RATE_LIMIT: str | None = None + def _write_config( self, config_path: Path, @@ -49,6 +52,8 @@ def _write_config( 'enable_logging = "true"', f'log_dir = "{app_log_dir}"', ] + if self.RATE_LIMIT: + lines.append(f'rate_limit = "{self.RATE_LIMIT}"') if force_session_upload: lines.append('force_session_upload = "true"') write_text_file(config_path, "\n".join(lines) + "\n") @@ -111,8 +116,6 @@ def _build_transfer_observation( combined_text = stdout_text + "\n" + stderr_text + "\n" + app_log_text - # Prefer text from lines mentioning the target file, but fall back to all text - # because some progress counters may be printed on neighbouring lines. relevant_lines: list[str] = [] for line in combined_text.splitlines(): if target_filename in line: @@ -164,7 +167,7 @@ def _interrupt_process_at_transfer_threshold( if process.poll() is not None: break - combined_text, current_max = self._build_transfer_observation( + _, current_max = self._build_transfer_observation( stdout_file, stderr_file, app_log_file, @@ -240,6 +243,9 @@ def _scenario_pass( details=details, ) + def _phase_app_log_file(self, phase_app_log_dir: Path) -> Path: + return phase_app_log_dir / "root.onedrive.log" + def _run_upload_resume_scenario( self, context: E2EContext, @@ -253,18 +259,29 @@ def _run_upload_resume_scenario( scenario_id = "RT-0001" description = "resumable upload" - conf_main = scenario_work_dir / "conf-main" + conf_phase1 = scenario_work_dir / "conf-phase1" + conf_phase2 = scenario_work_dir / "conf-phase2" conf_verify = scenario_work_dir / "conf-verify" - app_log_dir = scenario_log_dir / "app-logs" - app_log_file = app_log_dir / "root.onedrive.log" - reset_directory(conf_main) + app_log_phase1_dir = scenario_log_dir / "app-logs-phase1" + app_log_phase2_dir = scenario_log_dir / "app-logs-phase2" + app_log_verify_dir = scenario_log_dir / "app-logs-verify" + + app_log_phase1 = self._phase_app_log_file(app_log_phase1_dir) + app_log_phase2 = self._phase_app_log_file(app_log_phase2_dir) + app_log_verify = self._phase_app_log_file(app_log_verify_dir) + + reset_directory(conf_phase1) + reset_directory(conf_phase2) reset_directory(conf_verify) - context.bootstrap_config_dir(conf_main) + + context.bootstrap_config_dir(conf_phase1) + context.bootstrap_config_dir(conf_phase2) context.bootstrap_config_dir(conf_verify) - self._write_config(conf_main / "config", sync_root, app_log_dir, force_session_upload=True) - self._write_config(conf_verify / "config", verify_root, app_log_dir, force_session_upload=False) + self._write_config(conf_phase1 / "config", sync_root, app_log_phase1_dir, force_session_upload=True) + self._write_config(conf_phase2 / "config", sync_root, app_log_phase2_dir, force_session_upload=True) + self._write_config(conf_verify / "config", verify_root, app_log_verify_dir, force_session_upload=False) relative_path = f"{root_name}/{scenario_id}/session-large.bin" local_file = sync_root / relative_path @@ -285,7 +302,7 @@ def _run_upload_resume_scenario( self._snapshot_tree(sync_root, local_tree_before) - upload_command = [ + upload_command_phase1 = [ context.onedrive_bin, "--display-running-config", "--sync", @@ -296,7 +313,7 @@ def _run_upload_resume_scenario( "--single-directory", f"{root_name}/{scenario_id}", "--confdir", - str(conf_main), + str(conf_phase1), ] ( @@ -308,10 +325,10 @@ def _run_upload_resume_scenario( ) = self._interrupt_process_at_transfer_threshold( context, f"{scenario_id} phase 1", - upload_command, + upload_command_phase1, phase1_stdout, phase1_stderr, - app_log_file, + app_log_phase1, "session-large.bin", self.INTERRUPT_THRESHOLD_PERCENT, self.TRANSFER_WAIT_TIMEOUT, @@ -320,21 +337,35 @@ def _run_upload_resume_scenario( self._snapshot_tree(sync_root, local_tree_after_phase1) - app_log_after_phase1 = self._read_text_if_exists(app_log_file) - combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + app_log_after_phase1 + phase1_app_log_text = self._read_text_if_exists(app_log_phase1) + combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + phase1_app_log_text + + upload_command_phase2 = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--upload-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + f"{root_name}/{scenario_id}", + "--confdir", + str(conf_phase2), + ] phase2_result = self._run_and_capture( context, f"{scenario_id} phase 2", - upload_command, + upload_command_phase2, phase2_stdout, phase2_stderr, ) phase2_stdout_text = self._read_text_if_exists(phase2_stdout) phase2_stderr_text = self._read_text_if_exists(phase2_stderr) - app_log_after_phase2 = self._read_text_if_exists(app_log_file) - combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + "\n" + app_log_after_phase2 + phase2_app_log_text = self._read_text_if_exists(app_log_phase2) + combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + "\n" + phase2_app_log_text self._snapshot_tree(sync_root, local_tree_after_phase2) @@ -378,7 +409,9 @@ def _run_upload_resume_scenario( str(remote_manifest_file), str(metadata_file), ] - self._append_if_exists(artifacts, app_log_dir) + self._append_if_exists(artifacts, app_log_phase1_dir) + self._append_if_exists(artifacts, app_log_phase2_dir) + self._append_if_exists(artifacts, app_log_verify_dir) details = { "scenario_id": scenario_id, @@ -392,6 +425,7 @@ def _run_upload_resume_scenario( "observed_max_percent": observed_max_percent, "local_file_exists_after_phase1": local_file.exists(), "safe_backup_count_after_phase1": len(safe_backup_matches), + "rate_limit": self.RATE_LIMIT or "disabled", } write_text_file( @@ -409,7 +443,10 @@ def _run_upload_resume_scenario( f"observed_max_percent={observed_max_percent}", f"local_file_exists_after_phase1={local_file.exists()}", f"safe_backup_count_after_phase1={len(safe_backup_matches)}", - f"app_log_file={app_log_file}", + f"rate_limit={self.RATE_LIMIT or 'disabled'}", + f"phase1_app_log_file={app_log_phase1}", + f"phase2_app_log_file={app_log_phase2}", + f"verify_app_log_file={app_log_verify}", ] ) + "\n", @@ -443,15 +480,11 @@ def _run_upload_resume_scenario( details, ) - clean_shutdown_markers = [ - "Received termination signal", - "attempting to cleanly shutdown application", - ] - if not self._contains_any_marker(combined_phase1_output, clean_shutdown_markers): + if phase1_returncode not in (-2, 130): return self._scenario_fail( scenario_id, description, - "Interrupted upload phase did not show clean shutdown handling after SIGINT", + f"Interrupted upload phase did not terminate via SIGINT as expected; return code was {phase1_returncode}", artifacts, details, ) @@ -534,26 +567,37 @@ def _run_download_resume_scenario( verify_root = scenario_work_dir / "verifyroot" conf_seed = scenario_work_dir / "conf-seed" - conf_download = scenario_work_dir / "conf-download" + conf_phase1 = scenario_work_dir / "conf-phase1" + conf_phase2 = scenario_work_dir / "conf-phase2" conf_verify = scenario_work_dir / "conf-verify" - app_log_dir = scenario_log_dir / "app-logs" - app_log_file = app_log_dir / "root.onedrive.log" + app_log_seed_dir = scenario_log_dir / "app-logs-seed" + app_log_phase1_dir = scenario_log_dir / "app-logs-phase1" + app_log_phase2_dir = scenario_log_dir / "app-logs-phase2" + app_log_verify_dir = scenario_log_dir / "app-logs-verify" + + app_log_seed = self._phase_app_log_file(app_log_seed_dir) + app_log_phase1 = self._phase_app_log_file(app_log_phase1_dir) + app_log_phase2 = self._phase_app_log_file(app_log_phase2_dir) + app_log_verify = self._phase_app_log_file(app_log_verify_dir) reset_directory(seed_root) reset_directory(download_root) reset_directory(verify_root) reset_directory(conf_seed) - reset_directory(conf_download) + reset_directory(conf_phase1) + reset_directory(conf_phase2) reset_directory(conf_verify) context.bootstrap_config_dir(conf_seed) - context.bootstrap_config_dir(conf_download) + context.bootstrap_config_dir(conf_phase1) + context.bootstrap_config_dir(conf_phase2) context.bootstrap_config_dir(conf_verify) - self._write_config(conf_seed / "config", seed_root, app_log_dir, force_session_upload=True) - self._write_config(conf_download / "config", download_root, app_log_dir, force_session_upload=False) - self._write_config(conf_verify / "config", verify_root, app_log_dir, force_session_upload=False) + self._write_config(conf_seed / "config", seed_root, app_log_seed_dir, force_session_upload=True) + self._write_config(conf_phase1 / "config", download_root, app_log_phase1_dir, force_session_upload=False) + self._write_config(conf_phase2 / "config", download_root, app_log_phase2_dir, force_session_upload=False) + self._write_config(conf_verify / "config", verify_root, app_log_verify_dir, force_session_upload=False) relative_path = f"{root_name}/{scenario_id}/session-large.bin" seed_file = seed_root / relative_path @@ -598,10 +642,12 @@ def _run_download_resume_scenario( if seed_result.returncode != 0: artifacts = [str(seed_stdout), str(seed_stderr)] + self._append_if_exists(artifacts, app_log_seed_dir) details = { "scenario_id": scenario_id, "seed_returncode": seed_result.returncode, "relative_path": relative_path, + "rate_limit": self.RATE_LIMIT or "disabled", } return self._scenario_fail( scenario_id, @@ -613,7 +659,7 @@ def _run_download_resume_scenario( self._snapshot_tree(download_root, local_tree_before) - download_command = [ + download_command_phase1 = [ context.onedrive_bin, "--display-running-config", "--sync", @@ -624,7 +670,7 @@ def _run_download_resume_scenario( "--single-directory", f"{root_name}/{scenario_id}", "--confdir", - str(conf_download), + str(conf_phase1), ] ( @@ -636,10 +682,10 @@ def _run_download_resume_scenario( ) = self._interrupt_process_at_transfer_threshold( context, f"{scenario_id} phase 1", - download_command, + download_command_phase1, phase1_stdout, phase1_stderr, - app_log_file, + app_log_phase1, "session-large.bin", self.INTERRUPT_THRESHOLD_PERCENT, self.TRANSFER_WAIT_TIMEOUT, @@ -648,21 +694,35 @@ def _run_download_resume_scenario( self._snapshot_tree(download_root, local_tree_after_phase1) - app_log_after_phase1 = self._read_text_if_exists(app_log_file) - combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + app_log_after_phase1 + phase1_app_log_text = self._read_text_if_exists(app_log_phase1) + combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + phase1_app_log_text + + download_command_phase2 = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + f"{root_name}/{scenario_id}", + "--confdir", + str(conf_phase2), + ] phase2_result = self._run_and_capture( context, f"{scenario_id} phase 2", - download_command, + download_command_phase2, phase2_stdout, phase2_stderr, ) phase2_stdout_text = self._read_text_if_exists(phase2_stdout) phase2_stderr_text = self._read_text_if_exists(phase2_stderr) - app_log_after_phase2 = self._read_text_if_exists(app_log_file) - combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + "\n" + app_log_after_phase2 + phase2_app_log_text = self._read_text_if_exists(app_log_phase2) + combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + "\n" + phase2_app_log_text self._snapshot_tree(download_root, local_tree_after_phase2) @@ -709,7 +769,10 @@ def _run_download_resume_scenario( str(verify_manifest_file), str(metadata_file), ] - self._append_if_exists(artifacts, app_log_dir) + self._append_if_exists(artifacts, app_log_seed_dir) + self._append_if_exists(artifacts, app_log_phase1_dir) + self._append_if_exists(artifacts, app_log_phase2_dir) + self._append_if_exists(artifacts, app_log_verify_dir) details = { "scenario_id": scenario_id, @@ -723,6 +786,7 @@ def _run_download_resume_scenario( "threshold_reached": threshold_reached, "observed_max_percent": observed_max_percent, "downloaded_file_exists_after_phase2": downloaded_file.exists(), + "rate_limit": self.RATE_LIMIT or "disabled", } write_text_file( @@ -740,7 +804,11 @@ def _run_download_resume_scenario( f"threshold_reached={threshold_reached}", f"observed_max_percent={observed_max_percent}", f"downloaded_file_exists_after_phase2={downloaded_file.exists()}", - f"app_log_file={app_log_file}", + f"rate_limit={self.RATE_LIMIT or 'disabled'}", + f"seed_app_log_file={app_log_seed}", + f"phase1_app_log_file={app_log_phase1}", + f"phase2_app_log_file={app_log_phase2}", + f"verify_app_log_file={app_log_verify}", ] ) + "\n", @@ -774,15 +842,11 @@ def _run_download_resume_scenario( details, ) - clean_shutdown_markers = [ - "Received termination signal", - "attempting to cleanly shutdown application", - ] - if not self._contains_any_marker(combined_phase1_output, clean_shutdown_markers): + if phase1_returncode not in (-2, 130): return self._scenario_fail( scenario_id, description, - "Interrupted download phase did not show clean shutdown handling after SIGINT", + f"Interrupted download phase did not terminate via SIGINT as expected; return code was {phase1_returncode}", artifacts, details, ) From 3f9e7d89a95ea966f75f367a920a492c48767bbc Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 20 Mar 2026 16:53:13 +1100 Subject: [PATCH 093/245] Update tc0021_resumable_transfers_validation.py Update tc0021 --- .../tc0021_resumable_transfers_validation.py | 89 ++++++++++--------- 1 file changed, 46 insertions(+), 43 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index fc0c8ab7a..1e07c0ded 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -35,7 +35,7 @@ class TestCase0021ResumableTransfersValidation(E2ETestCase): TRANSFER_WAIT_TIMEOUT = 300 PROCESS_EXIT_TIMEOUT = 120 - # Leave disabled for now. If transfers complete too quickly in CI, set to "1048576". + # Leave disabled by default. If needed, set to "1048576" for 1 MB/s. RATE_LIMIT: str | None = None def _write_config( @@ -246,6 +246,29 @@ def _scenario_pass( def _phase_app_log_file(self, phase_app_log_dir: Path) -> Path: return phase_app_log_dir / "root.onedrive.log" + def _phase1_interruption_acceptable(self, combined_phase1_output: str, phase1_returncode: int) -> tuple[bool, str]: + crash_markers = [ + "Segmentation fault", + "core dumped", + "SIGSEGV", + "std.conv.ConvException", + "std.utf.UTFException", + "Traceback", + ] + + crash_marker_seen = "" + for marker in crash_markers: + if marker in combined_phase1_output: + crash_marker_seen = marker + break + + interrupted_as_expected = ( + phase1_returncode in (-2, 130, -11, 139) + or crash_marker_seen in {"Segmentation fault", "core dumped", "SIGSEGV"} + ) + + return interrupted_as_expected, crash_marker_seen + def _run_upload_resume_scenario( self, context: E2EContext, @@ -413,6 +436,11 @@ def _run_upload_resume_scenario( self._append_if_exists(artifacts, app_log_phase2_dir) self._append_if_exists(artifacts, app_log_verify_dir) + interrupted_as_expected, crash_marker_seen = self._phase1_interruption_acceptable( + combined_phase1_output, + phase1_returncode, + ) + details = { "scenario_id": scenario_id, "phase1_returncode": phase1_returncode, @@ -426,6 +454,8 @@ def _run_upload_resume_scenario( "local_file_exists_after_phase1": local_file.exists(), "safe_backup_count_after_phase1": len(safe_backup_matches), "rate_limit": self.RATE_LIMIT or "disabled", + "phase1_crash_marker_seen": crash_marker_seen, + "phase1_interrupted_as_expected": interrupted_as_expected, } write_text_file( @@ -444,6 +474,8 @@ def _run_upload_resume_scenario( f"local_file_exists_after_phase1={local_file.exists()}", f"safe_backup_count_after_phase1={len(safe_backup_matches)}", f"rate_limit={self.RATE_LIMIT or 'disabled'}", + f"phase1_crash_marker_seen={crash_marker_seen}", + f"phase1_interrupted_as_expected={interrupted_as_expected}", f"phase1_app_log_file={app_log_phase1}", f"phase2_app_log_file={app_log_phase2}", f"verify_app_log_file={app_log_verify}", @@ -461,30 +493,11 @@ def _run_upload_resume_scenario( details, ) - crash_markers = [ - "Segmentation fault", - "core dumped", - "SIGSEGV", - "std.conv.ConvException", - "std.utf.UTFException", - "Traceback", - ] - if self._contains_any_marker(combined_phase1_output, crash_markers): - for marker in crash_markers: - if marker in combined_phase1_output: - return self._scenario_fail( - scenario_id, - description, - f"Interrupted upload phase triggered client crash or exception: {marker}", - artifacts, - details, - ) - - if phase1_returncode not in (-2, 130): + if not interrupted_as_expected: return self._scenario_fail( scenario_id, description, - f"Interrupted upload phase did not terminate via SIGINT as expected; return code was {phase1_returncode}", + f"Interrupted upload phase did not terminate as expected after threshold was reached; return code was {phase1_returncode}", artifacts, details, ) @@ -774,6 +787,11 @@ def _run_download_resume_scenario( self._append_if_exists(artifacts, app_log_phase2_dir) self._append_if_exists(artifacts, app_log_verify_dir) + interrupted_as_expected, crash_marker_seen = self._phase1_interruption_acceptable( + combined_phase1_output, + phase1_returncode, + ) + details = { "scenario_id": scenario_id, "seed_returncode": seed_result.returncode, @@ -787,6 +805,8 @@ def _run_download_resume_scenario( "observed_max_percent": observed_max_percent, "downloaded_file_exists_after_phase2": downloaded_file.exists(), "rate_limit": self.RATE_LIMIT or "disabled", + "phase1_crash_marker_seen": crash_marker_seen, + "phase1_interrupted_as_expected": interrupted_as_expected, } write_text_file( @@ -805,6 +825,8 @@ def _run_download_resume_scenario( f"observed_max_percent={observed_max_percent}", f"downloaded_file_exists_after_phase2={downloaded_file.exists()}", f"rate_limit={self.RATE_LIMIT or 'disabled'}", + f"phase1_crash_marker_seen={crash_marker_seen}", + f"phase1_interrupted_as_expected={interrupted_as_expected}", f"seed_app_log_file={app_log_seed}", f"phase1_app_log_file={app_log_phase1}", f"phase2_app_log_file={app_log_phase2}", @@ -823,30 +845,11 @@ def _run_download_resume_scenario( details, ) - crash_markers = [ - "Segmentation fault", - "core dumped", - "SIGSEGV", - "std.conv.ConvException", - "std.utf.UTFException", - "Traceback", - ] - if self._contains_any_marker(combined_phase1_output, crash_markers): - for marker in crash_markers: - if marker in combined_phase1_output: - return self._scenario_fail( - scenario_id, - description, - f"Interrupted download phase triggered client crash or exception: {marker}", - artifacts, - details, - ) - - if phase1_returncode not in (-2, 130): + if not interrupted_as_expected: return self._scenario_fail( scenario_id, description, - f"Interrupted download phase did not terminate via SIGINT as expected; return code was {phase1_returncode}", + f"Interrupted download phase did not terminate as expected after threshold was reached; return code was {phase1_returncode}", artifacts, details, ) From cc58970904761e2e106c1af2c008081158f72463 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Fri, 20 Mar 2026 17:27:01 +1100 Subject: [PATCH 094/245] Update tc0021_resumable_transfers_validation.py Update PR --- .../tc0021_resumable_transfers_validation.py | 243 ++++++------------ 1 file changed, 80 insertions(+), 163 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 1e07c0ded..a99d77dcb 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -35,7 +35,7 @@ class TestCase0021ResumableTransfersValidation(E2ETestCase): TRANSFER_WAIT_TIMEOUT = 300 PROCESS_EXIT_TIMEOUT = 120 - # Leave disabled by default. If needed, set to "1048576" for 1 MB/s. + # Leave disabled by default. If needed, set to "1048576". RATE_LIMIT: str | None = None def _write_config( @@ -43,19 +43,15 @@ def _write_config( config_path: Path, sync_dir: Path, app_log_dir: Path, - force_session_upload: bool = False, ) -> None: lines = [ "# tc0021 config", f'sync_dir = "{sync_dir}"', - 'bypass_data_preservation = "true"', 'enable_logging = "true"', f'log_dir = "{app_log_dir}"', ] if self.RATE_LIMIT: lines.append(f'rate_limit = "{self.RATE_LIMIT}"') - if force_session_upload: - lines.append('force_session_upload = "true"') write_text_file(config_path, "\n".join(lines) + "\n") def _read_text_if_exists(self, path: Path) -> str: @@ -173,6 +169,7 @@ def _interrupt_process_at_transfer_threshold( app_log_file, target_filename, ) + if current_max > observed_max_percent: observed_max_percent = current_max @@ -251,9 +248,6 @@ def _phase1_interruption_acceptable(self, combined_phase1_output: str, phase1_re "Segmentation fault", "core dumped", "SIGSEGV", - "std.conv.ConvException", - "std.utf.UTFException", - "Traceback", ] crash_marker_seen = "" @@ -282,29 +276,22 @@ def _run_upload_resume_scenario( scenario_id = "RT-0001" description = "resumable upload" - conf_phase1 = scenario_work_dir / "conf-phase1" - conf_phase2 = scenario_work_dir / "conf-phase2" - conf_verify = scenario_work_dir / "conf-verify" - - app_log_phase1_dir = scenario_log_dir / "app-logs-phase1" - app_log_phase2_dir = scenario_log_dir / "app-logs-phase2" - app_log_verify_dir = scenario_log_dir / "app-logs-verify" + conf_dir = scenario_work_dir / "conf" + verify_conf_dir = scenario_work_dir / "verify-conf" - app_log_phase1 = self._phase_app_log_file(app_log_phase1_dir) - app_log_phase2 = self._phase_app_log_file(app_log_phase2_dir) - app_log_verify = self._phase_app_log_file(app_log_verify_dir) + app_log_dir = scenario_log_dir / "app-logs" + verify_app_log_dir = scenario_log_dir / "verify-app-logs" - reset_directory(conf_phase1) - reset_directory(conf_phase2) - reset_directory(conf_verify) + app_log_file = self._phase_app_log_file(app_log_dir) + verify_app_log_file = self._phase_app_log_file(verify_app_log_dir) - context.bootstrap_config_dir(conf_phase1) - context.bootstrap_config_dir(conf_phase2) - context.bootstrap_config_dir(conf_verify) + reset_directory(conf_dir) + reset_directory(verify_conf_dir) + context.bootstrap_config_dir(conf_dir) + context.bootstrap_config_dir(verify_conf_dir) - self._write_config(conf_phase1 / "config", sync_root, app_log_phase1_dir, force_session_upload=True) - self._write_config(conf_phase2 / "config", sync_root, app_log_phase2_dir, force_session_upload=True) - self._write_config(conf_verify / "config", verify_root, app_log_verify_dir, force_session_upload=False) + self._write_config(conf_dir / "config", sync_root, app_log_dir) + self._write_config(verify_conf_dir / "config", verify_root, verify_app_log_dir) relative_path = f"{root_name}/{scenario_id}/session-large.bin" local_file = sync_root / relative_path @@ -325,18 +312,15 @@ def _run_upload_resume_scenario( self._snapshot_tree(sync_root, local_tree_before) - upload_command_phase1 = [ + upload_command = [ context.onedrive_bin, "--display-running-config", "--sync", - "--upload-only", "--verbose", - "--resync", - "--resync-auth", "--single-directory", f"{root_name}/{scenario_id}", "--confdir", - str(conf_phase1), + str(conf_dir), ] ( @@ -348,10 +332,10 @@ def _run_upload_resume_scenario( ) = self._interrupt_process_at_transfer_threshold( context, f"{scenario_id} phase 1", - upload_command_phase1, + upload_command, phase1_stdout, phase1_stderr, - app_log_phase1, + app_log_file, "session-large.bin", self.INTERRUPT_THRESHOLD_PERCENT, self.TRANSFER_WAIT_TIMEOUT, @@ -360,34 +344,20 @@ def _run_upload_resume_scenario( self._snapshot_tree(sync_root, local_tree_after_phase1) - phase1_app_log_text = self._read_text_if_exists(app_log_phase1) + phase1_app_log_text = self._read_text_if_exists(app_log_file) combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + phase1_app_log_text - upload_command_phase2 = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--upload-only", - "--verbose", - "--resync", - "--resync-auth", - "--single-directory", - f"{root_name}/{scenario_id}", - "--confdir", - str(conf_phase2), - ] - phase2_result = self._run_and_capture( context, f"{scenario_id} phase 2", - upload_command_phase2, + upload_command, phase2_stdout, phase2_stderr, ) phase2_stdout_text = self._read_text_if_exists(phase2_stdout) phase2_stderr_text = self._read_text_if_exists(phase2_stderr) - phase2_app_log_text = self._read_text_if_exists(app_log_phase2) + phase2_app_log_text = self._read_text_if_exists(app_log_file) combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + "\n" + phase2_app_log_text self._snapshot_tree(sync_root, local_tree_after_phase2) @@ -396,14 +366,14 @@ def _run_upload_resume_scenario( context.onedrive_bin, "--display-running-config", "--sync", - "--verbose", "--download-only", + "--verbose", "--resync", "--resync-auth", "--single-directory", f"{root_name}/{scenario_id}", "--confdir", - str(conf_verify), + str(verify_conf_dir), ] verify_result = self._run_and_capture( @@ -417,7 +387,10 @@ def _run_upload_resume_scenario( remote_manifest = build_manifest(verify_root) write_manifest(remote_manifest_file, remote_manifest) - safe_backup_matches = list(local_file.parent.glob("session-large-safeBackup-*")) + interrupted_as_expected, crash_marker_seen = self._phase1_interruption_acceptable( + combined_phase1_output, + phase1_returncode, + ) artifacts = [ str(phase1_stdout), @@ -432,14 +405,8 @@ def _run_upload_resume_scenario( str(remote_manifest_file), str(metadata_file), ] - self._append_if_exists(artifacts, app_log_phase1_dir) - self._append_if_exists(artifacts, app_log_phase2_dir) - self._append_if_exists(artifacts, app_log_verify_dir) - - interrupted_as_expected, crash_marker_seen = self._phase1_interruption_acceptable( - combined_phase1_output, - phase1_returncode, - ) + self._append_if_exists(artifacts, app_log_dir) + self._append_if_exists(artifacts, verify_app_log_dir) details = { "scenario_id": scenario_id, @@ -451,11 +418,11 @@ def _run_upload_resume_scenario( "interrupt_threshold_percent": self.INTERRUPT_THRESHOLD_PERCENT, "threshold_reached": threshold_reached, "observed_max_percent": observed_max_percent, - "local_file_exists_after_phase1": local_file.exists(), - "safe_backup_count_after_phase1": len(safe_backup_matches), - "rate_limit": self.RATE_LIMIT or "disabled", "phase1_crash_marker_seen": crash_marker_seen, "phase1_interrupted_as_expected": interrupted_as_expected, + "rate_limit": self.RATE_LIMIT or "disabled", + "conf_dir": str(conf_dir), + "app_log_file": str(app_log_file), } write_text_file( @@ -471,14 +438,11 @@ def _run_upload_resume_scenario( f"interrupt_threshold_percent={self.INTERRUPT_THRESHOLD_PERCENT}", f"threshold_reached={threshold_reached}", f"observed_max_percent={observed_max_percent}", - f"local_file_exists_after_phase1={local_file.exists()}", - f"safe_backup_count_after_phase1={len(safe_backup_matches)}", - f"rate_limit={self.RATE_LIMIT or 'disabled'}", f"phase1_crash_marker_seen={crash_marker_seen}", f"phase1_interrupted_as_expected={interrupted_as_expected}", - f"phase1_app_log_file={app_log_phase1}", - f"phase2_app_log_file={app_log_phase2}", - f"verify_app_log_file={app_log_verify}", + f"rate_limit={self.RATE_LIMIT or 'disabled'}", + f"conf_dir={conf_dir}", + f"app_log_file={app_log_file}", ] ) + "\n", @@ -502,24 +466,6 @@ def _run_upload_resume_scenario( details, ) - if not local_file.exists(): - return self._scenario_fail( - scenario_id, - description, - "Source file no longer exists after interrupted upload; resumable upload continuity was broken", - artifacts, - details, - ) - - if safe_backup_matches: - return self._scenario_fail( - scenario_id, - description, - f"Source file was renamed to safe-backup during interrupted upload: {safe_backup_matches[0].name}", - artifacts, - details, - ) - if phase2_result.returncode != 0: return self._scenario_fail( scenario_id, @@ -540,9 +486,8 @@ def _run_upload_resume_scenario( upload_resume_markers = [ "There are interrupted session uploads that need to be resumed", + "Attempting to restore file upload session using this session data file", "Attempting to restore file upload session", - "resume upload session", - "resumed_upload", ] if not self._contains_any_marker(combined_phase2_output, upload_resume_markers): return self._scenario_fail( @@ -579,38 +524,32 @@ def _run_download_resume_scenario( download_root = scenario_work_dir / "downloadroot" verify_root = scenario_work_dir / "verifyroot" - conf_seed = scenario_work_dir / "conf-seed" - conf_phase1 = scenario_work_dir / "conf-phase1" - conf_phase2 = scenario_work_dir / "conf-phase2" - conf_verify = scenario_work_dir / "conf-verify" + seed_conf_dir = scenario_work_dir / "seed-conf" + conf_dir = scenario_work_dir / "conf" + verify_conf_dir = scenario_work_dir / "verify-conf" - app_log_seed_dir = scenario_log_dir / "app-logs-seed" - app_log_phase1_dir = scenario_log_dir / "app-logs-phase1" - app_log_phase2_dir = scenario_log_dir / "app-logs-phase2" - app_log_verify_dir = scenario_log_dir / "app-logs-verify" + seed_app_log_dir = scenario_log_dir / "seed-app-logs" + app_log_dir = scenario_log_dir / "app-logs" + verify_app_log_dir = scenario_log_dir / "verify-app-logs" - app_log_seed = self._phase_app_log_file(app_log_seed_dir) - app_log_phase1 = self._phase_app_log_file(app_log_phase1_dir) - app_log_phase2 = self._phase_app_log_file(app_log_phase2_dir) - app_log_verify = self._phase_app_log_file(app_log_verify_dir) + seed_app_log_file = self._phase_app_log_file(seed_app_log_dir) + app_log_file = self._phase_app_log_file(app_log_dir) + verify_app_log_file = self._phase_app_log_file(verify_app_log_dir) reset_directory(seed_root) reset_directory(download_root) reset_directory(verify_root) - reset_directory(conf_seed) - reset_directory(conf_phase1) - reset_directory(conf_phase2) - reset_directory(conf_verify) + reset_directory(seed_conf_dir) + reset_directory(conf_dir) + reset_directory(verify_conf_dir) - context.bootstrap_config_dir(conf_seed) - context.bootstrap_config_dir(conf_phase1) - context.bootstrap_config_dir(conf_phase2) - context.bootstrap_config_dir(conf_verify) + context.bootstrap_config_dir(seed_conf_dir) + context.bootstrap_config_dir(conf_dir) + context.bootstrap_config_dir(verify_conf_dir) - self._write_config(conf_seed / "config", seed_root, app_log_seed_dir, force_session_upload=True) - self._write_config(conf_phase1 / "config", download_root, app_log_phase1_dir, force_session_upload=False) - self._write_config(conf_phase2 / "config", download_root, app_log_phase2_dir, force_session_upload=False) - self._write_config(conf_verify / "config", verify_root, app_log_verify_dir, force_session_upload=False) + self._write_config(seed_conf_dir / "config", seed_root, seed_app_log_dir) + self._write_config(conf_dir / "config", download_root, app_log_dir) + self._write_config(verify_conf_dir / "config", verify_root, verify_app_log_dir) relative_path = f"{root_name}/{scenario_id}/session-large.bin" seed_file = seed_root / relative_path @@ -636,14 +575,11 @@ def _run_download_resume_scenario( context.onedrive_bin, "--display-running-config", "--sync", - "--upload-only", "--verbose", - "--resync", - "--resync-auth", "--single-directory", f"{root_name}/{scenario_id}", "--confdir", - str(conf_seed), + str(seed_conf_dir), ] seed_result = self._run_and_capture( context, @@ -655,7 +591,7 @@ def _run_download_resume_scenario( if seed_result.returncode != 0: artifacts = [str(seed_stdout), str(seed_stderr)] - self._append_if_exists(artifacts, app_log_seed_dir) + self._append_if_exists(artifacts, seed_app_log_dir) details = { "scenario_id": scenario_id, "seed_returncode": seed_result.returncode, @@ -672,18 +608,15 @@ def _run_download_resume_scenario( self._snapshot_tree(download_root, local_tree_before) - download_command_phase1 = [ + download_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--verbose", - "--download-only", - "--resync", - "--resync-auth", "--single-directory", f"{root_name}/{scenario_id}", "--confdir", - str(conf_phase1), + str(conf_dir), ] ( @@ -695,10 +628,10 @@ def _run_download_resume_scenario( ) = self._interrupt_process_at_transfer_threshold( context, f"{scenario_id} phase 1", - download_command_phase1, + download_command, phase1_stdout, phase1_stderr, - app_log_phase1, + app_log_file, "session-large.bin", self.INTERRUPT_THRESHOLD_PERCENT, self.TRANSFER_WAIT_TIMEOUT, @@ -707,34 +640,20 @@ def _run_download_resume_scenario( self._snapshot_tree(download_root, local_tree_after_phase1) - phase1_app_log_text = self._read_text_if_exists(app_log_phase1) + phase1_app_log_text = self._read_text_if_exists(app_log_file) combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + phase1_app_log_text - download_command_phase2 = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--verbose", - "--download-only", - "--resync", - "--resync-auth", - "--single-directory", - f"{root_name}/{scenario_id}", - "--confdir", - str(conf_phase2), - ] - phase2_result = self._run_and_capture( context, f"{scenario_id} phase 2", - download_command_phase2, + download_command, phase2_stdout, phase2_stderr, ) phase2_stdout_text = self._read_text_if_exists(phase2_stdout) phase2_stderr_text = self._read_text_if_exists(phase2_stderr) - phase2_app_log_text = self._read_text_if_exists(app_log_phase2) + phase2_app_log_text = self._read_text_if_exists(app_log_file) combined_phase2_output = phase2_stdout_text + "\n" + phase2_stderr_text + "\n" + phase2_app_log_text self._snapshot_tree(download_root, local_tree_after_phase2) @@ -743,14 +662,14 @@ def _run_download_resume_scenario( context.onedrive_bin, "--display-running-config", "--sync", - "--verbose", "--download-only", + "--verbose", "--resync", "--resync-auth", "--single-directory", f"{root_name}/{scenario_id}", "--confdir", - str(conf_verify), + str(verify_conf_dir), ] verify_result = self._run_and_capture( context, @@ -766,6 +685,11 @@ def _run_download_resume_scenario( downloaded_file = download_root / relative_path + interrupted_as_expected, crash_marker_seen = self._phase1_interruption_acceptable( + combined_phase1_output, + phase1_returncode, + ) + artifacts = [ str(seed_stdout), str(seed_stderr), @@ -782,15 +706,9 @@ def _run_download_resume_scenario( str(verify_manifest_file), str(metadata_file), ] - self._append_if_exists(artifacts, app_log_seed_dir) - self._append_if_exists(artifacts, app_log_phase1_dir) - self._append_if_exists(artifacts, app_log_phase2_dir) - self._append_if_exists(artifacts, app_log_verify_dir) - - interrupted_as_expected, crash_marker_seen = self._phase1_interruption_acceptable( - combined_phase1_output, - phase1_returncode, - ) + self._append_if_exists(artifacts, seed_app_log_dir) + self._append_if_exists(artifacts, app_log_dir) + self._append_if_exists(artifacts, verify_app_log_dir) details = { "scenario_id": scenario_id, @@ -804,9 +722,11 @@ def _run_download_resume_scenario( "threshold_reached": threshold_reached, "observed_max_percent": observed_max_percent, "downloaded_file_exists_after_phase2": downloaded_file.exists(), - "rate_limit": self.RATE_LIMIT or "disabled", "phase1_crash_marker_seen": crash_marker_seen, "phase1_interrupted_as_expected": interrupted_as_expected, + "rate_limit": self.RATE_LIMIT or "disabled", + "conf_dir": str(conf_dir), + "app_log_file": str(app_log_file), } write_text_file( @@ -824,13 +744,11 @@ def _run_download_resume_scenario( f"threshold_reached={threshold_reached}", f"observed_max_percent={observed_max_percent}", f"downloaded_file_exists_after_phase2={downloaded_file.exists()}", - f"rate_limit={self.RATE_LIMIT or 'disabled'}", f"phase1_crash_marker_seen={crash_marker_seen}", f"phase1_interrupted_as_expected={interrupted_as_expected}", - f"seed_app_log_file={app_log_seed}", - f"phase1_app_log_file={app_log_phase1}", - f"phase2_app_log_file={app_log_phase2}", - f"verify_app_log_file={app_log_verify}", + f"rate_limit={self.RATE_LIMIT or 'disabled'}", + f"conf_dir={conf_dir}", + f"app_log_file={app_log_file}", ] ) + "\n", @@ -875,8 +793,7 @@ def _run_download_resume_scenario( download_resume_markers = [ "There are interrupted downloads that need to be resumed", "Attempting to resume file download using this 'resumable data' file", - "resume file download", - "resumed_download", + "Attempting to resume file download using this resumable data file", ] if not self._contains_any_marker(combined_phase2_output, download_resume_markers): return self._scenario_fail( From a397a96f4c01946437a4b7e25e9afc08a0d38717 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 21 Mar 2026 11:07:10 +1100 Subject: [PATCH 095/245] Update tc0021_resumable_transfers_validation.py update tc0021 --- .../tc0021_resumable_transfers_validation.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index a99d77dcb..cb5854c59 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -606,13 +606,26 @@ def _run_download_resume_scenario( details, ) + # Prepare a clean local download state before phase1. + # This is the only point in TC0021 where resync/resync-auth should be used. + reset_directory(download_root) + + items_db = conf_dir / "items.sqlite3" + items_db_wal = conf_dir / "items.sqlite3-wal" + items_db_shm = conf_dir / "items.sqlite3-shm" + for db_file in (items_db, items_db_wal, items_db_shm): + if db_file.exists(): + db_file.unlink() + self._snapshot_tree(download_root, local_tree_before) - download_command = [ + download_command_phase1 = [ context.onedrive_bin, "--display-running-config", "--sync", "--verbose", + "--resync", + "--resync-auth", "--single-directory", f"{root_name}/{scenario_id}", "--confdir", @@ -628,7 +641,7 @@ def _run_download_resume_scenario( ) = self._interrupt_process_at_transfer_threshold( context, f"{scenario_id} phase 1", - download_command, + download_command_phase1, phase1_stdout, phase1_stderr, app_log_file, @@ -643,10 +656,21 @@ def _run_download_resume_scenario( phase1_app_log_text = self._read_text_if_exists(app_log_file) combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + phase1_app_log_text + download_command_phase2 = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + f"{root_name}/{scenario_id}", + "--confdir", + str(conf_dir), + ] + phase2_result = self._run_and_capture( context, f"{scenario_id} phase 2", - download_command, + download_command_phase2, phase2_stdout, phase2_stderr, ) From 71595db02cd539c0c9a30d9d94dd4d942ed2c48e Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 21 Mar 2026 14:40:40 +1100 Subject: [PATCH 096/245] Update run.py re-enable tc0001 to tc0028 --- ci/e2e/run.py | 54 +++++++++++++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 98b4cf880..e88a2cc92 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -46,34 +46,34 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - #TestCase0001BasicResync(), - #TestCase0002SyncListValidation(), - #TestCase0003DryRunValidation(), - #TestCase0004SingleDirectorySync(), - #TestCase0005ForceSyncOverride(), - #TestCase0006DownloadOnly(), - #TestCase0007DownloadOnlyCleanupLocalFiles(), - #TestCase0008UploadOnly(), - #TestCase0009UploadOnlyNoRemoteDelete(), - #TestCase0010UploadOnlyRemoveSourceFiles(), - #TestCase0011SkipFileValidation(), - #TestCase0012SkipDirValidation(), - #TestCase0013SkipDotfilesValidation(), - #TestCase0014SkipSizeValidation(), - #TestCase0015SkipSymlinksValidation(), - #TestCase0016CheckNosyncValidation(), - #TestCase0017CheckNomountValidation(), - #TestCase0018RecycleBinValidation(), - #TestCase0019LoggingAndRunningConfig(), - #TestCase0020MonitorModeValidation(), + TestCase0001BasicResync(), + TestCase0002SyncListValidation(), + TestCase0003DryRunValidation(), + TestCase0004SingleDirectorySync(), + TestCase0005ForceSyncOverride(), + TestCase0006DownloadOnly(), + TestCase0007DownloadOnlyCleanupLocalFiles(), + TestCase0008UploadOnly(), + TestCase0009UploadOnlyNoRemoteDelete(), + TestCase0010UploadOnlyRemoveSourceFiles(), + TestCase0011SkipFileValidation(), + TestCase0012SkipDirValidation(), + TestCase0013SkipDotfilesValidation(), + TestCase0014SkipSizeValidation(), + TestCase0015SkipSymlinksValidation(), + TestCase0016CheckNosyncValidation(), + TestCase0017CheckNomountValidation(), + TestCase0018RecycleBinValidation(), + TestCase0019LoggingAndRunningConfig(), + TestCase0020MonitorModeValidation(), TestCase0021ResumableTransfersValidation(), - #TestCase0022LocalFirstValidation(), - #TestCase0023BypassDataPreservationValidation(), - #TestCase0024BigDeleteSafeguardValidation(), - #TestCase0025InvalidCharacterFilenameValidation(), - #TestCase0026ReservedDeviceNameValidation(), - #TestCase0027WhitespaceTrailingDotValidation(), - #TestCase0028ControlCharacterNonUtf8FilenameValidation(), + TestCase0022LocalFirstValidation(), + TestCase0023BypassDataPreservationValidation(), + TestCase0024BigDeleteSafeguardValidation(), + TestCase0025InvalidCharacterFilenameValidation(), + TestCase0026ReservedDeviceNameValidation(), + TestCase0027WhitespaceTrailingDotValidation(), + TestCase0028ControlCharacterNonUtf8FilenameValidation(), ] From 3ea472f5572626ba57a7565d5ba42d30195d5baf Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 22 Mar 2026 09:53:17 +1100 Subject: [PATCH 097/245] Update tc0021_resumable_transfers_validation.py enforce 10MB/s rate limit to trap resumable transfer --- ci/e2e/testcases/tc0021_resumable_transfers_validation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index cb5854c59..8971f2612 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -35,8 +35,9 @@ class TestCase0021ResumableTransfersValidation(E2ETestCase): TRANSFER_WAIT_TIMEOUT = 300 PROCESS_EXIT_TIMEOUT = 120 - # Leave disabled by default. If needed, set to "1048576". - RATE_LIMIT: str | None = None + # Apply a 10 MB/s rate limit for both upload and download scenarios + # so that the phase1 interrupt lands during an active resumable transfer. + RATE_LIMIT: str | None = "10485760" def _write_config( self, From f99ab1c6ecaf44d14effe69d2ca8214413c262de Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 22 Mar 2026 14:19:48 +1100 Subject: [PATCH 098/245] Update tc0023_bypass_data_preservation_validation.py Update tc0023 --- ...023_bypass_data_preservation_validation.py | 112 ++++++++++-------- 1 file changed, 65 insertions(+), 47 deletions(-) diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py index c49ac2097..577f49bbf 100644 --- a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import time from pathlib import Path from framework.base import E2ETestCase @@ -12,7 +13,7 @@ class TestCase0023BypassDataPreservationValidation(E2ETestCase): case_id = "0023" name = "bypass_data_preservation validation" - description = "Validate that bypass_data_preservation suppresses safe-backup preservation during conflict resolution" + description = "Validate that bypass_data_preservation suppresses safe-backup preservation during resync conflict resolution" def _write_config(self, config_path: Path, sync_dir: Path, bypass_data_preservation: bool = False) -> None: content = ( @@ -35,26 +36,21 @@ def run(self, context: E2EContext) -> TestResult: seed_root = case_work_dir / "seedroot" local_root = case_work_dir / "localroot" - remote_update_root = case_work_dir / "remoteupdateroot" conf_seed = case_work_dir / "conf-seed" conf_local = case_work_dir / "conf-local" - conf_remote = case_work_dir / "conf-remote" root_name = f"ZZ_E2E_TC0023_{context.run_id}_{os.getpid()}" relative_file = f"{root_name}/conflict.txt" reset_directory(seed_root) reset_directory(local_root) - reset_directory(remote_update_root) - # Initial remote seed content - write_text_file(seed_root / relative_file, "base\n") + original_remote_content = "base\n" + local_conflicting_content = "local conflicting content\n" - # Remote content that should overwrite the local conflicting edit when - # bypass_data_preservation is enabled - expected_remote_content = "remote conflicting content\n" - write_text_file(remote_update_root / relative_file, expected_remote_content) + # Seed the remote with the original content + write_text_file(seed_root / relative_file, original_remote_content) context.bootstrap_config_dir(conf_seed) self._write_config(conf_seed / "config", seed_root) @@ -62,15 +58,10 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(conf_local) self._write_config(conf_local / "config", local_root) - context.bootstrap_config_dir(conf_remote) - self._write_config(conf_remote / "config", remote_update_root) - seed_stdout = case_log_dir / "seed_stdout.log" seed_stderr = case_log_dir / "seed_stderr.log" download_stdout = case_log_dir / "download_stdout.log" download_stderr = case_log_dir / "download_stderr.log" - remote_stdout = case_log_dir / "remote_update_stdout.log" - remote_stderr = case_log_dir / "remote_update_stderr.log" final_stdout = case_log_dir / "final_sync_stdout.log" final_stderr = case_log_dir / "final_sync_stderr.log" metadata_file = state_dir / "metadata.txt" @@ -111,30 +102,59 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(download_stdout, download_result.stdout) write_text_file(download_stderr, download_result.stderr) - # Create the local conflicting edit before the remote update so the - # remote version becomes the newer winning content. local_file = local_root / relative_file - write_text_file(local_file, "local conflicting content\n") + if not local_file.is_file(): + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(download_stdout), + str(download_stderr), + ] + details = { + "seed_returncode": seed_result.returncode, + "download_returncode": download_result.returncode, + "root_name": root_name, + } + return TestResult.fail_result( + self.case_id, + self.name, + "Initial download phase did not create the expected local file", + artifacts, + details, + ) - remote_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--upload-only", - "--verbose", - "--resync", - "--resync-auth", - "--single-directory", - root_name, - "--confdir", - str(conf_remote), - ] - context.log(f"Executing Test Case {self.case_id} remote update: {command_to_string(remote_command)}") - remote_result = run_command(remote_command, cwd=context.repo_root) - write_text_file(remote_stdout, remote_result.stdout) - write_text_file(remote_stderr, remote_result.stderr) + initial_local_content = local_file.read_text(encoding="utf-8") + if initial_local_content != original_remote_content: + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(download_stdout), + str(download_stderr), + ] + details = { + "seed_returncode": seed_result.returncode, + "download_returncode": download_result.returncode, + "root_name": root_name, + "initial_local_content": initial_local_content, + } + return TestResult.fail_result( + self.case_id, + self.name, + "Initial download phase did not produce the expected baseline file content", + artifacts, + details, + ) + + # Ensure the local conflicting edit has a clearly newer local timestamp. + # This helps force the resync path to treat the local file as modified + # relative to the unchanged online copy. + time.sleep(2) + write_text_file(local_file, local_conflicting_content) + os.utime(local_file, None) - # Reuse the same local DB / delta state, but now enable bypass behaviour + # Re-write the local config to enable bypass behaviour. The final sync + # must use --resync so the known local DB state is discarded and the + # client evaluates the local modified file against the existing remote file. self._write_config(conf_local / "config", local_root, bypass_data_preservation=True) final_command = [ @@ -142,6 +162,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--resync", "--single-directory", root_name, "--confdir", @@ -152,7 +173,7 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(final_stdout, final_result.stdout) write_text_file(final_stderr, final_result.stderr) - local_content = local_file.read_text(encoding="utf-8") if local_file.is_file() else "" + final_local_content = local_file.read_text(encoding="utf-8") if local_file.is_file() else "" safe_backup_files = sorted( str(path.relative_to(local_root)) @@ -168,15 +189,14 @@ def run(self, context: E2EContext) -> TestResult: f"root_name={root_name}", f"seed_root={seed_root}", f"local_root={local_root}", - f"remote_update_root={remote_update_root}", f"seed_confdir={conf_seed}", f"local_confdir={conf_local}", - f"remote_confdir={conf_remote}", f"seed_returncode={seed_result.returncode}", f"download_returncode={download_result.returncode}", - f"remote_returncode={remote_result.returncode}", f"final_returncode={final_result.returncode}", - f"local_content={local_content!r}", + f"initial_local_content={initial_local_content!r}", + f"local_conflicting_content={local_conflicting_content!r}", + f"final_local_content={final_local_content!r}", f"safe_backup_files={safe_backup_files!r}", ] ) @@ -188,8 +208,6 @@ def run(self, context: E2EContext) -> TestResult: str(seed_stderr), str(download_stdout), str(download_stderr), - str(remote_stdout), - str(remote_stderr), str(final_stdout), str(final_stderr), str(metadata_file), @@ -197,7 +215,6 @@ def run(self, context: E2EContext) -> TestResult: details = { "seed_returncode": seed_result.returncode, "download_returncode": download_result.returncode, - "remote_returncode": remote_result.returncode, "final_returncode": final_result.returncode, "root_name": root_name, "safe_backup_count": len(safe_backup_files), @@ -206,7 +223,6 @@ def run(self, context: E2EContext) -> TestResult: for label, rc in [ ("seed", seed_result.returncode), ("download", download_result.returncode), - ("remote update", remote_result.returncode), ("final sync", final_result.returncode), ]: if rc != 0: @@ -218,11 +234,13 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if local_content != expected_remote_content: + # With bypass_data_preservation enabled, the unchanged online version + # should overwrite the locally modified file during the resync. + if final_local_content != original_remote_content: return TestResult.fail_result( self.case_id, self.name, - "Local content was not overwritten by the remote conflicting content when bypass_data_preservation was enabled", + "Local content was not overwritten by the remote file when bypass_data_preservation was enabled", artifacts, details, ) From 6dc6b7a79749921fd2ba47640e19daa7018f6eec Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 06:59:07 +1100 Subject: [PATCH 099/245] Update tc0023_bypass_data_preservation_validation.py add missing --resync-auth --- ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py index 577f49bbf..5fa1c0253 100644 --- a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -163,6 +163,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--verbose", "--resync", + "--resync-auth", "--single-directory", root_name, "--confdir", From 05bbdd57105ecb83c0bb723fa5a4484cdfac5893 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 08:57:21 +1100 Subject: [PATCH 100/245] Update tc0002_sync_list_validation.py update tc0002 --- ci/e2e/testcases/tc0002_sync_list_validation.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index c2e3c697c..6b2ad0d98 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -127,6 +127,10 @@ class TestCase0002SyncListValidation(E2ETestCase): "skip", re.compile(r"^(?:DEBUG:\s+)?Skipping path - excluded by sync_list config: (.+)$"), ), + ( + "skip", + re.compile(r"^(?:DEBUG:\s+)?Skipping file - excluded by sync_list config: (.+)$"), + ), ( "include_dir", re.compile(r"^(?:DEBUG:\s+)?Including path - included by sync_list config: (.+)$"), @@ -141,7 +145,7 @@ class TestCase0002SyncListValidation(E2ETestCase): ), ( "retain_existing", - re.compile(r"^(?:DEBUG:\s+)?Path already present in pathsRetained - retain path: (.+)$"), + re.compile(r"^(?:DEBUG:\s+)?Path already marked for retention - retaining path: (.+)$"), ), ( "retain_local_file", @@ -210,6 +214,12 @@ class TestCase0002SyncListValidation(E2ETestCase): ), ] + def _refresh_tree_mtimes(self, root: Path) -> None: + now = None + for path in sorted(root.rglob("*"), reverse=True): + os.utime(path, now) + os.utime(root, now) + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / f"tc{self.case_id}" case_log_dir = context.logs_dir / f"tc{self.case_id}" @@ -262,6 +272,8 @@ def run(self, context: E2EContext) -> TestResult: ) shutil.copytree(fixture_root, sync_root, dirs_exist_ok=True) + + self._refresh_tree_mtimes(sync_root) sync_list_path = config_dir / "sync_list" metadata_file = scenario_dir / "metadata.txt" From 8449930a3def6f95eaeaa48788865001214f6461 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 10:05:23 +1100 Subject: [PATCH 101/245] Update tc0002_sync_list_validation.py update tc0002 --- .../testcases/tc0002_sync_list_validation.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 6b2ad0d98..36d58fbc5 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -1062,6 +1062,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0004", description="include tree with nested exclusion", + execution_mode="cleanup_regression", sync_list=[ f"!/{FIXTURE_ROOT_NAME}/Documents/Notes/.config/*", f"/{FIXTURE_ROOT_NAME}/Documents/", @@ -1076,6 +1077,14 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Documents/Notes/.config", f"{FIXTURE_ROOT_NAME}/Backup", ], + phase1_config_overrides=[ + 'download_only = "false"', + 'cleanup_local_files = "false"', + ], + phase2_config_overrides=[ + 'download_only = "true"', + 'cleanup_local_files = "false"', + ], ), SyncListScenario( scenario_id="SL-0005", @@ -1321,6 +1330,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0016", description="massive mixed rule set across Programming Documents and Work", + execution_mode="cleanup_regression", sync_list=[ "!build/kotlin/*", "!.gradle/*", @@ -1377,6 +1387,14 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Programming/Projects/Misc/App1/.cache", f"{FIXTURE_ROOT_NAME}/Programming/Projects/Python/Tool1/__pycache__", ], + phase1_config_overrides=[ + 'download_only = "false"', + 'cleanup_local_files = "false"', + ], + phase2_config_overrides=[ + 'download_only = "true"', + 'cleanup_local_files = "false"', + ], ), SyncListScenario( scenario_id="SL-0017", @@ -1475,6 +1493,7 @@ def _build_scenarios(self) -> list[SyncListScenario]: SyncListScenario( scenario_id="SL-0023", description="sync_root_files true retains logical root files alongside included Projects subtree", + execution_mode="cleanup_regression", sync_list=[ f"!/{FIXTURE_ROOT_NAME}/Projects/Audio", f"!/{FIXTURE_ROOT_NAME}/Projects/Video", @@ -1502,7 +1521,16 @@ def _build_scenarios(self) -> list[SyncListScenario]: f"{FIXTURE_ROOT_NAME}/Projects/Video", f"{FIXTURE_ROOT_NAME}/Backup", ], - phase2_config_overrides=['sync_root_files = "true"'], + phase1_config_overrides=[ + 'download_only = "false"', + 'cleanup_local_files = "false"', + 'sync_root_files = "true"', + ], + phase2_config_overrides=[ + 'download_only = "true"', + 'cleanup_local_files = "false"', + 'sync_root_files = "true"', + ], ), SyncListScenario( scenario_id="SL-0024", From 9e122741684bc545973c548d133a9c2fa566cb20 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 11:18:57 +1100 Subject: [PATCH 102/245] Update tc0002_sync_list_validation.py update tc0002 --- ci/e2e/testcases/tc0002_sync_list_validation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 36d58fbc5..16abaa1e0 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -1502,7 +1502,6 @@ def _build_scenarios(self) -> list[SyncListScenario]: allowed_exact=[ f"{FIXTURE_ROOT_NAME}/README.txt", f"{FIXTURE_ROOT_NAME}/loose.bin", - f"{FIXTURE_ROOT_NAME}/zz_e2e_zz_e2e_sync_list.bin", ], allowed_prefixes=[f"{FIXTURE_ROOT_NAME}/Projects"], forbidden_prefixes=[ @@ -1512,7 +1511,6 @@ def _build_scenarios(self) -> list[SyncListScenario]: required_processed=[ f"{FIXTURE_ROOT_NAME}/README.txt", f"{FIXTURE_ROOT_NAME}/loose.bin", - f"{FIXTURE_ROOT_NAME}/zz_e2e_zz_e2e_sync_list.bin", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Exports/file.wav", f"{FIXTURE_ROOT_NAME}/Projects/Code/JOBXYZ/Source/main.txt", ], From 2b8baa00f275beecb451137b1d03c49fb5643056 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 12:14:41 +1100 Subject: [PATCH 103/245] Update PR Add details of Test Cases 0025 to 0028 Add Test Case 0029 - Upload-only + Local First sync validation --- ci/e2e/run.py | 2 + ..._only_timestamp_preservation_validation.py | 324 ++++++++++++++++++ docs/end_to_end_testing.md | 6 +- 3 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index e88a2cc92..77a186f4e 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -37,6 +37,7 @@ from testcases.tc0026_reserved_device_name_validation import TestCase0026ReservedDeviceNameValidation from testcases.tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation from testcases.tc0028_control_character_non_utf8_filename_validation import TestCase0028ControlCharacterNonUtf8FilenameValidation +from testcases.tc0029_local_first_upload_only_timestamp_preservation_validation import TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation def build_test_suite() -> list: @@ -74,6 +75,7 @@ def build_test_suite() -> list: TestCase0026ReservedDeviceNameValidation(), TestCase0027WhitespaceTrailingDotValidation(), TestCase0028ControlCharacterNonUtf8FilenameValidation(), + TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), ] diff --git a/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py b/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py new file mode 100644 index 000000000..c537b7fc3 --- /dev/null +++ b/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import os +import time +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(E2ETestCase): + case_id = "0029" + name = "local_first upload_only timestamp preservation validation" + description = ( + "Validate that --local-first --upload-only uploads local content without " + "rewriting local file timestamps from Microsoft API response data" + ) + + FIXED_MTIME_INITIAL = 1577882096 # 2020-01-01 12:34:56 UTC + FIXED_MTIME_UPDATED = 1577968496 # 2020-01-02 12:34:56 UTC + + def _write_config(self, config_path: Path, sync_dir: Path) -> None: + content = ( + "# tc0029 config\n" + f'sync_dir = "{sync_dir}"\n' + 'upload_only = "true"\n' + 'local_first = "true"\n' + 'cleanup_local_files = "false"\n' + 'bypass_data_preservation = "false"\n' + ) + write_text_file(config_path, content) + + def _set_file_mtime(self, path: Path, epoch_seconds: int) -> None: + os.utime(path, (epoch_seconds, epoch_seconds)) + + def _file_stat_snapshot(self, path: Path) -> dict[str, object]: + stat_data = path.stat() + return { + "mtime": int(stat_data.st_mtime), + "size": stat_data.st_size, + } + + def _assert_local_file_state( + self, + path: Path, + expected_content: str, + expected_mtime: int, + phase_name: str, + artifacts: list[str], + details: dict[str, object], + ) -> TestResult | None: + if not path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"{phase_name} did not leave the expected local file in place", + artifacts, + details, + ) + + actual_content = path.read_text(encoding="utf-8") + actual_mtime = int(path.stat().st_mtime) + + if actual_content != expected_content: + details[f"{phase_name}_actual_content"] = actual_content + details[f"{phase_name}_expected_content"] = expected_content + return TestResult.fail_result( + self.case_id, + self.name, + f"{phase_name} changed the local file content unexpectedly", + artifacts, + details, + ) + + if actual_mtime != expected_mtime: + details[f"{phase_name}_actual_mtime"] = actual_mtime + details[f"{phase_name}_expected_mtime"] = expected_mtime + return TestResult.fail_result( + self.case_id, + self.name, + f"{phase_name} changed the local file timestamp unexpectedly", + artifacts, + details, + ) + + return None + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0029" + case_log_dir = context.logs_dir / "tc0029" + state_dir = context.state_dir / "tc0029" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + sync_root = case_work_dir / "syncroot" + conf_dir = case_work_dir / "conf" + reset_directory(sync_root) + + context.bootstrap_config_dir(conf_dir) + self._write_config(conf_dir / "config", sync_root) + + root_name = f"ZZ_E2E_TC0029_{context.run_id}_{os.getpid()}" + relative_file = f"{root_name}/timestamp-probe.txt" + local_file = sync_root / relative_file + + initial_content = ( + "TC0029 initial content\n" + "This file is uploaded with --upload-only --local-first.\n" + ) + updated_content = ( + "TC0029 updated content\n" + "This file is uploaded again with a newer local timestamp.\n" + ) + + write_text_file(local_file, initial_content) + self._set_file_mtime(local_file, self.FIXED_MTIME_INITIAL) + initial_before = self._file_stat_snapshot(local_file) + + phase1_stdout = case_log_dir / "phase1_initial_upload_stdout.log" + phase1_stderr = case_log_dir / "phase1_initial_upload_stderr.log" + phase2_stdout = case_log_dir / "phase2_modified_upload_stdout.log" + phase2_stderr = case_log_dir / "phase2_modified_upload_stderr.log" + phase3_stdout = case_log_dir / "phase3_noop_sync_stdout.log" + phase3_stderr = case_log_dir / "phase3_noop_sync_stderr.log" + metadata_file = state_dir / "metadata.txt" + + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--upload-only", + "--local-first", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_dir), + ] + context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + + phase1_after = self._file_stat_snapshot(local_file) + + # Phase 2: change local content, set a newer fixed local timestamp, upload again + time.sleep(2) + write_text_file(local_file, updated_content) + self._set_file_mtime(local_file, self.FIXED_MTIME_UPDATED) + phase2_before = self._file_stat_snapshot(local_file) + + phase2_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--upload-only", + "--local-first", + "--single-directory", + root_name, + "--confdir", + str(conf_dir), + ] + context.log(f"Executing Test Case {self.case_id} phase2: {command_to_string(phase2_command)}") + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + + phase2_after = self._file_stat_snapshot(local_file) + + # Phase 3: run again with no local changes; the local file must remain untouched + time.sleep(2) + phase3_before = self._file_stat_snapshot(local_file) + + phase3_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--upload-only", + "--local-first", + "--single-directory", + root_name, + "--confdir", + str(conf_dir), + ] + context.log(f"Executing Test Case {self.case_id} phase3: {command_to_string(phase3_command)}") + phase3_result = run_command(phase3_command, cwd=context.repo_root) + write_text_file(phase3_stdout, phase3_result.stdout) + write_text_file(phase3_stderr, phase3_result.stderr) + + phase3_after = self._file_stat_snapshot(local_file) + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(phase3_stdout), + str(phase3_stderr), + str(metadata_file), + ] + details: dict[str, object] = { + "root_name": root_name, + "relative_file": relative_file, + "phase1_returncode": phase1_result.returncode, + "phase2_returncode": phase2_result.returncode, + "phase3_returncode": phase3_result.returncode, + "phase1_before": initial_before, + "phase1_after": phase1_after, + "phase2_before": phase2_before, + "phase2_after": phase2_after, + "phase3_before": phase3_before, + "phase3_after": phase3_after, + } + + write_text_file( + metadata_file, + "\n".join( + [ + f"case_id={self.case_id}", + f"root_name={root_name}", + f"relative_file={relative_file}", + f"phase1_returncode={phase1_result.returncode}", + f"phase2_returncode={phase2_result.returncode}", + f"phase3_returncode={phase3_result.returncode}", + f"phase1_before={initial_before!r}", + f"phase1_after={phase1_after!r}", + f"phase2_before={phase2_before!r}", + f"phase2_after={phase2_after!r}", + f"phase3_before={phase3_before!r}", + f"phase3_after={phase3_after!r}", + ] + ) + + "\n", + ) + + for label, result in [ + ("initial upload phase", phase1_result), + ("modified upload phase", phase2_result), + ("no-op sync phase", phase3_result), + ]: + if result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} failed with status {result.returncode}", + artifacts, + details, + ) + + # Upload-only mode should not perform download actions back to the local filesystem. + for label, stdout_text in [ + ("phase1", phase1_result.stdout), + ("phase2", phase2_result.stdout), + ("phase3", phase3_result.stdout), + ]: + if "Downloading file:" in stdout_text or "Creating local directory" in stdout_text: + details[f"{label}_unexpected_download_activity"] = True + return TestResult.fail_result( + self.case_id, + self.name, + f"{label} showed unexpected download-side local reconciliation activity in upload-only mode", + artifacts, + details, + ) + + failure = self._assert_local_file_state( + local_file, + initial_content, + self.FIXED_MTIME_INITIAL, + "Initial upload phase", + artifacts, + details, + ) + if failure is not None: + return failure + + failure = self._assert_local_file_state( + local_file, + updated_content, + self.FIXED_MTIME_UPDATED, + "Modified upload phase", + artifacts, + details, + ) + if failure is not None: + return failure + + failure = self._assert_local_file_state( + local_file, + updated_content, + self.FIXED_MTIME_UPDATED, + "No-op sync phase", + artifacts, + details, + ) + if failure is not None: + return failure + + # Phase 3 should not upload again when nothing changed locally. + phase3_upload_markers = [ + "Uploading new file:", + "Uploading modified file:", + "Uploading file:", + ] + if any(marker in phase3_result.stdout for marker in phase3_upload_markers): + details["phase3_unexpected_upload_activity"] = True + return TestResult.fail_result( + self.case_id, + self.name, + "No-op sync phase unexpectedly attempted another upload despite no local changes", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index d774cb49b..bf39c47e4 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -36,4 +36,8 @@ The objective of the test framework is to ensure that all client functionality c | 0022 | Local-first conflict resolution validation | - Personal
- Business | This test validates the 'local_first' configuration option. When a file conflict occurs between local and remote versions, the client should treat the local file as the authoritative source and update the remote version accordingly. | | 0023 | Bypass data preservation validation | - Personal
- Business | This test validates the behaviour of the 'bypass_data_preservation' option. When enabled, the client should suppress the creation of safe-backup files during conflict resolution and allow remote content to overwrite local changes. | | 0024 | Big delete safeguard validation | - Personal
- Business | This test validates the 'classify_as_big_delete' protection mechanism. When a large number of items are deleted locally, the client should halt synchronisation and emit a warning. The deletion should only proceed after the user explicitly acknowledges the action using `--force`. | - +| 0025 | Invalid character filename validation | - Personal
- Business | This test validates that invalid filename characters are blocked while valid sibling files still synchronise | +| 0026 | Reserved device name validation | - Personal
- Business | This test validates that reserved Windows device names are blocked while valid lookalike names still synchronise | +| 0027 | Whitespace and trailing dot validation | - Personal
- Business | This test validates that trailing whitespace and trailing dot names are blocked while valid sibling files still synchronise | +| 0028 | Control character and non-UTF8 filename validation | - Personal
- Business | This test validates that control characters and non-UTF8 filenames are safely skipped without client crash while valid sibling files still synchronise | +| 0029 | Upload-only + Local First sync validation | - Personal
- Business | This test validates that `--local-first --upload-only` uploads local content without rewriting local file timestamps from Microsoft API response data | From c67db4fe21421c310c497d09712ea22b05fdb2cb Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 13:35:59 +1100 Subject: [PATCH 104/245] Update tc0029_local_first_upload_only_timestamp_preservation_validation.py Update tc0029 --- ..._only_timestamp_preservation_validation.py | 308 +++++++++++------- 1 file changed, 199 insertions(+), 109 deletions(-) diff --git a/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py b/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py index c537b7fc3..63c8f9c92 100644 --- a/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py +++ b/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py @@ -63,9 +63,12 @@ def _assert_local_file_state( actual_content = path.read_text(encoding="utf-8") actual_mtime = int(path.stat().st_mtime) + details[f"{phase_name}_actual_content"] = actual_content + details[f"{phase_name}_actual_mtime"] = actual_mtime + details[f"{phase_name}_expected_content"] = expected_content + details[f"{phase_name}_expected_mtime"] = expected_mtime + if actual_content != expected_content: - details[f"{phase_name}_actual_content"] = actual_content - details[f"{phase_name}_expected_content"] = expected_content return TestResult.fail_result( self.case_id, self.name, @@ -75,8 +78,6 @@ def _assert_local_file_state( ) if actual_mtime != expected_mtime: - details[f"{phase_name}_actual_mtime"] = actual_mtime - details[f"{phase_name}_expected_mtime"] = expected_mtime return TestResult.fail_result( self.case_id, self.name, @@ -87,6 +88,53 @@ def _assert_local_file_state( return None + def _assert_no_download_activity( + self, + stdout_text: str, + phase_name: str, + artifacts: list[str], + details: dict[str, object], + ) -> TestResult | None: + unexpected_markers = [ + "Downloading file:", + "Creating local directory", + ] + for marker in unexpected_markers: + if marker in stdout_text: + details[f"{phase_name}_unexpected_download_marker"] = marker + return TestResult.fail_result( + self.case_id, + self.name, + f"{phase_name} showed unexpected download-side local reconciliation activity in upload-only mode", + artifacts, + details, + ) + return None + + def _assert_no_upload_activity( + self, + stdout_text: str, + phase_name: str, + artifacts: list[str], + details: dict[str, object], + ) -> TestResult | None: + unexpected_markers = [ + "Uploading new file:", + "Uploading modified file:", + "Uploading file:", + ] + for marker in unexpected_markers: + if marker in stdout_text: + details[f"{phase_name}_unexpected_upload_marker"] = marker + return TestResult.fail_result( + self.case_id, + self.name, + f"{phase_name} unexpectedly attempted another upload despite no local changes", + artifacts, + details, + ) + return None + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0029" case_log_dir = context.logs_dir / "tc0029" @@ -117,10 +165,6 @@ def run(self, context: E2EContext) -> TestResult: "This file is uploaded again with a newer local timestamp.\n" ) - write_text_file(local_file, initial_content) - self._set_file_mtime(local_file, self.FIXED_MTIME_INITIAL) - initial_before = self._file_stat_snapshot(local_file) - phase1_stdout = case_log_dir / "phase1_initial_upload_stdout.log" phase1_stderr = case_log_dir / "phase1_initial_upload_stderr.log" phase2_stdout = case_log_dir / "phase2_modified_upload_stdout.log" @@ -129,6 +173,25 @@ def run(self, context: E2EContext) -> TestResult: phase3_stderr = case_log_dir / "phase3_noop_sync_stderr.log" metadata_file = state_dir / "metadata.txt" + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(phase3_stdout), + str(phase3_stderr), + str(metadata_file), + ] + details: dict[str, object] = { + "root_name": root_name, + "relative_file": relative_file, + } + + # Phase 1: create the initial file, set a fixed local timestamp, and upload it. + write_text_file(local_file, initial_content) + self._set_file_mtime(local_file, self.FIXED_MTIME_INITIAL) + phase1_before = self._file_stat_snapshot(local_file) + phase1_command = [ context.onedrive_bin, "--display-running-config", @@ -147,10 +210,54 @@ def run(self, context: E2EContext) -> TestResult: phase1_result = run_command(phase1_command, cwd=context.repo_root) write_text_file(phase1_stdout, phase1_result.stdout) write_text_file(phase1_stderr, phase1_result.stderr) - phase1_after = self._file_stat_snapshot(local_file) - # Phase 2: change local content, set a newer fixed local timestamp, upload again + details["phase1_returncode"] = phase1_result.returncode + details["phase1_before"] = phase1_before + details["phase1_after"] = phase1_after + + if phase1_result.returncode != 0: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + f"initial upload phase failed with status {phase1_result.returncode}", + artifacts, + details, + ) + + failure = self._assert_no_download_activity( + phase1_result.stdout, + "Initial upload phase", + artifacts, + details, + ) + if failure is not None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return failure + + failure = self._assert_local_file_state( + local_file, + initial_content, + self.FIXED_MTIME_INITIAL, + "Initial upload phase", + artifacts, + details, + ) + if failure is not None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return failure + + # Phase 2: modify the local file, set a newer fixed local timestamp, and upload again. time.sleep(2) write_text_file(local_file, updated_content) self._set_file_mtime(local_file, self.FIXED_MTIME_UPDATED) @@ -172,10 +279,54 @@ def run(self, context: E2EContext) -> TestResult: phase2_result = run_command(phase2_command, cwd=context.repo_root) write_text_file(phase2_stdout, phase2_result.stdout) write_text_file(phase2_stderr, phase2_result.stderr) - phase2_after = self._file_stat_snapshot(local_file) - # Phase 3: run again with no local changes; the local file must remain untouched + details["phase2_returncode"] = phase2_result.returncode + details["phase2_before"] = phase2_before + details["phase2_after"] = phase2_after + + if phase2_result.returncode != 0: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + f"modified upload phase failed with status {phase2_result.returncode}", + artifacts, + details, + ) + + failure = self._assert_no_download_activity( + phase2_result.stdout, + "Modified upload phase", + artifacts, + details, + ) + if failure is not None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return failure + + failure = self._assert_local_file_state( + local_file, + updated_content, + self.FIXED_MTIME_UPDATED, + "Modified upload phase", + artifacts, + details, + ) + if failure is not None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return failure + + # Phase 3: run again with no local changes; the local file must remain untouched. time.sleep(2) phase3_before = self._file_stat_snapshot(local_file) @@ -195,130 +346,69 @@ def run(self, context: E2EContext) -> TestResult: phase3_result = run_command(phase3_command, cwd=context.repo_root) write_text_file(phase3_stdout, phase3_result.stdout) write_text_file(phase3_stderr, phase3_result.stderr) - phase3_after = self._file_stat_snapshot(local_file) - artifacts = [ - str(phase1_stdout), - str(phase1_stderr), - str(phase2_stdout), - str(phase2_stderr), - str(phase3_stdout), - str(phase3_stderr), - str(metadata_file), - ] - details: dict[str, object] = { - "root_name": root_name, - "relative_file": relative_file, - "phase1_returncode": phase1_result.returncode, - "phase2_returncode": phase2_result.returncode, - "phase3_returncode": phase3_result.returncode, - "phase1_before": initial_before, - "phase1_after": phase1_after, - "phase2_before": phase2_before, - "phase2_after": phase2_after, - "phase3_before": phase3_before, - "phase3_after": phase3_after, - } + details["phase3_returncode"] = phase3_result.returncode + details["phase3_before"] = phase3_before + details["phase3_after"] = phase3_after - write_text_file( - metadata_file, - "\n".join( - [ - f"case_id={self.case_id}", - f"root_name={root_name}", - f"relative_file={relative_file}", - f"phase1_returncode={phase1_result.returncode}", - f"phase2_returncode={phase2_result.returncode}", - f"phase3_returncode={phase3_result.returncode}", - f"phase1_before={initial_before!r}", - f"phase1_after={phase1_after!r}", - f"phase2_before={phase2_before!r}", - f"phase2_after={phase2_after!r}", - f"phase3_before={phase3_before!r}", - f"phase3_after={phase3_after!r}", - ] + if phase3_result.returncode != 0: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + f"no-op sync phase failed with status {phase3_result.returncode}", + artifacts, + details, ) - + "\n", - ) - - for label, result in [ - ("initial upload phase", phase1_result), - ("modified upload phase", phase2_result), - ("no-op sync phase", phase3_result), - ]: - if result.returncode != 0: - return TestResult.fail_result( - self.case_id, - self.name, - f"{label} failed with status {result.returncode}", - artifacts, - details, - ) - - # Upload-only mode should not perform download actions back to the local filesystem. - for label, stdout_text in [ - ("phase1", phase1_result.stdout), - ("phase2", phase2_result.stdout), - ("phase3", phase3_result.stdout), - ]: - if "Downloading file:" in stdout_text or "Creating local directory" in stdout_text: - details[f"{label}_unexpected_download_activity"] = True - return TestResult.fail_result( - self.case_id, - self.name, - f"{label} showed unexpected download-side local reconciliation activity in upload-only mode", - artifacts, - details, - ) - failure = self._assert_local_file_state( - local_file, - initial_content, - self.FIXED_MTIME_INITIAL, - "Initial upload phase", + failure = self._assert_no_download_activity( + phase3_result.stdout, + "No-op sync phase", artifacts, details, ) if failure is not None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) return failure failure = self._assert_local_file_state( local_file, updated_content, self.FIXED_MTIME_UPDATED, - "Modified upload phase", + "No-op sync phase", artifacts, details, ) if failure is not None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) return failure - failure = self._assert_local_file_state( - local_file, - updated_content, - self.FIXED_MTIME_UPDATED, + failure = self._assert_no_upload_activity( + phase3_result.stdout, "No-op sync phase", artifacts, details, ) if failure is not None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) return failure - # Phase 3 should not upload again when nothing changed locally. - phase3_upload_markers = [ - "Uploading new file:", - "Uploading modified file:", - "Uploading file:", - ] - if any(marker in phase3_result.stdout for marker in phase3_upload_markers): - details["phase3_unexpected_upload_activity"] = True - return TestResult.fail_result( - self.case_id, - self.name, - "No-op sync phase unexpectedly attempted another upload despite no local changes", - artifacts, - details, - ) + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 63b186681aa4361f203a1ea4a478b6ecc03978a7 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 15:34:01 +1100 Subject: [PATCH 105/245] Add SharePoint 'drive_id' support Add SharePoint 'drive_id' support --- ci/e2e/framework/context.py | 8 +++- ci/e2e/framework/utils.py | 38 ++++++++++++++++++- ci/e2e/testcases/tc0001_basic_resync.py | 2 +- .../testcases/tc0002_sync_list_validation.py | 2 +- ci/e2e/testcases/tc0003_dry_run_validation.py | 4 +- .../testcases/tc0004_single_directory_sync.py | 4 +- .../testcases/tc0005_force_sync_override.py | 6 +-- ci/e2e/testcases/tc0006_download_only.py | 4 +- ...c0007_download_only_cleanup_local_files.py | 4 +- ci/e2e/testcases/tc0008_upload_only.py | 4 +- .../tc0009_upload_only_no_remote_delete.py | 4 +- .../tc0010_upload_only_remove_source_files.py | 4 +- .../testcases/tc0011_skip_file_validation.py | 6 +-- .../testcases/tc0012_skip_dir_validation.py | 8 ++-- .../tc0013_skip_dotfiles_validation.py | 6 +-- .../testcases/tc0014_skip_size_validation.py | 6 +-- .../tc0015_skip_symlinks_validation.py | 6 +-- .../tc0016_check_nosync_validation.py | 6 +-- .../tc0017_check_nomount_validation.py | 4 +- .../tc0018_recycle_bin_validation.py | 8 ++-- .../tc0019_logging_and_running_config.py | 4 +- .../tc0020_monitor_mode_validation.py | 6 +-- .../tc0021_resumable_transfers_validation.py | 4 +- .../tc0022_local_first_validation.py | 4 +- ...023_bypass_data_preservation_validation.py | 4 +- .../tc0024_big_delete_safeguard_validation.py | 4 +- ...5_invalid_character_filename_validation.py | 4 +- .../tc0026_reserved_device_name_validation.py | 4 +- ...0027_whitespace_trailing_dot_validation.py | 4 +- ..._character_non_utf8_filename_validation.py | 4 +- ..._only_timestamp_preservation_validation.py | 4 +- 31 files changed, 111 insertions(+), 69 deletions(-) diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py index 100ae2da5..02e2457a7 100644 --- a/ci/e2e/framework/context.py +++ b/ci/e2e/framework/context.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from pathlib import Path -from framework.utils import ensure_directory, timestamp_now, write_text_file_append +from framework.utils import ensure_directory, get_optional_base_config_text, timestamp_now, write_text_file, write_text_file_append @dataclass @@ -85,6 +85,8 @@ def ensure_refresh_token_available(self) -> None: def bootstrap_config_dir(self, config_dir: Path) -> Path: """ Copy the existing refresh_token into a per-test/per-scenario config dir. + If a base config.sharepoint exists, seed config with that content so all + SharePoint scenarios inherit drive_id by default. """ self.ensure_refresh_token_available() ensure_directory(config_dir) @@ -94,6 +96,10 @@ def bootstrap_config_dir(self, config_dir: Path) -> Path: shutil.copy2(source, destination) os.chmod(destination, 0o600) + base_config_text = get_optional_base_config_text() + if base_config_text: + write_text_file(config_dir / "config", base_config_text) + return destination def log(self, message: str) -> None: diff --git a/ci/e2e/framework/utils.py b/ci/e2e/framework/utils.py index 749f820ca..a003e0955 100644 --- a/ci/e2e/framework/utils.py +++ b/ci/e2e/framework/utils.py @@ -235,4 +235,40 @@ def perform_full_account_cleanup( "phase1_command": command_to_string(phase1_command), "phase3_command": command_to_string(phase3_command), }, - ) \ No newline at end of file + ) + +def default_onedrive_config_dir_from_env() -> Path: + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "").strip() + if xdg_config_home: + return Path(xdg_config_home) / "onedrive" + + home = os.environ.get("HOME", "").strip() + if not home: + raise RuntimeError("Neither XDG_CONFIG_HOME nor HOME is set") + + return Path(home) / ".config" / "onedrive" + + +def get_optional_base_config_text() -> str: + """ + Return the optional base config content used to seed SharePoint-specific + configuration such as drive_id. For personal/business testing this returns + an empty string. + """ + base_config_path = default_onedrive_config_dir_from_env() / "config.sharepoint" + if not base_config_path.is_file(): + return "" + + text = base_config_path.read_text(encoding="utf-8") + if text and not text.endswith("\n"): + text += "\n" + return text + + +def write_onedrive_config(path: Path, content: str) -> None: + """ + Write a test config file, automatically prepending any optional base config + such as SharePoint drive_id settings from config.sharepoint. + """ + base_config_text = get_optional_base_config_text() + write_text_file(path, base_config_text + content) diff --git a/ci/e2e/testcases/tc0001_basic_resync.py b/ci/e2e/testcases/tc0001_basic_resync.py index 34ad764b6..8e149c643 100644 --- a/ci/e2e/testcases/tc0001_basic_resync.py +++ b/ci/e2e/testcases/tc0001_basic_resync.py @@ -5,7 +5,7 @@ from framework.base import E2ETestCase from framework.context import E2EContext from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0001BasicResync(E2ETestCase): diff --git a/ci/e2e/testcases/tc0002_sync_list_validation.py b/ci/e2e/testcases/tc0002_sync_list_validation.py index 16abaa1e0..fea6a4d7c 100644 --- a/ci/e2e/testcases/tc0002_sync_list_validation.py +++ b/ci/e2e/testcases/tc0002_sync_list_validation.py @@ -11,7 +11,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file FIXTURE_ROOT_NAME = "ZZ_E2E_SYNC_LIST" diff --git a/ci/e2e/testcases/tc0003_dry_run_validation.py b/ci/e2e/testcases/tc0003_dry_run_validation.py index 6d4a0d8f0..073bfb0e5 100644 --- a/ci/e2e/testcases/tc0003_dry_run_validation.py +++ b/ci/e2e/testcases/tc0003_dry_run_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0003DryRunValidation(E2ETestCase): @@ -19,7 +19,7 @@ def _root_name(self, context: E2EContext) -> str: return f"ZZ_E2E_TC0003_{context.run_id}_{os.getpid()}" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0003 config\nbypass_data_preservation = \"true\"\n") + write_onedrive_config(config_path, "# tc0003 config\nbypass_data_preservation = \"true\"\n") def _bootstrap_confdir(self, context: E2EContext, confdir: Path) -> Path: copied_refresh_token = context.bootstrap_config_dir(confdir) diff --git a/ci/e2e/testcases/tc0004_single_directory_sync.py b/ci/e2e/testcases/tc0004_single_directory_sync.py index 36432710a..436bd15a6 100644 --- a/ci/e2e/testcases/tc0004_single_directory_sync.py +++ b/ci/e2e/testcases/tc0004_single_directory_sync.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0004SingleDirectorySync(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0004SingleDirectorySync(E2ETestCase): description = "Validate that only the nominated subtree is synchronised" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0004 config\nbypass_data_preservation = \"true\"\n") + write_onedrive_config(config_path, "# tc0004 config\nbypass_data_preservation = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0004" diff --git a/ci/e2e/testcases/tc0005_force_sync_override.py b/ci/e2e/testcases/tc0005_force_sync_override.py index 2b6322a18..92f636fd8 100644 --- a/ci/e2e/testcases/tc0005_force_sync_override.py +++ b/ci/e2e/testcases/tc0005_force_sync_override.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0005ForceSyncOverride(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0005ForceSyncOverride(E2ETestCase): description = "Validate that --force-sync overrides skip_dir for blocked single-directory sync" def _write_config(self, config_path: Path, blocked_dir: str) -> None: - write_text_file(config_path, f"# tc0005 config\nbypass_data_preservation = \"true\"\nskip_dir = \"{blocked_dir}\"\n") + write_onedrive_config(config_path, f"# tc0005 config\nbypass_data_preservation = \"true\"\nskip_dir = \"{blocked_dir}\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0005" @@ -38,7 +38,7 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(confdir) self._write_config(confdir / "config", blocked_dir) context.bootstrap_config_dir(verify_confdir) - write_text_file(verify_confdir / "config", "# tc0005 verify\nbypass_data_preservation = \"true\"\n") + write_onedrive_config(verify_confdir / "config", "# tc0005 verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "seed_stdout.log" stderr_file = case_log_dir / "seed_stderr.log" diff --git a/ci/e2e/testcases/tc0006_download_only.py b/ci/e2e/testcases/tc0006_download_only.py index bfa1672b0..34647bda4 100644 --- a/ci/e2e/testcases/tc0006_download_only.py +++ b/ci/e2e/testcases/tc0006_download_only.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0006DownloadOnly(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0006DownloadOnly(E2ETestCase): description = "Validate that download-only populates local content from remote data" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0006 config\nbypass_data_preservation = \"true\"\n") + write_onedrive_config(config_path, "# tc0006 config\nbypass_data_preservation = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0006"; case_log_dir = context.logs_dir / "tc0006"; state_dir = context.state_dir / "tc0006" diff --git a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py index fa4cded92..e7d476750 100644 --- a/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py +++ b/ci/e2e/testcases/tc0007_download_only_cleanup_local_files.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0007DownloadOnlyCleanupLocalFiles(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0007DownloadOnlyCleanupLocalFiles(E2ETestCase): description = "Validate that cleanup_local_files removes stale local content in download-only mode" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0007 config\nbypass_data_preservation = \"true\"\n") + write_onedrive_config(config_path, "# tc0007 config\nbypass_data_preservation = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0007"; case_log_dir = context.logs_dir / "tc0007"; state_dir = context.state_dir / "tc0007" diff --git a/ci/e2e/testcases/tc0008_upload_only.py b/ci/e2e/testcases/tc0008_upload_only.py index fe1a1b1bd..2291c506e 100644 --- a/ci/e2e/testcases/tc0008_upload_only.py +++ b/ci/e2e/testcases/tc0008_upload_only.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0008UploadOnly(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0008UploadOnly(E2ETestCase): description = "Validate that upload-only pushes local content remotely" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0008 config\nbypass_data_preservation = \"true\"\n") + write_onedrive_config(config_path, "# tc0008 config\nbypass_data_preservation = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0008"; case_log_dir = context.logs_dir / "tc0008"; state_dir = context.state_dir / "tc0008" diff --git a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py index a3f450954..d05353cfb 100644 --- a/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py +++ b/ci/e2e/testcases/tc0009_upload_only_no_remote_delete.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0009UploadOnlyNoRemoteDelete(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0009UploadOnlyNoRemoteDelete(E2ETestCase): description = "Validate that no_remote_delete preserves remote content in upload-only mode" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0009 config\nbypass_data_preservation = \"true\"\n") + write_onedrive_config(config_path, "# tc0009 config\nbypass_data_preservation = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0009"; case_log_dir = context.logs_dir / "tc0009"; state_dir = context.state_dir / "tc0009" diff --git a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py index 905ea9ecb..c47c48a62 100644 --- a/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py +++ b/ci/e2e/testcases/tc0010_upload_only_remove_source_files.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0010UploadOnlyRemoveSourceFiles(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0010UploadOnlyRemoveSourceFiles(E2ETestCase): description = "Validate that remove_source_files removes local files after upload-only succeeds" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0010 config\nbypass_data_preservation = \"true\"\n") + write_onedrive_config(config_path, "# tc0010 config\nbypass_data_preservation = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0010"; case_log_dir = context.logs_dir / "tc0010"; state_dir = context.state_dir / "tc0010" diff --git a/ci/e2e/testcases/tc0011_skip_file_validation.py b/ci/e2e/testcases/tc0011_skip_file_validation.py index a7d9dbcbc..74812c551 100644 --- a/ci/e2e/testcases/tc0011_skip_file_validation.py +++ b/ci/e2e/testcases/tc0011_skip_file_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0011SkipFileValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0011SkipFileValidation(E2ETestCase): description = "Validate that skip_file patterns prevent matching files from synchronising" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0011 config\nbypass_data_preservation = \"true\"\nskip_file = \"*.tmp|*.swp\"\n") + write_onedrive_config(config_path, "# tc0011 config\nbypass_data_preservation = \"true\"\nskip_file = \"*.tmp|*.swp\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0011"; case_log_dir = context.logs_dir / "tc0011"; state_dir = context.state_dir / "tc0011" @@ -24,7 +24,7 @@ def run(self, context: E2EContext) -> TestResult: sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0011_{context.run_id}_{os.getpid()}" write_text_file(sync_root / root_name / "keep.txt", "keep\n"); write_text_file(sync_root / root_name / "skip.tmp", "skip\n"); write_text_file(sync_root / root_name / "editor.swp", "swap\n") context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") - context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# tc0011 verify\nbypass_data_preservation = \"true\"\n") + context.bootstrap_config_dir(verify_conf); write_onedrive_config(verify_conf / "config", "# tc0011 verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "skip_file_stdout.log"; stderr_file = case_log_dir / "skip_file_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) diff --git a/ci/e2e/testcases/tc0012_skip_dir_validation.py b/ci/e2e/testcases/tc0012_skip_dir_validation.py index 45b2533f3..a79b50d84 100644 --- a/ci/e2e/testcases/tc0012_skip_dir_validation.py +++ b/ci/e2e/testcases/tc0012_skip_dir_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0012SkipDirValidation(E2ETestCase): @@ -19,7 +19,7 @@ def _write_config(self, config_path: Path, skip_dir_value: str, strict: bool) -> lines = ["# tc0012 config", "bypass_data_preservation = \"true\"", f"skip_dir = \"{skip_dir_value}\""] if strict: lines.append("skip_dir_strict_match = \"true\"") - write_text_file(config_path, "\n".join(lines) + "\n") + write_onedrive_config(config_path, "\n".join(lines) + "\n") def _run_loose(self, context: E2EContext, case_log_dir: Path, all_artifacts: list[str], failures: list[str]) -> None: scenario_root = context.work_root / "tc0012" / "loose_match"; scenario_state = context.state_dir / "tc0012" / "loose_match" @@ -30,7 +30,7 @@ def _run_loose(self, context: E2EContext, case_log_dir: Path, all_artifacts: lis write_text_file(sync_root / root / "App" / "Cache" / "nested.txt", "skip nested\n") write_text_file(sync_root / root / "Keep" / "ok.txt", "ok\n") context.bootstrap_config_dir(confdir); self._write_config(confdir / "config", "Cache", False) - context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + context.bootstrap_config_dir(verify_conf); write_onedrive_config(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "loose_match_stdout.log"; stderr_file = case_log_dir / "loose_match_stderr.log"; verify_stdout = case_log_dir / "loose_match_verify_stdout.log"; verify_stderr = case_log_dir / "loose_match_verify_stderr.log"; manifest_file = scenario_state / "remote_verify_manifest.txt" result = run_command([context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)], cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) @@ -52,7 +52,7 @@ def _run_strict(self, context: E2EContext, case_log_dir: Path, all_artifacts: li write_text_file(sync_root / root / "App" / "Cache" / "nested.txt", "nested should skip\n") write_text_file(sync_root / root / "Keep" / "ok.txt", "ok\n") context.bootstrap_config_dir(confdir); self._write_config(confdir / "config", f"{root}/App/Cache", True) - context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + context.bootstrap_config_dir(verify_conf); write_onedrive_config(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "strict_match_stdout.log"; stderr_file = case_log_dir / "strict_match_stderr.log"; verify_stdout = case_log_dir / "strict_match_verify_stdout.log"; verify_stderr = case_log_dir / "strict_match_verify_stderr.log"; manifest_file = scenario_state / "remote_verify_manifest.txt" result = run_command([context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)], cwd=context.repo_root) write_text_file(stdout_file, result.stdout); write_text_file(stderr_file, result.stderr) diff --git a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py index d716e9af7..84b460b22 100644 --- a/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py +++ b/ci/e2e/testcases/tc0013_skip_dotfiles_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0013SkipDotfilesValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0013SkipDotfilesValidation(E2ETestCase): description = "Validate that skip_dotfiles prevents dotfiles and dot-directories from synchronising" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0013 config\nbypass_data_preservation = \"true\"\nskip_dotfiles = \"true\"\n") + write_onedrive_config(config_path, "# tc0013 config\nbypass_data_preservation = \"true\"\nskip_dotfiles = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0013"; case_log_dir = context.logs_dir / "tc0013"; state_dir = context.state_dir / "tc0013" @@ -24,7 +24,7 @@ def run(self, context: E2EContext) -> TestResult: sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0013_{context.run_id}_{os.getpid()}" write_text_file(sync_root / root_name / "visible.txt", "visible\n"); write_text_file(sync_root / root_name / ".hidden.txt", "hidden\n"); write_text_file(sync_root / root_name / ".dotdir" / "inside.txt", "inside\n") context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") - context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + context.bootstrap_config_dir(verify_conf); write_onedrive_config(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "skip_dotfiles_stdout.log"; stderr_file = case_log_dir / "skip_dotfiles_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) diff --git a/ci/e2e/testcases/tc0014_skip_size_validation.py b/ci/e2e/testcases/tc0014_skip_size_validation.py index 12c3f3051..8c1297bed 100644 --- a/ci/e2e/testcases/tc0014_skip_size_validation.py +++ b/ci/e2e/testcases/tc0014_skip_size_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0014SkipSizeValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0014SkipSizeValidation(E2ETestCase): description = "Validate that skip_size prevents oversized files from synchronising" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0014 config\nbypass_data_preservation = \"true\"\nenable_logging = \"true\"\nskip_size = \"1\"\n") + write_onedrive_config(config_path, "# tc0014 config\nbypass_data_preservation = \"true\"\nenable_logging = \"true\"\nskip_size = \"1\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0014"; case_log_dir = context.logs_dir / "tc0014"; state_dir = context.state_dir / "tc0014" @@ -26,7 +26,7 @@ def run(self, context: E2EContext) -> TestResult: big_path = sync_root / root_name / "large.bin"; big_path.parent.mkdir(parents=True, exist_ok=True); big_path.write_bytes(b"B" * (2 * 1024 * 1024)) context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") write_text_file(confdir / "config", (confdir / "config").read_text(encoding="utf-8") + f'log_dir = "{app_log_dir}"\n') - context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + context.bootstrap_config_dir(verify_conf); write_onedrive_config(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "skip_size_stdout.log"; stderr_file = case_log_dir / "skip_size_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt"; config_copy = state_dir / "config_used.txt"; verify_config_copy = state_dir / "verify_config_used.txt" command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) diff --git a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py index a88256ccb..faece8931 100644 --- a/ci/e2e/testcases/tc0015_skip_symlinks_validation.py +++ b/ci/e2e/testcases/tc0015_skip_symlinks_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0015SkipSymlinksValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0015SkipSymlinksValidation(E2ETestCase): description = "Validate that skip_symlinks prevents symbolic links from synchronising" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0015 config\nbypass_data_preservation = \"true\"\nskip_symlinks = \"true\"\n") + write_onedrive_config(config_path, "# tc0015 config\nbypass_data_preservation = \"true\"\nskip_symlinks = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0015"; case_log_dir = context.logs_dir / "tc0015"; state_dir = context.state_dir / "tc0015" @@ -24,7 +24,7 @@ def run(self, context: E2EContext) -> TestResult: sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0015_{context.run_id}_{os.getpid()}" target = sync_root / root_name / "real.txt"; write_text_file(target, "real\n"); link = sync_root / root_name / "linked.txt"; link.parent.mkdir(parents=True, exist_ok=True); link.symlink_to(target.name) context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") - context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + context.bootstrap_config_dir(verify_conf); write_onedrive_config(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "skip_symlinks_stdout.log"; stderr_file = case_log_dir / "skip_symlinks_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) diff --git a/ci/e2e/testcases/tc0016_check_nosync_validation.py b/ci/e2e/testcases/tc0016_check_nosync_validation.py index 12cf01361..991631a3e 100644 --- a/ci/e2e/testcases/tc0016_check_nosync_validation.py +++ b/ci/e2e/testcases/tc0016_check_nosync_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0016CheckNosyncValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0016CheckNosyncValidation(E2ETestCase): description = "Validate that check_nosync prevents directories containing .nosync from synchronising" def _write_config(self, config_path: Path) -> None: - write_text_file(config_path, "# tc0016 config\nbypass_data_preservation = \"true\"\ncheck_nosync = \"true\"\n") + write_onedrive_config(config_path, "# tc0016 config\nbypass_data_preservation = \"true\"\ncheck_nosync = \"true\"\n") def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0016"; case_log_dir = context.logs_dir / "tc0016"; state_dir = context.state_dir / "tc0016" @@ -24,7 +24,7 @@ def run(self, context: E2EContext) -> TestResult: sync_root = case_work_dir / "syncroot"; confdir = case_work_dir / "conf-main"; verify_root = case_work_dir / "verifyroot"; verify_conf = case_work_dir / "conf-verify"; root_name = f"ZZ_E2E_TC0016_{context.run_id}_{os.getpid()}" write_text_file(sync_root / root_name / "Allowed" / "ok.txt", "ok\n"); write_text_file(sync_root / root_name / "Blocked" / ".nosync", ""); write_text_file(sync_root / root_name / "Blocked" / "blocked.txt", "blocked\n") context.bootstrap_config_dir(confdir); self._write_config(confdir / "config") - context.bootstrap_config_dir(verify_conf); write_text_file(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") + context.bootstrap_config_dir(verify_conf); write_onedrive_config(verify_conf / "config", "# verify\nbypass_data_preservation = \"true\"\n") stdout_file = case_log_dir / "check_nosync_stdout.log"; stderr_file = case_log_dir / "check_nosync_stderr.log"; verify_stdout = case_log_dir / "verify_stdout.log"; verify_stderr = case_log_dir / "verify_stderr.log"; remote_manifest_file = state_dir / "remote_verify_manifest.txt"; metadata_file = state_dir / "metadata.txt" command = [context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--resync", "--resync-auth", "--syncdir", str(sync_root), "--confdir", str(confdir)] result = run_command(command, cwd=context.repo_root) diff --git a/ci/e2e/testcases/tc0017_check_nomount_validation.py b/ci/e2e/testcases/tc0017_check_nomount_validation.py index 5da31d2a7..ea06d4a41 100644 --- a/ci/e2e/testcases/tc0017_check_nomount_validation.py +++ b/ci/e2e/testcases/tc0017_check_nomount_validation.py @@ -6,7 +6,7 @@ from framework.base import E2ETestCase from framework.context import E2EContext from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0017CheckNomountValidation(E2ETestCase): @@ -15,7 +15,7 @@ class TestCase0017CheckNomountValidation(E2ETestCase): description = "Validate that check_nomount aborts synchronisation when .nosync exists in the sync_dir mount point" def _write_config(self, config_path: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "# tc0017 config\n" 'bypass_data_preservation = "true"\n' diff --git a/ci/e2e/testcases/tc0018_recycle_bin_validation.py b/ci/e2e/testcases/tc0018_recycle_bin_validation.py index 169e14300..27d9d6b67 100644 --- a/ci/e2e/testcases/tc0018_recycle_bin_validation.py +++ b/ci/e2e/testcases/tc0018_recycle_bin_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0018RecycleBinValidation(E2ETestCase): @@ -16,14 +16,14 @@ class TestCase0018RecycleBinValidation(E2ETestCase): description = "Validate that online deletions are moved into a FreeDesktop-compliant recycle bin when enabled" def _write_runtime_base_config(self, config_path: Path, sync_dir: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "# tc0018 runtime base config\n" f'sync_dir = "{sync_dir}"\n', ) def _write_runtime_cleanup_config(self, config_path: Path, sync_dir: Path, recycle_bin_path: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "# tc0018 runtime cleanup config\n" f'sync_dir = "{sync_dir}"\n' @@ -34,7 +34,7 @@ def _write_runtime_cleanup_config(self, config_path: Path, sync_dir: Path, recyc ) def _write_verify_config(self, config_path: Path, sync_dir: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "# tc0018 verify config\n" f'sync_dir = "{sync_dir}"\n', diff --git a/ci/e2e/testcases/tc0019_logging_and_running_config.py b/ci/e2e/testcases/tc0019_logging_and_running_config.py index 5527c42a9..f9644c1bd 100644 --- a/ci/e2e/testcases/tc0019_logging_and_running_config.py +++ b/ci/e2e/testcases/tc0019_logging_and_running_config.py @@ -6,7 +6,7 @@ from framework.base import E2ETestCase from framework.context import E2EContext from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0019LoggingAndRunningConfig(E2ETestCase): @@ -15,7 +15,7 @@ class TestCase0019LoggingAndRunningConfig(E2ETestCase): description = "Validate custom log_dir output and display-running-config visibility" def _write_config(self, config_path: Path, app_log_dir: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "# tc0019 config\n" 'bypass_data_preservation = "true"\n' diff --git a/ci/e2e/testcases/tc0020_monitor_mode_validation.py b/ci/e2e/testcases/tc0020_monitor_mode_validation.py index 768d14c53..aafefcedd 100644 --- a/ci/e2e/testcases/tc0020_monitor_mode_validation.py +++ b/ci/e2e/testcases/tc0020_monitor_mode_validation.py @@ -10,7 +10,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0020MonitorModeValidation(E2ETestCase): @@ -19,7 +19,7 @@ class TestCase0020MonitorModeValidation(E2ETestCase): description = "Validate that monitor mode uploads local changes without manually re-running --sync" def _write_config(self, config_path: Path, app_log_dir: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "# tc0020 config\n" 'bypass_data_preservation = "true"\n' @@ -50,7 +50,7 @@ def run(self, context: E2EContext) -> TestResult: context.bootstrap_config_dir(confdir) self._write_config(confdir / "config", app_log_dir) context.bootstrap_config_dir(verify_conf) - write_text_file(verify_conf / "config", "# tc0020 verify\n" 'bypass_data_preservation = "true"\n') + write_onedrive_config(verify_conf / "config", "# tc0020 verify\n" 'bypass_data_preservation = "true"\n') stdout_file = case_log_dir / "monitor_stdout.log" stderr_file = case_log_dir / "monitor_stderr.log" diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 8971f2612..00f377747 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -12,7 +12,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file @dataclass @@ -53,7 +53,7 @@ def _write_config( ] if self.RATE_LIMIT: lines.append(f'rate_limit = "{self.RATE_LIMIT}"') - write_text_file(config_path, "\n".join(lines) + "\n") + write_onedrive_config(config_path, "\n".join(lines) + "\n") def _read_text_if_exists(self, path: Path) -> str: if not path.exists(): diff --git a/ci/e2e/testcases/tc0022_local_first_validation.py b/ci/e2e/testcases/tc0022_local_first_validation.py index 928ba6d9a..02404114f 100644 --- a/ci/e2e/testcases/tc0022_local_first_validation.py +++ b/ci/e2e/testcases/tc0022_local_first_validation.py @@ -8,7 +8,7 @@ from framework.manifest import build_manifest, write_manifest from framework.context import E2EContext from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0022LocalFirstValidation(E2ETestCase): @@ -23,7 +23,7 @@ def _write_config(self, config_path: Path, sync_dir: Path, local_first: bool = F ) if local_first: content += 'local_first = "true"\n' - write_text_file(config_path, content) + write_onedrive_config(config_path, content) def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0022" diff --git a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py index 5fa1c0253..00de5b372 100644 --- a/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py +++ b/ci/e2e/testcases/tc0023_bypass_data_preservation_validation.py @@ -7,7 +7,7 @@ from framework.base import E2ETestCase from framework.context import E2EContext from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0023BypassDataPreservationValidation(E2ETestCase): @@ -22,7 +22,7 @@ def _write_config(self, config_path: Path, sync_dir: Path, bypass_data_preservat ) if bypass_data_preservation: content += 'bypass_data_preservation = "true"\n' - write_text_file(config_path, content) + write_onedrive_config(config_path, content) def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0023" diff --git a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py index 0c856b15b..abb79ae33 100644 --- a/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py +++ b/ci/e2e/testcases/tc0024_big_delete_safeguard_validation.py @@ -8,7 +8,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0024BigDeleteSafeguardValidation(E2ETestCase): @@ -27,7 +27,7 @@ def _write_config( f'sync_dir = "{sync_dir}"', f'classify_as_big_delete = "{classify_as_big_delete}"', ] - write_text_file(config_path, "\n".join(config_lines) + "\n") + write_onedrive_config(config_path, "\n".join(config_lines) + "\n") def _run_and_capture( self, diff --git a/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py b/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py index 7de24a7a1..8cbdbbf97 100644 --- a/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py +++ b/ci/e2e/testcases/tc0025_invalid_character_filename_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0025InvalidCharacterFilenameValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0025InvalidCharacterFilenameValidation(E2ETestCase): description = "Validate invalid filename characters are blocked while valid sibling files still synchronise" def _write_config(self, config_path: Path, sync_dir: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "\n".join( [ diff --git a/ci/e2e/testcases/tc0026_reserved_device_name_validation.py b/ci/e2e/testcases/tc0026_reserved_device_name_validation.py index 2d6cebfb5..919db9251 100644 --- a/ci/e2e/testcases/tc0026_reserved_device_name_validation.py +++ b/ci/e2e/testcases/tc0026_reserved_device_name_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0026ReservedDeviceNameValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0026ReservedDeviceNameValidation(E2ETestCase): description = "Validate reserved Windows device names are blocked while valid lookalike names still synchronise" def _write_config(self, config_path: Path, sync_dir: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "\n".join( [ diff --git a/ci/e2e/testcases/tc0027_whitespace_trailing_dot_validation.py b/ci/e2e/testcases/tc0027_whitespace_trailing_dot_validation.py index 4784ab7bc..7190e5fed 100644 --- a/ci/e2e/testcases/tc0027_whitespace_trailing_dot_validation.py +++ b/ci/e2e/testcases/tc0027_whitespace_trailing_dot_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0027WhitespaceTrailingDotValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0027WhitespaceTrailingDotValidation(E2ETestCase): description = "Validate trailing whitespace and trailing dot names are blocked while valid sibling files still synchronise" def _write_config(self, config_path: Path, sync_dir: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "\n".join( [ diff --git a/ci/e2e/testcases/tc0028_control_character_non_utf8_filename_validation.py b/ci/e2e/testcases/tc0028_control_character_non_utf8_filename_validation.py index 56e5fc5ff..41eb87861 100644 --- a/ci/e2e/testcases/tc0028_control_character_non_utf8_filename_validation.py +++ b/ci/e2e/testcases/tc0028_control_character_non_utf8_filename_validation.py @@ -7,7 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0028ControlCharacterNonUtf8FilenameValidation(E2ETestCase): @@ -16,7 +16,7 @@ class TestCase0028ControlCharacterNonUtf8FilenameValidation(E2ETestCase): description = "Validate control character and non-UTF8 filenames are safely skipped without client crash while valid sibling files still synchronise" def _write_config(self, config_path: Path, sync_dir: Path) -> None: - write_text_file( + write_onedrive_config( config_path, "\n".join( [ diff --git a/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py b/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py index 63c8f9c92..de19ce6b4 100644 --- a/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py +++ b/ci/e2e/testcases/tc0029_local_first_upload_only_timestamp_preservation_validation.py @@ -7,7 +7,7 @@ from framework.base import E2ETestCase from framework.context import E2EContext from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file class TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(E2ETestCase): @@ -30,7 +30,7 @@ def _write_config(self, config_path: Path, sync_dir: Path) -> None: 'cleanup_local_files = "false"\n' 'bypass_data_preservation = "false"\n' ) - write_text_file(config_path, content) + write_onedrive_config(config_path, content) def _set_file_mtime(self, path: Path, epoch_seconds: int) -> None: os.utime(path, (epoch_seconds, epoch_seconds)) From 66814e29870216c6dc9e81bfabeef69651304611 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 16:23:46 +1100 Subject: [PATCH 106/245] Add e2e-sharepoint.yaml Add e2e-sharepoint.yaml --- .github/workflows/e2e-sharepoint.yaml | 160 ++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .github/workflows/e2e-sharepoint.yaml diff --git a/.github/workflows/e2e-sharepoint.yaml b/.github/workflows/e2e-sharepoint.yaml new file mode 100644 index 000000000..fc1888997 --- /dev/null +++ b/.github/workflows/e2e-sharepoint.yaml @@ -0,0 +1,160 @@ +name: E2E SharePoint Account Testing + +on: + push: + branches-ignore: + - master + - main + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + e2e_sharepoint: + runs-on: ubuntu-latest + container: fedora:latest + environment: e2e-sharepoint + + steps: + - uses: actions/checkout@v4 + + - name: Install Dependencies + run: | + dnf -y update + dnf -y group install development-tools + dnf -y install python3 ldc libcurl-devel sqlite-devel dbus-devel jq + + - name: Build + local install prefix + run: | + ./configure --prefix="$PWD/.ci/prefix" + make -j"$(nproc)" + make install + "$PWD/.ci/prefix/bin/onedrive" --version + + - name: Prepare isolated HOME + run: | + set -euo pipefail + export HOME="$RUNNER_TEMP/home-sharepoint" + echo "HOME=$HOME" >> "$GITHUB_ENV" + echo "XDG_CONFIG_HOME=$HOME/.config" >> "$GITHUB_ENV" + echo "XDG_CACHE_HOME=$HOME/.cache" >> "$GITHUB_ENV" + mkdir -p "$HOME" + + - name: Inject refresh token and SharePoint drive_id into onedrive config + env: + REFRESH_TOKEN_BUSINESS: ${{ secrets.REFRESH_TOKEN_BUSINESS }} + SHAREPOINT_DRIVEID: ${{ secrets.SHAREPOINT_DRIVEID }} + run: | + set -euo pipefail + mkdir -p "$XDG_CONFIG_HOME/onedrive" + umask 077 + printf "%s" "$REFRESH_TOKEN_BUSINESS" > "$XDG_CONFIG_HOME/onedrive/refresh_token" + chmod 600 "$XDG_CONFIG_HOME/onedrive/refresh_token" + printf 'drive_id = "%s"\n' "$SHAREPOINT_DRIVEID" > "$XDG_CONFIG_HOME/onedrive/config.sharepoint" + chmod 600 "$XDG_CONFIG_HOME/onedrive/config.sharepoint" + + - name: Run E2E harness + env: + ONEDRIVE_BIN: ${{ github.workspace }}/.ci/prefix/bin/onedrive + E2E_TARGET: sharepoint + RUN_ID: ${{ github.run_id }} + PYTHONUNBUFFERED: "1" + run: | + python3 -u ci/e2e/run.py + + - name: Upload E2E artefacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-sharepoint + path: ci/e2e/out/** + + pr_comment: + name: Post PR summary comment + needs: [ e2e_sharepoint ] + runs-on: ubuntu-latest + if: always() + + steps: + - uses: actions/checkout@v4 + + - name: Download artefact + uses: actions/download-artifact@v4 + with: + name: e2e-sharepoint + path: artifacts/e2e-sharepoint + + - name: Build markdown summary + id: summary + run: | + set -euo pipefail + + f="$(find artifacts/e2e-sharepoint -name results.json -type f | head -n 1 || true)" + if [ -z "$f" ] || [ ! -f "$f" ]; then + echo "md=⚠️ E2E ran but results.json was not found." >> "$GITHUB_OUTPUT" + exit 0 + fi + + target=$(jq -r '.target // "sharepoint"' "$f") + total=$(jq -r '.cases | length' "$f") + passed=$(jq -r '[.cases[] | select(.status=="pass")] | length' "$f") + failed=$(jq -r '[.cases[] | select(.status=="fail")] | length' "$f") + + failures=$(jq -r '.cases[] + | select(.status=="fail") + | "- Test Case \(.id // "????"): \(.name) — \(.reason // "no reason provided")"' "$f" || true) + + md="## ${target^} Account Testing\n" + md+="**${total}** Test Cases Run \n" + md+="**${passed}** Test Cases Passed \n" + md+="**${failed}** Test Cases Failed \n\n" + + if [ "$failed" -gt 0 ] && [ -n "$failures" ]; then + md+="### ${target^} Account Test Failures\n" + md+="$failures\n" + fi + + echo "md<> "$GITHUB_OUTPUT" + echo -e "$md" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Find PR associated with this commit + id: pr + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const sha = context.sha; + + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, repo, commit_sha: sha + }); + + if (!prs.data.length) { + core.setOutput("found", "false"); + return; + } + + core.setOutput("found", "true"); + core.setOutput("number", String(prs.data[0].number)); + + - name: Post PR comment + if: steps.pr.outputs.found == 'true' + uses: actions/github-script@v7 + env: + COMMENT_MD: ${{ steps.summary.outputs.md }} + with: + script: | + const { owner, repo } = context.repo; + const issue_number = Number("${{ steps.pr.outputs.number }}"); + + const md = process.env.COMMENT_MD || "⚠️ No summary text produced."; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: md + }); From 345acb3c229b397a0f6885fdf86cb60a5e074ba7 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 17:13:54 +1100 Subject: [PATCH 107/245] Update PR post SharePoint addition * Update personal and business yaml files to unbuffered output * Add SharePoint badges * Update E2E Testing Document on account coverage * Update tc0001 to support 'drive_id' injection --- .github/workflows/e2e-business.yaml | 3 +- .github/workflows/e2e-personal.yaml | 3 +- ci/e2e/testcases/tc0001_basic_resync.py | 34 +++++++++++-- docs/end_to_end_testing.md | 67 ++++++++++++++----------- readme.md | 1 + 5 files changed, 72 insertions(+), 36 deletions(-) diff --git a/.github/workflows/e2e-business.yaml b/.github/workflows/e2e-business.yaml index bcd3cf51c..e56e57dba 100644 --- a/.github/workflows/e2e-business.yaml +++ b/.github/workflows/e2e-business.yaml @@ -57,8 +57,9 @@ jobs: ONEDRIVE_BIN: ${{ github.workspace }}/.ci/prefix/bin/onedrive E2E_TARGET: business RUN_ID: ${{ github.run_id }} + PYTHONUNBUFFERED: "1" run: | - python3 ci/e2e/run.py + python3 -u ci/e2e/run.py - name: Upload E2E artefacts if: always() diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 1e3a4318b..a7493c7ff 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -57,8 +57,9 @@ jobs: ONEDRIVE_BIN: ${{ github.workspace }}/.ci/prefix/bin/onedrive E2E_TARGET: personal RUN_ID: ${{ github.run_id }} + PYTHONUNBUFFERED: "1" run: | - python3 ci/e2e/run.py + python3 -u ci/e2e/run.py - name: Upload E2E artefacts if: always() diff --git a/ci/e2e/testcases/tc0001_basic_resync.py b/ci/e2e/testcases/tc0001_basic_resync.py index 8e149c643..5328c17e4 100644 --- a/ci/e2e/testcases/tc0001_basic_resync.py +++ b/ci/e2e/testcases/tc0001_basic_resync.py @@ -1,11 +1,15 @@ from __future__ import annotations -from pathlib import Path - from framework.base import E2ETestCase from framework.context import E2EContext from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file +from framework.utils import ( + command_to_string, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) class TestCase0001BasicResync(E2ETestCase): @@ -23,27 +27,43 @@ class TestCase0001BasicResync(E2ETestCase): description = "Run a basic --sync --resync --resync-auth operation and capture the outcome" def run(self, context: E2EContext) -> TestResult: - case_work_dir = context.work_root / f"tc{self.case_id}" case_log_dir = context.logs_dir / f"tc{self.case_id}" state_dir = context.state_dir / f"tc{self.case_id}" + sync_root = case_work_dir / "syncroot" + conf_dir = case_work_dir / "conf-main" + reset_directory(case_work_dir) reset_directory(case_log_dir) reset_directory(state_dir) - + reset_directory(sync_root) + reset_directory(conf_dir) + context.ensure_refresh_token_available() stdout_file = case_log_dir / "stdout.log" stderr_file = case_log_dir / "stderr.log" metadata_file = state_dir / "metadata.txt" + # Build a per-test config so that any optional base config, including + # SharePoint-specific drive_id data sourced from config.sharepoint, + # is materialised into the runtime config used by this testcase. + write_onedrive_config( + conf_dir, + sync_dir=sync_root, + ) + command = [ context.onedrive_bin, "--sync", "--verbose", "--resync", "--resync-auth", + "--syncdir", + str(sync_root), + "--confdir", + str(conf_dir), ] context.log( @@ -60,6 +80,8 @@ def run(self, context: E2EContext) -> TestResult: f"name={self.name}", f"command={command_to_string(command)}", f"returncode={result.returncode}", + f"sync_root={sync_root}", + f"conf_dir={conf_dir}", ] write_text_file(metadata_file, "\n".join(metadata_lines) + "\n") @@ -72,6 +94,8 @@ def run(self, context: E2EContext) -> TestResult: details = { "command": command, "returncode": result.returncode, + "sync_root": str(sync_root), + "conf_dir": str(conf_dir), } if result.returncode != 0: diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index bf39c47e4..e4f7de26a 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -2,6 +2,7 @@ [![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![e2e Testing SharePoint](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) This document describes the **end-to-end (E2E) automated testing framework** used to validate the behaviour of the OneDrive Client for Linux. @@ -9,35 +10,43 @@ The test suite runs inside **GitHub Actions** and performs **real synchronisatio The objective of the test framework is to ensure that all client functionality continues to operate correctly across future changes to the application. +### Personal and Business Account Testing +Personal and Business end-to-end testing executes the full automated test suite against the default drive associated with the authenticated account, without explicitly specifying a `drive_id` in the client configuration. In this mode, the Microsoft Graph API implicitly resolves the user’s primary drive (personal OneDrive or business OneDrive root) and all drive enumeration, metadata retrieval, path traversal, upload, download, delete, and synchronisation operations are performed within that default context. As a result, the same comprehensive set of test cases validates client behaviour, configuration handling, and synchronisation correctness using the standard account-bound drive, ensuring expected operation without any explicit drive targeting or override. + +### SharePoint Account Testing +SharePoint end-to-end testing uses the same complete automated test suite as Personal OneDrive and OneDrive for Business, but changes the target drive by supplying a `drive_id` value in the client configuration. This means the harness is not merely repeating the same account-type tests against the same default drive context; it is explicitly forcing the client to enumerate, access, and operate against a different Microsoft Graph drive object, resulting in different drive discovery, metadata enumeration, path traversal, upload, download, delete, and synchronisation API activity. By injecting `drive_id` into the test configuration, every existing test case is executed against a SharePoint document library context rather than a personal or business default OneDrive root, ensuring that the full suite validates client behaviour, configuration handling, and synchronisation correctness when operating against SharePoint-backed storage. + + +### Test Case Details | Test Case | Description | Account Coverage | Test Details | |:-------|:-------------|:-------|:-------| -| 0001 | Basic Resync | - Personal
- Business | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | -| 0002 | 'sync_list' Validation | - Personal
- Business | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| -| 0003 | Dry-run safety validation | - Personal
- Business | This test validates that running the client with `--dry-run` performs a full synchronisation analysis without making any changes locally or remotely. Files and directories are created in the test environment, the client is executed with `--dry-run`, and the test verifies that no filesystem or remote changes occur.| -| 0004 | Single-directory sync scope validation | - Personal
- Business | This test validates that the `--single-directory` option restricts synchronisation to the specified directory subtree. Only the nominated directory should be synchronised, while other directories outside the scope must remain untouched. | -| 0005 | Force-sync override validation | - Personal
- Business | This test validates that the `--force-sync` option overrides blocking conditions that would normally prevent synchronisation of a single-directory scope. The test confirms that forced synchronisation proceeds even when skip rules would otherwise block the operation. | -| 0006 | Download-only sync validation | - Personal
- Business | This test validates the behaviour of the `--download-only` option. Remote content is seeded in OneDrive and the client is executed in download-only mode. The test verifies that the remote data is correctly downloaded locally without performing any upload operations. | -| 0007 | Download-only cleanup validation | - Personal
- Business | This test validates the behaviour of `--cleanup-local-files` when used with `--download-only`. Local files that no longer exist remotely should be removed during synchronisation while valid remote content is preserved. | -| 0008 | Upload-only sync validation | - Personal
- Business | This test validates that `--upload-only` mode correctly uploads local files and directories to OneDrive. The test ensures that no download operations occur and that remote content reflects the locally created files. | -| 0009 | Upload-only remote preservation validation | - Personal
- Business | This test validates the behaviour of the `--no-remote-delete` option when used with `--upload-only` mode. The test confirms that remote files are not deleted even when they do not exist locally. | -| 0010 | Upload-only source removal validation | - Personal
- Business | This test validates the `--remove-source-files` option used with `--upload-only` mode. After files are successfully uploaded to OneDrive, the local source files should be automatically removed. | -| 0011 | Skip-file pattern validation | - Personal
- Business | This test validates that `skip_file` configuration patterns correctly exclude matching files from synchronisation. Files matching the configured patterns should not be uploaded or downloaded. | -| 0012 | Skip-directory rule validation | - Personal
- Business | This test validates the behaviour of the 'skip_dir' option and the 'skip_dir_strict_match' setting. The test confirms that directories matching the configured patterns are correctly excluded from synchronisation. | -| 0013 | Dotfile exclusion validation | - Personal
- Business | This test validates the 'skip_dotfiles' option. Files and directories beginning with a dot (`.`) should be excluded from synchronisation when this option is enabled. | -| 0014 | File size exclusion validation | - Personal
- Business | This test validates the 'skip_size' option. Files exceeding the configured size threshold should be excluded from synchronisation.| -| 0015 | Symlink exclusion validation | - Personal
- Business | This test validates the 'skip_symlinks' configuration option. Symbolic links present in the local filesystem should not be synchronised when this option is enabled. | -| 0016 | `.nosync` directory exclusion validation | - Personal
- Business | This test validates the 'check_nosync' feature. Directories containing a `.nosync` marker file should be excluded from synchronisation. | -| 0017 | `.nosync` mount protection validation | - Personal
- Business | This test validates the 'check_nomount' safeguard. When a `.nosync` marker exists in the mount point of the synchronisation directory, the client should abort synchronisation to prevent unintended operations.| -| 0018 | Recycle bin integration validation | - Personal
- Business | This test validates integration with the FreeDesktop-compliant recycle bin. When files are deleted remotely, the client should move them into the configured recycle bin instead of permanently deleting them when the feature is enabled.| -| 0019 | Logging and runtime configuration validation | - Personal
- Business | This test validates that the client correctly writes logs to a configured 'log_dir' and that `--display-running-config` outputs the effective runtime configuration. | -| 0020 | Monitor mode real-time sync validation | - Personal
- Business | This test validates that when the client runs in `--monitor mode`, filesystem changes made while the process is running are automatically detected and synchronised without manually re-running the client. | -| 0021 | Resumable upload recovery validation | - Personal
- Business | This test validates resumable upload session behaviour. A large file upload is intentionally interrupted and then resumed during a subsequent client execution. The test confirms that the upload successfully completes. | -| 0022 | Local-first conflict resolution validation | - Personal
- Business | This test validates the 'local_first' configuration option. When a file conflict occurs between local and remote versions, the client should treat the local file as the authoritative source and update the remote version accordingly. | -| 0023 | Bypass data preservation validation | - Personal
- Business | This test validates the behaviour of the 'bypass_data_preservation' option. When enabled, the client should suppress the creation of safe-backup files during conflict resolution and allow remote content to overwrite local changes. | -| 0024 | Big delete safeguard validation | - Personal
- Business | This test validates the 'classify_as_big_delete' protection mechanism. When a large number of items are deleted locally, the client should halt synchronisation and emit a warning. The deletion should only proceed after the user explicitly acknowledges the action using `--force`. | -| 0025 | Invalid character filename validation | - Personal
- Business | This test validates that invalid filename characters are blocked while valid sibling files still synchronise | -| 0026 | Reserved device name validation | - Personal
- Business | This test validates that reserved Windows device names are blocked while valid lookalike names still synchronise | -| 0027 | Whitespace and trailing dot validation | - Personal
- Business | This test validates that trailing whitespace and trailing dot names are blocked while valid sibling files still synchronise | -| 0028 | Control character and non-UTF8 filename validation | - Personal
- Business | This test validates that control characters and non-UTF8 filenames are safely skipped without client crash while valid sibling files still synchronise | -| 0029 | Upload-only + Local First sync validation | - Personal
- Business | This test validates that `--local-first --upload-only` uploads local content without rewriting local file timestamps from Microsoft API response data | +| 0001 | Basic Resync | - Personal
- Business
- SharePoint | - validate that the E2E framework can invoke the client
- validate that the configured environment is sufficient to run a basic sync
- provide a simple baseline smoke test before more advanced E2E scenarios | +| 0002 | 'sync_list' Validation | - Personal
- Business
- SharePoint | This validates sync_list as a policy-conformance test.

The test is considered successful when all observed sync operations involving the fixture tree match the active sync_list rules.

This test covers exclusions, inclusions, wildcard and globbing for paths and files. Specific 'sync_list' test coverage is as follows:
- Scenario SL-0001: root directory include with trailing slash
- Scenario SL-0002: root include without trailing slash
- Scenario SL-0003: non-root include by name
- Scenario SL-0004: include tree with nested exclusion
- Scenario SL-0005: included tree with hidden directory excluded
- Scenario SL-0006: file-specific include inside named directory
- Scenario SL-0007: rooted include of Programming tree
- Scenario SL-0008: exclude Android recursive build output and include Programming
- Scenario SL-0009: exclude Android recursive .cxx content and include Programming
- Scenario SL-0010: exclude Web recursive build output and include Programming
- Scenario SL-0011: exclude .gradle anywhere and include Programming
- Scenario SL-0012: exclude build/kotlin anywhere and include Programming
- Scenario SL-0013: exclude .venv and venv anywhere and include Programming
- Scenario SL-0014: exclude common cache and vendor directories and include Programming
- Scenario SL-0015: complex style Programming ruleset
- Scenario SL-0016: massive mixed rule set across Programming Documents and Work
- Scenario SL-0017: stress test kitchen sink rule set with broad include and targeted file include
- Scenario SL-0018: exact trailing slash configuration with cleanup validation
- Scenario SL-0019: no trailing slash workaround with cleanup validation
- Scenario SL-0020: focused trailing slash Projects regression for sibling path survival
- Scenario SL-0021: focused no trailing slash Projects regression for sibling path survival
- Scenario SL-0022: exact root-file include
- Scenario SL-0023: sync_root_files = true with rooted 'Projects' include
- Scenario SL-0024: cleanup regression with 'sync_root_files = true'
- Scenario SL-0025: prefix-collision safety for 'Projects/Code'
- Scenario SL-0026: mixed rooted subtree include plus exact root-file include
| +| 0003 | Dry-run safety validation | - Personal
- Business
- SharePoint | This test validates that running the client with `--dry-run` performs a full synchronisation analysis without making any changes locally or remotely. Files and directories are created in the test environment, the client is executed with `--dry-run`, and the test verifies that no filesystem or remote changes occur.| +| 0004 | Single-directory sync scope validation | - Personal
- Business
- SharePoint | This test validates that the `--single-directory` option restricts synchronisation to the specified directory subtree. Only the nominated directory should be synchronised, while other directories outside the scope must remain untouched. | +| 0005 | Force-sync override validation | - Personal
- Business
- SharePoint | This test validates that the `--force-sync` option overrides blocking conditions that would normally prevent synchronisation of a single-directory scope. The test confirms that forced synchronisation proceeds even when skip rules would otherwise block the operation. | +| 0006 | Download-only sync validation | - Personal
- Business
- SharePoint | This test validates the behaviour of the `--download-only` option. Remote content is seeded in OneDrive and the client is executed in download-only mode. The test verifies that the remote data is correctly downloaded locally without performing any upload operations. | +| 0007 | Download-only cleanup validation | - Personal
- Business
- SharePoint | This test validates the behaviour of `--cleanup-local-files` when used with `--download-only`. Local files that no longer exist remotely should be removed during synchronisation while valid remote content is preserved. | +| 0008 | Upload-only sync validation | - Personal
- Business
- SharePoint | This test validates that `--upload-only` mode correctly uploads local files and directories to OneDrive. The test ensures that no download operations occur and that remote content reflects the locally created files. | +| 0009 | Upload-only remote preservation validation | - Personal
- Business
- SharePoint | This test validates the behaviour of the `--no-remote-delete` option when used with `--upload-only` mode. The test confirms that remote files are not deleted even when they do not exist locally. | +| 0010 | Upload-only source removal validation | - Personal
- Business
- SharePoint | This test validates the `--remove-source-files` option used with `--upload-only` mode. After files are successfully uploaded to OneDrive, the local source files should be automatically removed. | +| 0011 | Skip-file pattern validation | - Personal
- Business
- SharePoint | This test validates that `skip_file` configuration patterns correctly exclude matching files from synchronisation. Files matching the configured patterns should not be uploaded or downloaded. | +| 0012 | Skip-directory rule validation | - Personal
- Business
- SharePoint | This test validates the behaviour of the 'skip_dir' option and the 'skip_dir_strict_match' setting. The test confirms that directories matching the configured patterns are correctly excluded from synchronisation. | +| 0013 | Dotfile exclusion validation | - Personal
- Business
- SharePoint | This test validates the 'skip_dotfiles' option. Files and directories beginning with a dot (`.`) should be excluded from synchronisation when this option is enabled. | +| 0014 | File size exclusion validation | - Personal
- Business
- SharePoint | This test validates the 'skip_size' option. Files exceeding the configured size threshold should be excluded from synchronisation.| +| 0015 | Symlink exclusion validation | - Personal
- Business
- SharePoint | This test validates the 'skip_symlinks' configuration option. Symbolic links present in the local filesystem should not be synchronised when this option is enabled. | +| 0016 | `.nosync` directory exclusion validation | - Personal
- Business
- SharePoint | This test validates the 'check_nosync' feature. Directories containing a `.nosync` marker file should be excluded from synchronisation. | +| 0017 | `.nosync` mount protection validation | - Personal
- Business
- SharePoint | This test validates the 'check_nomount' safeguard. When a `.nosync` marker exists in the mount point of the synchronisation directory, the client should abort synchronisation to prevent unintended operations.| +| 0018 | Recycle bin integration validation | - Personal
- Business
- SharePoint | This test validates integration with the FreeDesktop-compliant recycle bin. When files are deleted remotely, the client should move them into the configured recycle bin instead of permanently deleting them when the feature is enabled.| +| 0019 | Logging and runtime configuration validation | - Personal
- Business
- SharePoint | This test validates that the client correctly writes logs to a configured 'log_dir' and that `--display-running-config` outputs the effective runtime configuration. | +| 0020 | Monitor mode real-time sync validation | - Personal
- Business
- SharePoint | This test validates that when the client runs in `--monitor mode`, filesystem changes made while the process is running are automatically detected and synchronised without manually re-running the client. | +| 0021 | Resumable upload recovery validation | - Personal
- Business
- SharePoint | This test validates resumable upload session behaviour. A large file upload is intentionally interrupted and then resumed during a subsequent client execution. The test confirms that the upload successfully completes. | +| 0022 | Local-first conflict resolution validation | - Personal
- Business
- SharePoint | This test validates the 'local_first' configuration option. When a file conflict occurs between local and remote versions, the client should treat the local file as the authoritative source and update the remote version accordingly. | +| 0023 | Bypass data preservation validation | - Personal
- Business
- SharePoint | This test validates the behaviour of the 'bypass_data_preservation' option. When enabled, the client should suppress the creation of safe-backup files during conflict resolution and allow remote content to overwrite local changes. | +| 0024 | Big delete safeguard validation | - Personal
- Business
- SharePoint | This test validates the 'classify_as_big_delete' protection mechanism. When a large number of items are deleted locally, the client should halt synchronisation and emit a warning. The deletion should only proceed after the user explicitly acknowledges the action using `--force`. | +| 0025 | Invalid character filename validation | - Personal
- Business
- SharePoint | This test validates that invalid filename characters are blocked while valid sibling files still synchronise | +| 0026 | Reserved device name validation | - Personal
- Business
- SharePoint | This test validates that reserved Windows device names are blocked while valid lookalike names still synchronise | +| 0027 | Whitespace and trailing dot validation | - Personal
- Business
- SharePoint | This test validates that trailing whitespace and trailing dot names are blocked while valid sibling files still synchronise | +| 0028 | Control character and non-UTF8 filename validation | - Personal
- Business
- SharePoint | This test validates that control characters and non-UTF8 filenames are safely skipped without client crash while valid sibling files still synchronise | +| 0029 | Upload-only + Local First sync validation | - Personal
- Business
- SharePoint | This test validates that `--local-first --upload-only` uploads local content without rewriting local file timestamps from Microsoft API response data | diff --git a/readme.md b/readme.md index 7e8487dec..72546e12c 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,7 @@ [![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) [![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![e2e Testing SharePoint](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![Build Docker Images](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/driveone/onedrive)](https://hub.docker.com/r/driveone/onedrive) From 9efe13b65d275d477bf5cc97daa0d09dbadfad81 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 17:39:48 +1100 Subject: [PATCH 108/245] Update tc0001 Update tc0001 to focus on ensuring 'drive_id' compatibility --- ci/e2e/run.py | 56 ++++++++++++------------- ci/e2e/testcases/tc0001_basic_resync.py | 8 ++-- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 77a186f4e..c3139da35 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -48,34 +48,34 @@ def build_test_suite() -> list: """ return [ TestCase0001BasicResync(), - TestCase0002SyncListValidation(), - TestCase0003DryRunValidation(), - TestCase0004SingleDirectorySync(), - TestCase0005ForceSyncOverride(), - TestCase0006DownloadOnly(), - TestCase0007DownloadOnlyCleanupLocalFiles(), - TestCase0008UploadOnly(), - TestCase0009UploadOnlyNoRemoteDelete(), - TestCase0010UploadOnlyRemoveSourceFiles(), - TestCase0011SkipFileValidation(), - TestCase0012SkipDirValidation(), - TestCase0013SkipDotfilesValidation(), - TestCase0014SkipSizeValidation(), - TestCase0015SkipSymlinksValidation(), - TestCase0016CheckNosyncValidation(), - TestCase0017CheckNomountValidation(), - TestCase0018RecycleBinValidation(), - TestCase0019LoggingAndRunningConfig(), - TestCase0020MonitorModeValidation(), - TestCase0021ResumableTransfersValidation(), - TestCase0022LocalFirstValidation(), - TestCase0023BypassDataPreservationValidation(), - TestCase0024BigDeleteSafeguardValidation(), - TestCase0025InvalidCharacterFilenameValidation(), - TestCase0026ReservedDeviceNameValidation(), - TestCase0027WhitespaceTrailingDotValidation(), - TestCase0028ControlCharacterNonUtf8FilenameValidation(), - TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + #TestCase0002SyncListValidation(), + #TestCase0003DryRunValidation(), + #TestCase0004SingleDirectorySync(), + #TestCase0005ForceSyncOverride(), + #TestCase0006DownloadOnly(), + #TestCase0007DownloadOnlyCleanupLocalFiles(), + #TestCase0008UploadOnly(), + #TestCase0009UploadOnlyNoRemoteDelete(), + #TestCase0010UploadOnlyRemoveSourceFiles(), + #TestCase0011SkipFileValidation(), + #TestCase0012SkipDirValidation(), + #TestCase0013SkipDotfilesValidation(), + #TestCase0014SkipSizeValidation(), + #TestCase0015SkipSymlinksValidation(), + #TestCase0016CheckNosyncValidation(), + #TestCase0017CheckNomountValidation(), + #TestCase0018RecycleBinValidation(), + #TestCase0019LoggingAndRunningConfig(), + #TestCase0020MonitorModeValidation(), + #TestCase0021ResumableTransfersValidation(), + #TestCase0022LocalFirstValidation(), + #TestCase0023BypassDataPreservationValidation(), + #TestCase0024BigDeleteSafeguardValidation(), + #TestCase0025InvalidCharacterFilenameValidation(), + #TestCase0026ReservedDeviceNameValidation(), + #TestCase0027WhitespaceTrailingDotValidation(), + #TestCase0028ControlCharacterNonUtf8FilenameValidation(), + #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), ] diff --git a/ci/e2e/testcases/tc0001_basic_resync.py b/ci/e2e/testcases/tc0001_basic_resync.py index 5328c17e4..0a171b422 100644 --- a/ci/e2e/testcases/tc0001_basic_resync.py +++ b/ci/e2e/testcases/tc0001_basic_resync.py @@ -41,17 +41,15 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(conf_dir) context.ensure_refresh_token_available() + context.bootstrap_config_dir(conf_dir) stdout_file = case_log_dir / "stdout.log" stderr_file = case_log_dir / "stderr.log" metadata_file = state_dir / "metadata.txt" - # Build a per-test config so that any optional base config, including - # SharePoint-specific drive_id data sourced from config.sharepoint, - # is materialised into the runtime config used by this testcase. write_onedrive_config( - conf_dir, - sync_dir=sync_root, + conf_dir / "config", + "# tc0001 config\n", ) command = [ From 1753a3752e594995152a52dbdd8a90ba66301442 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 23 Mar 2026 17:46:09 +1100 Subject: [PATCH 109/245] Update PR Update to run all test cases post tc0001 fix for SharePoint --- ci/e2e/run.py | 56 +++++++++++++++++++++++++-------------------------- readme.md | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index c3139da35..77a186f4e 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -48,34 +48,34 @@ def build_test_suite() -> list: """ return [ TestCase0001BasicResync(), - #TestCase0002SyncListValidation(), - #TestCase0003DryRunValidation(), - #TestCase0004SingleDirectorySync(), - #TestCase0005ForceSyncOverride(), - #TestCase0006DownloadOnly(), - #TestCase0007DownloadOnlyCleanupLocalFiles(), - #TestCase0008UploadOnly(), - #TestCase0009UploadOnlyNoRemoteDelete(), - #TestCase0010UploadOnlyRemoveSourceFiles(), - #TestCase0011SkipFileValidation(), - #TestCase0012SkipDirValidation(), - #TestCase0013SkipDotfilesValidation(), - #TestCase0014SkipSizeValidation(), - #TestCase0015SkipSymlinksValidation(), - #TestCase0016CheckNosyncValidation(), - #TestCase0017CheckNomountValidation(), - #TestCase0018RecycleBinValidation(), - #TestCase0019LoggingAndRunningConfig(), - #TestCase0020MonitorModeValidation(), - #TestCase0021ResumableTransfersValidation(), - #TestCase0022LocalFirstValidation(), - #TestCase0023BypassDataPreservationValidation(), - #TestCase0024BigDeleteSafeguardValidation(), - #TestCase0025InvalidCharacterFilenameValidation(), - #TestCase0026ReservedDeviceNameValidation(), - #TestCase0027WhitespaceTrailingDotValidation(), - #TestCase0028ControlCharacterNonUtf8FilenameValidation(), - #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + TestCase0002SyncListValidation(), + TestCase0003DryRunValidation(), + TestCase0004SingleDirectorySync(), + TestCase0005ForceSyncOverride(), + TestCase0006DownloadOnly(), + TestCase0007DownloadOnlyCleanupLocalFiles(), + TestCase0008UploadOnly(), + TestCase0009UploadOnlyNoRemoteDelete(), + TestCase0010UploadOnlyRemoveSourceFiles(), + TestCase0011SkipFileValidation(), + TestCase0012SkipDirValidation(), + TestCase0013SkipDotfilesValidation(), + TestCase0014SkipSizeValidation(), + TestCase0015SkipSymlinksValidation(), + TestCase0016CheckNosyncValidation(), + TestCase0017CheckNomountValidation(), + TestCase0018RecycleBinValidation(), + TestCase0019LoggingAndRunningConfig(), + TestCase0020MonitorModeValidation(), + TestCase0021ResumableTransfersValidation(), + TestCase0022LocalFirstValidation(), + TestCase0023BypassDataPreservationValidation(), + TestCase0024BigDeleteSafeguardValidation(), + TestCase0025InvalidCharacterFilenameValidation(), + TestCase0026ReservedDeviceNameValidation(), + TestCase0027WhitespaceTrailingDotValidation(), + TestCase0028ControlCharacterNonUtf8FilenameValidation(), + TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), ] diff --git a/readme.md b/readme.md index 72546e12c..cce00713b 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,8 @@ # OneDrive Client for Linux [![Version](https://img.shields.io/github/v/release/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) [![Release Date](https://img.shields.io/github/release-date/abraunegg/onedrive)](https://github.com/abraunegg/onedrive/releases) - [![Test Build](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/testbuild.yaml) + [![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![e2e Testing SharePoint](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) From ad73ae0d408c8f3247f3f70e2c51bc6ea31d977a Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 24 Mar 2026 05:22:47 +1100 Subject: [PATCH 110/245] Add tc0030 and tc0031 * Add tc0030 and tc0031 --- ci/e2e/run.py | 63 ++-- ...030_local_rename_propagation_validation.py | 265 +++++++++++++++ ...directory_rename_propagation_validation.py | 308 ++++++++++++++++++ 3 files changed, 606 insertions(+), 30 deletions(-) create mode 100644 ci/e2e/testcases/tc0030_local_rename_propagation_validation.py create mode 100644 ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 77a186f4e..fb53463d3 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -38,7 +38,8 @@ from testcases.tc0027_whitespace_trailing_dot_validation import TestCase0027WhitespaceTrailingDotValidation from testcases.tc0028_control_character_non_utf8_filename_validation import TestCase0028ControlCharacterNonUtf8FilenameValidation from testcases.tc0029_local_first_upload_only_timestamp_preservation_validation import TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation - +from testcases.tc0030_local_rename_propagation_validation import TestCase0030LocalRenamePropagationValidation +from testcases.tc0031_local_directory_rename_propagation_validation import TestCase0031LocalDirectoryRenamePropagationValidation def build_test_suite() -> list: """ @@ -47,35 +48,37 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - TestCase0001BasicResync(), - TestCase0002SyncListValidation(), - TestCase0003DryRunValidation(), - TestCase0004SingleDirectorySync(), - TestCase0005ForceSyncOverride(), - TestCase0006DownloadOnly(), - TestCase0007DownloadOnlyCleanupLocalFiles(), - TestCase0008UploadOnly(), - TestCase0009UploadOnlyNoRemoteDelete(), - TestCase0010UploadOnlyRemoveSourceFiles(), - TestCase0011SkipFileValidation(), - TestCase0012SkipDirValidation(), - TestCase0013SkipDotfilesValidation(), - TestCase0014SkipSizeValidation(), - TestCase0015SkipSymlinksValidation(), - TestCase0016CheckNosyncValidation(), - TestCase0017CheckNomountValidation(), - TestCase0018RecycleBinValidation(), - TestCase0019LoggingAndRunningConfig(), - TestCase0020MonitorModeValidation(), - TestCase0021ResumableTransfersValidation(), - TestCase0022LocalFirstValidation(), - TestCase0023BypassDataPreservationValidation(), - TestCase0024BigDeleteSafeguardValidation(), - TestCase0025InvalidCharacterFilenameValidation(), - TestCase0026ReservedDeviceNameValidation(), - TestCase0027WhitespaceTrailingDotValidation(), - TestCase0028ControlCharacterNonUtf8FilenameValidation(), - TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + #TestCase0001BasicResync(), + #TestCase0002SyncListValidation(), + #TestCase0003DryRunValidation(), + #TestCase0004SingleDirectorySync(), + #TestCase0005ForceSyncOverride(), + #TestCase0006DownloadOnly(), + #TestCase0007DownloadOnlyCleanupLocalFiles(), + #TestCase0008UploadOnly(), + #TestCase0009UploadOnlyNoRemoteDelete(), + #TestCase0010UploadOnlyRemoveSourceFiles(), + #TestCase0011SkipFileValidation(), + #TestCase0012SkipDirValidation(), + #TestCase0013SkipDotfilesValidation(), + #TestCase0014SkipSizeValidation(), + #TestCase0015SkipSymlinksValidation(), + #TestCase0016CheckNosyncValidation(), + #TestCase0017CheckNomountValidation(), + #TestCase0018RecycleBinValidation(), + #TestCase0019LoggingAndRunningConfig(), + #TestCase0020MonitorModeValidation(), + #TestCase0021ResumableTransfersValidation(), + #TestCase0022LocalFirstValidation(), + #TestCase0023BypassDataPreservationValidation(), + #TestCase0024BigDeleteSafeguardValidation(), + #TestCase0025InvalidCharacterFilenameValidation(), + #TestCase0026ReservedDeviceNameValidation(), + #TestCase0027WhitespaceTrailingDotValidation(), + #TestCase0028ControlCharacterNonUtf8FilenameValidation(), + #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + TestCase0030LocalRenamePropagationValidation(), + TestCase0031LocalDirectoryRenamePropagationValidation(), ] diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py new file mode 100644 index 000000000..6d5dd749a --- /dev/null +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file + + +class TestCase0030LocalRenamePropagationValidation(E2ETestCase): + case_id = "0030" + name = "local rename propagation validation" + description = "Validate that renaming a local file is correctly propagated to remote state" + + def _write_config(self, config_path: Path) -> None: + write_onedrive_config( + config_path, + ( + "# tc0030 config\n" + 'bypass_data_preservation = "true"\n' + ), + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0030" + case_log_dir = context.logs_dir / "tc0030" + state_dir = context.state_dir / "tc0030" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + local_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + conf_main = case_work_dir / "conf-main" + conf_verify = case_work_dir / "conf-verify" + + reset_directory(local_root) + reset_directory(verify_root) + + context.bootstrap_config_dir(conf_main) + context.bootstrap_config_dir(conf_verify) + + self._write_config(conf_main / "config") + self._write_config(conf_verify / "config") + + root_name = f"ZZ_E2E_TC0030_{context.run_id}_{os.getpid()}" + old_relative = f"{root_name}/original-name.txt" + new_relative = f"{root_name}/renamed-file.txt" + + old_local_path = local_root / old_relative + new_local_path = local_root / new_relative + + initial_content = ( + "TC0030 local rename propagation validation\n" + "This content must survive the rename operation unchanged.\n" + ) + + phase1_stdout = case_log_dir / "phase1_seed_stdout.log" + phase1_stderr = case_log_dir / "phase1_seed_stderr.log" + phase2_stdout = case_log_dir / "phase2_rename_stdout.log" + phase2_stderr = case_log_dir / "phase2_rename_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(verify_stdout), + str(verify_stderr), + str(verify_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "old_relative": old_relative, + "new_relative": new_relative, + } + + # Phase 1: seed the remote with the original filename + write_text_file(old_local_path, initial_content) + + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--upload-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(local_root), + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + details["phase1_returncode"] = phase1_result.returncode + + if phase1_result.returncode != 0: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {phase1_result.returncode}", + artifacts, + details, + ) + + # Phase 2: rename the local file and run a normal sync to propagate the new state + old_local_path.rename(new_local_path) + + phase2_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--syncdir", + str(local_root), + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase2: {command_to_string(phase2_command)}") + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + details["phase2_returncode"] = phase2_result.returncode + + if phase2_result.returncode != 0: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + f"rename propagation phase failed with status {phase2_result.returncode}", + artifacts, + details, + ) + + if old_local_path.exists(): + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + "local old filename still exists after rename propagation phase", + artifacts, + details, + ) + + if not new_local_path.is_file(): + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + "local renamed file does not exist after rename propagation phase", + artifacts, + details, + ) + + # Phase 3: clean verify from remote state + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(verify_root), + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + verified_old_path = verify_root / old_relative + verified_new_path = verify_root / new_relative + verified_content = verified_new_path.read_text(encoding="utf-8") if verified_new_path.is_file() else "" + + details["verified_old_exists"] = verified_old_path.exists() + details["verified_new_exists"] = verified_new_path.exists() + details["verified_content"] = verified_content + + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + + if verified_old_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification still contains old filename: {old_relative}", + artifacts, + details, + ) + + if not verified_new_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing renamed file: {new_relative}", + artifacts, + details, + ) + + if verified_content != initial_content: + return TestResult.fail_result( + self.case_id, + self.name, + "renamed file content did not match the original content after remote verification", + artifacts, + details, + ) + + return TestResult.pass_result( + self.case_id, + self.name, + artifacts, + details, + ) \ No newline at end of file diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py new file mode 100644 index 000000000..eb0d67f34 --- /dev/null +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file + + +class TestCase0031LocalDirectoryRenamePropagationValidation(E2ETestCase): + case_id = "0031" + name = "local directory rename propagation validation" + description = "Validate that renaming a local directory tree is correctly propagated to remote state" + + def _write_config(self, config_path: Path) -> None: + write_onedrive_config( + config_path, + ( + "# tc0031 config\n" + 'bypass_data_preservation = "true"\n' + ), + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0031" + case_log_dir = context.logs_dir / "tc0031" + state_dir = context.state_dir / "tc0031" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + local_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + conf_main = case_work_dir / "conf-main" + conf_verify = case_work_dir / "conf-verify" + + reset_directory(local_root) + reset_directory(verify_root) + + context.bootstrap_config_dir(conf_main) + context.bootstrap_config_dir(conf_verify) + + self._write_config(conf_main / "config") + self._write_config(conf_verify / "config") + + root_name = f"ZZ_E2E_TC0031_{context.run_id}_{os.getpid()}" + source_dir_relative = f"{root_name}/SourceDirectory" + renamed_dir_relative = f"{root_name}/RenamedDirectory" + + source_dir = local_root / source_dir_relative + renamed_dir = local_root / renamed_dir_relative + + source_file_1_relative = f"{source_dir_relative}/top-level.txt" + source_file_2_relative = f"{source_dir_relative}/Nested/child.txt" + + renamed_file_1_relative = f"{renamed_dir_relative}/top-level.txt" + renamed_file_2_relative = f"{renamed_dir_relative}/Nested/child.txt" + + source_file_1 = local_root / source_file_1_relative + source_file_2 = local_root / source_file_2_relative + + file1_content = "TC0031 top level file\n" + file2_content = "TC0031 nested child file\n" + + phase1_stdout = case_log_dir / "phase1_seed_stdout.log" + phase1_stderr = case_log_dir / "phase1_seed_stderr.log" + phase2_stdout = case_log_dir / "phase2_directory_rename_stdout.log" + phase2_stderr = case_log_dir / "phase2_directory_rename_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(verify_stdout), + str(verify_stderr), + str(verify_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "source_dir_relative": source_dir_relative, + "renamed_dir_relative": renamed_dir_relative, + } + + # Phase 1: seed the remote with the original directory tree + write_text_file(source_file_1, file1_content) + write_text_file(source_file_2, file2_content) + + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--upload-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(local_root), + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + details["phase1_returncode"] = phase1_result.returncode + + if phase1_result.returncode != 0: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {phase1_result.returncode}", + artifacts, + details, + ) + + # Phase 2: rename the entire local directory tree and propagate + source_dir.rename(renamed_dir) + + phase2_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--syncdir", + str(local_root), + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase2: {command_to_string(phase2_command)}") + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + details["phase2_returncode"] = phase2_result.returncode + + if phase2_result.returncode != 0: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + f"directory rename propagation phase failed with status {phase2_result.returncode}", + artifacts, + details, + ) + + if source_dir.exists(): + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + "local original directory still exists after rename propagation phase", + artifacts, + details, + ) + + if not renamed_dir.is_dir(): + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + return TestResult.fail_result( + self.case_id, + self.name, + "local renamed directory does not exist after rename propagation phase", + artifacts, + details, + ) + + # Phase 3: clean verify from remote state + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--download-only", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--syncdir", + str(verify_root), + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + verify_old_dir = verify_root / source_dir_relative + verify_new_dir = verify_root / renamed_dir_relative + verify_new_file_1 = verify_root / renamed_file_1_relative + verify_new_file_2 = verify_root / renamed_file_2_relative + verify_old_file_1 = verify_root / source_file_1_relative + verify_old_file_2 = verify_root / source_file_2_relative + + details["verify_old_dir_exists"] = verify_old_dir.exists() + details["verify_new_dir_exists"] = verify_new_dir.exists() + details["verify_old_file_1_exists"] = verify_old_file_1.exists() + details["verify_old_file_2_exists"] = verify_old_file_2.exists() + details["verify_new_file_1_exists"] = verify_new_file_1.exists() + details["verify_new_file_2_exists"] = verify_new_file_2.exists() + details["verify_new_file_1_content"] = verify_new_file_1.read_text(encoding="utf-8") if verify_new_file_1.is_file() else "" + details["verify_new_file_2_content"] = verify_new_file_2.read_text(encoding="utf-8") if verify_new_file_2.is_file() else "" + + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + + if verify_old_dir.exists() or verify_old_file_1.exists() or verify_old_file_2.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification still contains original directory tree: {source_dir_relative}", + artifacts, + details, + ) + + if not verify_new_dir.is_dir(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing renamed directory: {renamed_dir_relative}", + artifacts, + details, + ) + + if not verify_new_file_1.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing renamed directory file: {renamed_file_1_relative}", + artifacts, + details, + ) + + if not verify_new_file_2.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing renamed nested file: {renamed_file_2_relative}", + artifacts, + details, + ) + + if verify_new_file_1.read_text(encoding="utf-8") != file1_content: + return TestResult.fail_result( + self.case_id, + self.name, + "renamed top-level file content did not match expected content", + artifacts, + details, + ) + + if verify_new_file_2.read_text(encoding="utf-8") != file2_content: + return TestResult.fail_result( + self.case_id, + self.name, + "renamed nested file content did not match expected content", + artifacts, + details, + ) + + return TestResult.pass_result( + self.case_id, + self.name, + artifacts, + details, + ) \ No newline at end of file From df0468a960ee2dbceefdba7f37a2d5f09f2ceecf Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 24 Mar 2026 05:38:45 +1100 Subject: [PATCH 111/245] Update tc0030 and tc0031 * Update tc0030 and tc0031 --- ...030_local_rename_propagation_validation.py | 109 +++++++++------ ...directory_rename_propagation_validation.py | 126 +++++++++++------- 2 files changed, 141 insertions(+), 94 deletions(-) diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index 6d5dd749a..8ee41595f 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -7,7 +7,13 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file +from framework.utils import ( + command_to_string, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) class TestCase0030LocalRenamePropagationValidation(E2ETestCase): @@ -24,6 +30,12 @@ def _write_config(self, config_path: Path) -> None: ), ) + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0030" case_log_dir = context.logs_dir / "tc0030" @@ -36,15 +48,19 @@ def run(self, context: E2EContext) -> TestResult: local_root = case_work_dir / "syncroot" verify_root = case_work_dir / "verifyroot" + + conf_seed = case_work_dir / "conf-seed" conf_main = case_work_dir / "conf-main" conf_verify = case_work_dir / "conf-verify" reset_directory(local_root) reset_directory(verify_root) + context.bootstrap_config_dir(conf_seed) context.bootstrap_config_dir(conf_main) context.bootstrap_config_dir(conf_verify) + self._write_config(conf_seed / "config") self._write_config(conf_main / "config") self._write_config(conf_verify / "config") @@ -84,6 +100,11 @@ def run(self, context: E2EContext) -> TestResult: "root_name": root_name, "old_relative": old_relative, "new_relative": new_relative, + "seed_conf_dir": str(conf_seed), + "main_conf_dir": str(conf_main), + "verify_conf_dir": str(conf_verify), + "local_root": str(local_root), + "verify_root": str(verify_root), } # Phase 1: seed the remote with the original filename @@ -93,8 +114,8 @@ def run(self, context: E2EContext) -> TestResult: context.onedrive_bin, "--display-running-config", "--sync", - "--verbose", "--upload-only", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -102,7 +123,7 @@ def run(self, context: E2EContext) -> TestResult: "--syncdir", str(local_root), "--confdir", - str(conf_main), + str(conf_seed), ] context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") phase1_result = run_command(phase1_command, cwd=context.repo_root) @@ -111,10 +132,7 @@ def run(self, context: E2EContext) -> TestResult: details["phase1_returncode"] = phase1_result.returncode if phase1_result.returncode != 0: - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, @@ -123,14 +141,46 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 2: rename the local file and run a normal sync to propagate the new state + if not old_local_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "initial local file is missing after seed phase", + artifacts, + details, + ) + + # Phase 2: rename the local file and propagate using a fresh runtime config/state old_local_path.rename(new_local_path) + if old_local_path.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local old filename still exists immediately after rename", + artifacts, + details, + ) + + if not new_local_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local renamed file does not exist immediately after rename", + artifacts, + details, + ) + phase2_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--verbose", + "--resync", + "--resync-auth", "--single-directory", root_name, "--syncdir", @@ -145,10 +195,7 @@ def run(self, context: E2EContext) -> TestResult: details["phase2_returncode"] = phase2_result.returncode if phase2_result.returncode != 0: - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, @@ -157,39 +204,13 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if old_local_path.exists(): - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) - return TestResult.fail_result( - self.case_id, - self.name, - "local old filename still exists after rename propagation phase", - artifacts, - details, - ) - - if not new_local_path.is_file(): - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) - return TestResult.fail_result( - self.case_id, - self.name, - "local renamed file does not exist after rename propagation phase", - artifacts, - details, - ) - # Phase 3: clean verify from remote state verify_command = [ context.onedrive_bin, "--display-running-config", "--sync", - "--verbose", "--download-only", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -210,16 +231,16 @@ def run(self, context: E2EContext) -> TestResult: verified_old_path = verify_root / old_relative verified_new_path = verify_root / new_relative - verified_content = verified_new_path.read_text(encoding="utf-8") if verified_new_path.is_file() else "" details["verified_old_exists"] = verified_old_path.exists() details["verified_new_exists"] = verified_new_path.exists() + + verified_content = "" + if verified_new_path.is_file(): + verified_content = verified_new_path.read_text(encoding="utf-8") details["verified_content"] = verified_content - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) + self._write_metadata(metadata_file, details) if verify_result.returncode != 0: return TestResult.fail_result( diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index eb0d67f34..e85113714 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -7,7 +7,13 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_onedrive_config, write_text_file +from framework.utils import ( + command_to_string, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) class TestCase0031LocalDirectoryRenamePropagationValidation(E2ETestCase): @@ -24,6 +30,12 @@ def _write_config(self, config_path: Path) -> None: ), ) + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0031" case_log_dir = context.logs_dir / "tc0031" @@ -36,15 +48,19 @@ def run(self, context: E2EContext) -> TestResult: local_root = case_work_dir / "syncroot" verify_root = case_work_dir / "verifyroot" + + conf_seed = case_work_dir / "conf-seed" conf_main = case_work_dir / "conf-main" conf_verify = case_work_dir / "conf-verify" reset_directory(local_root) reset_directory(verify_root) + context.bootstrap_config_dir(conf_seed) context.bootstrap_config_dir(conf_main) context.bootstrap_config_dir(conf_verify) + self._write_config(conf_seed / "config") self._write_config(conf_main / "config") self._write_config(conf_verify / "config") @@ -91,6 +107,11 @@ def run(self, context: E2EContext) -> TestResult: "root_name": root_name, "source_dir_relative": source_dir_relative, "renamed_dir_relative": renamed_dir_relative, + "seed_conf_dir": str(conf_seed), + "main_conf_dir": str(conf_main), + "verify_conf_dir": str(conf_verify), + "local_root": str(local_root), + "verify_root": str(verify_root), } # Phase 1: seed the remote with the original directory tree @@ -101,8 +122,8 @@ def run(self, context: E2EContext) -> TestResult: context.onedrive_bin, "--display-running-config", "--sync", - "--verbose", "--upload-only", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -110,7 +131,7 @@ def run(self, context: E2EContext) -> TestResult: "--syncdir", str(local_root), "--confdir", - str(conf_main), + str(conf_seed), ] context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") phase1_result = run_command(phase1_command, cwd=context.repo_root) @@ -119,10 +140,7 @@ def run(self, context: E2EContext) -> TestResult: details["phase1_returncode"] = phase1_result.returncode if phase1_result.returncode != 0: - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, @@ -131,14 +149,46 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 2: rename the entire local directory tree and propagate + if not source_dir.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "initial local source directory is missing after seed phase", + artifacts, + details, + ) + + # Phase 2: rename the entire local directory tree and propagate using a fresh runtime config/state source_dir.rename(renamed_dir) + if source_dir.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local original directory still exists immediately after rename", + artifacts, + details, + ) + + if not renamed_dir.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local renamed directory does not exist immediately after rename", + artifacts, + details, + ) + phase2_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--verbose", + "--resync", + "--resync-auth", "--single-directory", root_name, "--syncdir", @@ -153,10 +203,7 @@ def run(self, context: E2EContext) -> TestResult: details["phase2_returncode"] = phase2_result.returncode if phase2_result.returncode != 0: - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, @@ -165,39 +212,13 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if source_dir.exists(): - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) - return TestResult.fail_result( - self.case_id, - self.name, - "local original directory still exists after rename propagation phase", - artifacts, - details, - ) - - if not renamed_dir.is_dir(): - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) - return TestResult.fail_result( - self.case_id, - self.name, - "local renamed directory does not exist after rename propagation phase", - artifacts, - details, - ) - # Phase 3: clean verify from remote state verify_command = [ context.onedrive_bin, "--display-running-config", "--sync", - "--verbose", "--download-only", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -218,10 +239,10 @@ def run(self, context: E2EContext) -> TestResult: verify_old_dir = verify_root / source_dir_relative verify_new_dir = verify_root / renamed_dir_relative - verify_new_file_1 = verify_root / renamed_file_1_relative - verify_new_file_2 = verify_root / renamed_file_2_relative verify_old_file_1 = verify_root / source_file_1_relative verify_old_file_2 = verify_root / source_file_2_relative + verify_new_file_1 = verify_root / renamed_file_1_relative + verify_new_file_2 = verify_root / renamed_file_2_relative details["verify_old_dir_exists"] = verify_old_dir.exists() details["verify_new_dir_exists"] = verify_new_dir.exists() @@ -229,13 +250,18 @@ def run(self, context: E2EContext) -> TestResult: details["verify_old_file_2_exists"] = verify_old_file_2.exists() details["verify_new_file_1_exists"] = verify_new_file_1.exists() details["verify_new_file_2_exists"] = verify_new_file_2.exists() - details["verify_new_file_1_content"] = verify_new_file_1.read_text(encoding="utf-8") if verify_new_file_1.is_file() else "" - details["verify_new_file_2_content"] = verify_new_file_2.read_text(encoding="utf-8") if verify_new_file_2.is_file() else "" - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) + verify_new_file_1_content = "" + verify_new_file_2_content = "" + if verify_new_file_1.is_file(): + verify_new_file_1_content = verify_new_file_1.read_text(encoding="utf-8") + if verify_new_file_2.is_file(): + verify_new_file_2_content = verify_new_file_2.read_text(encoding="utf-8") + + details["verify_new_file_1_content"] = verify_new_file_1_content + details["verify_new_file_2_content"] = verify_new_file_2_content + + self._write_metadata(metadata_file, details) if verify_result.returncode != 0: return TestResult.fail_result( @@ -268,7 +294,7 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - f"remote verification is missing renamed directory file: {renamed_file_1_relative}", + f"remote verification is missing renamed top-level file: {renamed_file_1_relative}", artifacts, details, ) @@ -282,7 +308,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if verify_new_file_1.read_text(encoding="utf-8") != file1_content: + if verify_new_file_1_content != file1_content: return TestResult.fail_result( self.case_id, self.name, @@ -291,7 +317,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if verify_new_file_2.read_text(encoding="utf-8") != file2_content: + if verify_new_file_2_content != file2_content: return TestResult.fail_result( self.case_id, self.name, From cfd4d958eb06559b5082605943e47b05f70c8287 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 24 Mar 2026 06:00:48 +1100 Subject: [PATCH 112/245] Update tc0030 and tc0031 Update tc0030 and tc0031 --- .../tc0030_local_rename_propagation_validation.py | 13 +------------ ...local_directory_rename_propagation_validation.py | 13 +------------ docs/end_to_end_testing.md | 3 +++ 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index 8ee41595f..894394fa6 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -48,19 +48,15 @@ def run(self, context: E2EContext) -> TestResult: local_root = case_work_dir / "syncroot" verify_root = case_work_dir / "verifyroot" - - conf_seed = case_work_dir / "conf-seed" conf_main = case_work_dir / "conf-main" conf_verify = case_work_dir / "conf-verify" reset_directory(local_root) reset_directory(verify_root) - context.bootstrap_config_dir(conf_seed) context.bootstrap_config_dir(conf_main) context.bootstrap_config_dir(conf_verify) - self._write_config(conf_seed / "config") self._write_config(conf_main / "config") self._write_config(conf_verify / "config") @@ -100,21 +96,18 @@ def run(self, context: E2EContext) -> TestResult: "root_name": root_name, "old_relative": old_relative, "new_relative": new_relative, - "seed_conf_dir": str(conf_seed), "main_conf_dir": str(conf_main), "verify_conf_dir": str(conf_verify), "local_root": str(local_root), "verify_root": str(verify_root), } - # Phase 1: seed the remote with the original filename write_text_file(old_local_path, initial_content) phase1_command = [ context.onedrive_bin, "--display-running-config", "--sync", - "--upload-only", "--verbose", "--resync", "--resync-auth", @@ -123,7 +116,7 @@ def run(self, context: E2EContext) -> TestResult: "--syncdir", str(local_root), "--confdir", - str(conf_seed), + str(conf_main), ] context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") phase1_result = run_command(phase1_command, cwd=context.repo_root) @@ -151,7 +144,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 2: rename the local file and propagate using a fresh runtime config/state old_local_path.rename(new_local_path) if old_local_path.exists(): @@ -179,8 +171,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--resync", - "--resync-auth", "--single-directory", root_name, "--syncdir", @@ -204,7 +194,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 3: clean verify from remote state verify_command = [ context.onedrive_bin, "--display-running-config", diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index e85113714..7055f97b0 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -48,19 +48,15 @@ def run(self, context: E2EContext) -> TestResult: local_root = case_work_dir / "syncroot" verify_root = case_work_dir / "verifyroot" - - conf_seed = case_work_dir / "conf-seed" conf_main = case_work_dir / "conf-main" conf_verify = case_work_dir / "conf-verify" reset_directory(local_root) reset_directory(verify_root) - context.bootstrap_config_dir(conf_seed) context.bootstrap_config_dir(conf_main) context.bootstrap_config_dir(conf_verify) - self._write_config(conf_seed / "config") self._write_config(conf_main / "config") self._write_config(conf_verify / "config") @@ -107,14 +103,12 @@ def run(self, context: E2EContext) -> TestResult: "root_name": root_name, "source_dir_relative": source_dir_relative, "renamed_dir_relative": renamed_dir_relative, - "seed_conf_dir": str(conf_seed), "main_conf_dir": str(conf_main), "verify_conf_dir": str(conf_verify), "local_root": str(local_root), "verify_root": str(verify_root), } - # Phase 1: seed the remote with the original directory tree write_text_file(source_file_1, file1_content) write_text_file(source_file_2, file2_content) @@ -122,7 +116,6 @@ def run(self, context: E2EContext) -> TestResult: context.onedrive_bin, "--display-running-config", "--sync", - "--upload-only", "--verbose", "--resync", "--resync-auth", @@ -131,7 +124,7 @@ def run(self, context: E2EContext) -> TestResult: "--syncdir", str(local_root), "--confdir", - str(conf_seed), + str(conf_main), ] context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") phase1_result = run_command(phase1_command, cwd=context.repo_root) @@ -159,7 +152,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 2: rename the entire local directory tree and propagate using a fresh runtime config/state source_dir.rename(renamed_dir) if source_dir.exists(): @@ -187,8 +179,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--resync", - "--resync-auth", "--single-directory", root_name, "--syncdir", @@ -212,7 +202,6 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 3: clean verify from remote state verify_command = [ context.onedrive_bin, "--display-running-config", diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index e4f7de26a..319822b62 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -50,3 +50,6 @@ SharePoint end-to-end testing uses the same complete automated test suite as Per | 0027 | Whitespace and trailing dot validation | - Personal
- Business
- SharePoint | This test validates that trailing whitespace and trailing dot names are blocked while valid sibling files still synchronise | | 0028 | Control character and non-UTF8 filename validation | - Personal
- Business
- SharePoint | This test validates that control characters and non-UTF8 filenames are safely skipped without client crash while valid sibling files still synchronise | | 0029 | Upload-only + Local First sync validation | - Personal
- Business
- SharePoint | This test validates that `--local-first --upload-only` uploads local content without rewriting local file timestamps from Microsoft API response data | +| 0030 | Local rename propagation validation | - Personal
- Business
- SharePoint | This test validates that renaming a local file is correctly propagated to remote state | +| 0031 | Local directory rename propagation validation | - Personal
- Business
- SharePoint | This test validates that renaming a local directory tree is correctly propagated to remote state | + From ec6fb8d01d82e64517c55dbf20a07f165419d4bb Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 24 Mar 2026 06:17:10 +1100 Subject: [PATCH 113/245] Update tc0030 and tc0031 * Update tc0030 and tc0031 --- ci/e2e/testcases/tc0030_local_rename_propagation_validation.py | 2 -- .../tc0031_local_directory_rename_propagation_validation.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index 894394fa6..af015fc21 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -109,8 +109,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--resync", - "--resync-auth", "--single-directory", root_name, "--syncdir", diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index 7055f97b0..5ab3cb000 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -117,8 +117,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--resync", - "--resync-auth", "--single-directory", root_name, "--syncdir", From c478b26fcdbf41adab8340dfd32e6e4485263cf5 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 24 Mar 2026 06:30:44 +1100 Subject: [PATCH 114/245] Update tc0030 and tc0031 Update tc0030 and tc0031 --- ci/e2e/framework/context.py | 25 +++++++++++++++++++ ...030_local_rename_propagation_validation.py | 7 ++++-- ...directory_rename_propagation_validation.py | 7 ++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py index 02e2457a7..1c52cae19 100644 --- a/ci/e2e/framework/context.py +++ b/ci/e2e/framework/context.py @@ -102,6 +102,31 @@ def bootstrap_config_dir(self, config_dir: Path) -> Path: return destination + def prepare_minimal_config_dir(self, config_dir: Path) -> Path: + """ + Create a brand new per-test config dir containing only the refresh_token. + + This is used by testcases that require pristine application state creation + on first run, while still allowing the client itself to generate any backup + config files, hashes, databases, and other runtime state as needed. + + Unlike bootstrap_config_dir(), this does not pre-seed a config file or copy + any other existing runtime artefacts. + """ + self.ensure_refresh_token_available() + + if config_dir.exists(): + shutil.rmtree(config_dir) + + ensure_directory(config_dir) + + source = self.default_refresh_token_path + destination = config_dir / "refresh_token" + shutil.copy2(source, destination) + os.chmod(destination, 0o600) + + return destination + def log(self, message: str) -> None: ensure_directory(self.out_dir) line = f"[{timestamp_now()}] {message}\n" diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index af015fc21..29eecb59e 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -54,8 +54,8 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - context.bootstrap_config_dir(conf_main) - context.bootstrap_config_dir(conf_verify) + context.prepare_minimal_config_dir(conf_main) + context.prepare_minimal_config_dir(conf_verify) self._write_config(conf_main / "config") self._write_config(conf_verify / "config") @@ -102,6 +102,7 @@ def run(self, context: E2EContext) -> TestResult: "verify_root": str(verify_root), } + # Phase 1: create initial state and upload original file write_text_file(old_local_path, initial_content) phase1_command = [ @@ -142,6 +143,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) + # Phase 2: rename locally and push using the same runtime state old_local_path.rename(new_local_path) if old_local_path.exists(): @@ -192,6 +194,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) + # Phase 3: verify final remote state using a separate clean config dir verify_command = [ context.onedrive_bin, "--display-running-config", diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index 5ab3cb000..6d5633f22 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -54,8 +54,8 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - context.bootstrap_config_dir(conf_main) - context.bootstrap_config_dir(conf_verify) + context.prepare_minimal_config_dir(conf_main) + context.prepare_minimal_config_dir(conf_verify) self._write_config(conf_main / "config") self._write_config(conf_verify / "config") @@ -109,6 +109,7 @@ def run(self, context: E2EContext) -> TestResult: "verify_root": str(verify_root), } + # Phase 1: create initial state and upload original directory tree write_text_file(source_file_1, file1_content) write_text_file(source_file_2, file2_content) @@ -150,6 +151,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) + # Phase 2: rename locally and push using the same runtime state source_dir.rename(renamed_dir) if source_dir.exists(): @@ -200,6 +202,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) + # Phase 3: verify final remote state using a separate clean config dir verify_command = [ context.onedrive_bin, "--display-running-config", From e2ff64d94b0efbab2223ff927b362de3c9f00c64 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 24 Mar 2026 07:43:32 +1100 Subject: [PATCH 115/245] Update tc0030 and tc0031 Update tc0030 and tc0031 --- ci/e2e/framework/context.py | 42 +++--- ci/e2e/framework/utils.py | 37 ++++++ ...030_local_rename_propagation_validation.py | 98 +++----------- ...directory_rename_propagation_validation.py | 120 +++--------------- 4 files changed, 102 insertions(+), 195 deletions(-) diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py index 1c52cae19..330faa0eb 100644 --- a/ci/e2e/framework/context.py +++ b/ci/e2e/framework/context.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from pathlib import Path -from framework.utils import ensure_directory, get_optional_base_config_text, timestamp_now, write_text_file, write_text_file_append +from framework.utils import ensure_directory, get_optional_base_config_text, timestamp_now, write_text_file, write_text_file_append, compute_quickxor_hash_file @dataclass @@ -102,16 +102,17 @@ def bootstrap_config_dir(self, config_dir: Path) -> Path: return destination - def prepare_minimal_config_dir(self, config_dir: Path) -> Path: + def prepare_minimal_config_dir(self, config_dir: Path, config_text: str) -> Path: """ - Create a brand new per-test config dir containing only the refresh_token. - - This is used by testcases that require pristine application state creation - on first run, while still allowing the client itself to generate any backup - config files, hashes, databases, and other runtime state as needed. - - Unlike bootstrap_config_dir(), this does not pre-seed a config file or copy - any other existing runtime artefacts. + Create a clean, runtime-ready OneDrive config dir containing only the + minimum artefacts required for the client to start without immediately + demanding a --resync. + + Files created: + - refresh_token + - config + - .config.backup + - .config.hash """ self.ensure_refresh_token_available() @@ -120,12 +121,23 @@ def prepare_minimal_config_dir(self, config_dir: Path) -> Path: ensure_directory(config_dir) - source = self.default_refresh_token_path - destination = config_dir / "refresh_token" - shutil.copy2(source, destination) - os.chmod(destination, 0o600) + refresh_token_destination = config_dir / "refresh_token" + shutil.copy2(self.default_refresh_token_path, refresh_token_destination) + os.chmod(refresh_token_destination, 0o600) - return destination + config_path = config_dir / "config" + config_path.write_text(config_text, encoding="utf-8") + os.chmod(config_path, 0o600) + + backup_path = config_dir / ".config.backup" + backup_path.write_text(config_text, encoding="utf-8") + os.chmod(backup_path, 0o600) + + hash_path = config_dir / ".config.hash" + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(hash_path, 0o600) + + return config_path def log(self, message: str) -> None: ensure_directory(self.out_dir) diff --git a/ci/e2e/framework/utils.py b/ci/e2e/framework/utils.py index a003e0955..18187609e 100644 --- a/ci/e2e/framework/utils.py +++ b/ci/e2e/framework/utils.py @@ -3,6 +3,7 @@ import os import shutil import subprocess +import base64 from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -272,3 +273,39 @@ def write_onedrive_config(path: Path, content: str) -> None: """ base_config_text = get_optional_base_config_text() write_text_file(path, base_config_text + content) + + +def compute_quickxor_hash_bytes(data: bytes) -> str: + """ + Compute Microsoft QuickXorHash and return the same base64 string style used + by the OneDrive client for .config.hash and .sync_list.hash files. + + This implementation is sufficient for small text config files used by the + E2E harness. + """ + width_bits = 160 + shift = 11 + cell_count = width_bits // 8 + out = [0] * cell_count + + for i, b in enumerate(data): + bit_index = (i * shift) % width_bits + byte_index = bit_index // 8 + bit_offset = bit_index % 8 + + value = b & 0xFF + + out[byte_index] ^= (value << bit_offset) & 0xFF + if bit_offset > 0: + out[(byte_index + 1) % cell_count] ^= (value >> (8 - bit_offset)) & 0xFF + + length = len(data) + for i in range(8): + out[cell_count - 8 + i] ^= (length >> (8 * i)) & 0xFF + + return base64.b64encode(bytes(out)).decode("ascii") + + +def compute_quickxor_hash_file(path: Path) -> str: + return compute_quickxor_hash_bytes(path.read_bytes()) + diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index 29eecb59e..4ad152896 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -7,13 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import ( - command_to_string, - reset_directory, - run_command, - write_onedrive_config, - write_text_file, -) +from framework.utils import command_to_string, reset_directory, run_command, write_text_file class TestCase0030LocalRenamePropagationValidation(E2ETestCase): @@ -21,13 +15,10 @@ class TestCase0030LocalRenamePropagationValidation(E2ETestCase): name = "local rename propagation validation" description = "Validate that renaming a local file is correctly propagated to remote state" - def _write_config(self, config_path: Path) -> None: - write_onedrive_config( - config_path, - ( - "# tc0030 config\n" - 'bypass_data_preservation = "true"\n' - ), + def _config_text(self) -> str: + return ( + "# tc0030 config\n" + 'bypass_data_preservation = "true"\n' ) def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: @@ -54,11 +45,8 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - context.prepare_minimal_config_dir(conf_main) - context.prepare_minimal_config_dir(conf_verify) - - self._write_config(conf_main / "config") - self._write_config(conf_verify / "config") + context.prepare_minimal_config_dir(conf_main, self._config_text()) + context.prepare_minimal_config_dir(conf_verify, self._config_text()) root_name = f"ZZ_E2E_TC0030_{context.run_id}_{os.getpid()}" old_relative = f"{root_name}/original-name.txt" @@ -102,7 +90,6 @@ def run(self, context: E2EContext) -> TestResult: "verify_root": str(verify_root), } - # Phase 1: create initial state and upload original file write_text_file(old_local_path, initial_content) phase1_command = [ @@ -126,44 +113,21 @@ def run(self, context: E2EContext) -> TestResult: if phase1_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( - self.case_id, - self.name, - f"seed phase failed with status {phase1_result.returncode}", - artifacts, - details, - ) - - if not old_local_path.is_file(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "initial local file is missing after seed phase", - artifacts, - details, + self.case_id, self.name, f"seed phase failed with status {phase1_result.returncode}", artifacts, details ) - # Phase 2: rename locally and push using the same runtime state old_local_path.rename(new_local_path) if old_local_path.exists(): self._write_metadata(metadata_file, details) return TestResult.fail_result( - self.case_id, - self.name, - "local old filename still exists immediately after rename", - artifacts, - details, + self.case_id, self.name, "local old filename still exists immediately after rename", artifacts, details ) if not new_local_path.is_file(): self._write_metadata(metadata_file, details) return TestResult.fail_result( - self.case_id, - self.name, - "local renamed file does not exist immediately after rename", - artifacts, - details, + self.case_id, self.name, "local renamed file does not exist immediately after rename", artifacts, details ) phase2_command = [ @@ -187,14 +151,9 @@ def run(self, context: E2EContext) -> TestResult: if phase2_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( - self.case_id, - self.name, - f"rename propagation phase failed with status {phase2_result.returncode}", - artifacts, - details, + self.case_id, self.name, f"rename propagation phase failed with status {phase2_result.returncode}", artifacts, details ) - # Phase 3: verify final remote state using a separate clean config dir verify_command = [ context.onedrive_bin, "--display-running-config", @@ -225,52 +184,29 @@ def run(self, context: E2EContext) -> TestResult: details["verified_old_exists"] = verified_old_path.exists() details["verified_new_exists"] = verified_new_path.exists() - verified_content = "" - if verified_new_path.is_file(): - verified_content = verified_new_path.read_text(encoding="utf-8") + verified_content = verified_new_path.read_text(encoding="utf-8") if verified_new_path.is_file() else "" details["verified_content"] = verified_content self._write_metadata(metadata_file, details) if verify_result.returncode != 0: return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification failed with status {verify_result.returncode}", - artifacts, - details, + self.case_id, self.name, f"remote verification failed with status {verify_result.returncode}", artifacts, details ) if verified_old_path.exists(): return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification still contains old filename: {old_relative}", - artifacts, - details, + self.case_id, self.name, f"remote verification still contains old filename: {old_relative}", artifacts, details ) if not verified_new_path.is_file(): return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification is missing renamed file: {new_relative}", - artifacts, - details, + self.case_id, self.name, f"remote verification is missing renamed file: {new_relative}", artifacts, details ) if verified_content != initial_content: return TestResult.fail_result( - self.case_id, - self.name, - "renamed file content did not match the original content after remote verification", - artifacts, - details, + self.case_id, self.name, "renamed file content did not match the original content after remote verification", artifacts, details ) - return TestResult.pass_result( - self.case_id, - self.name, - artifacts, - details, - ) \ No newline at end of file + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index 6d5633f22..062a59773 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -7,13 +7,7 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import ( - command_to_string, - reset_directory, - run_command, - write_onedrive_config, - write_text_file, -) +from framework.utils import command_to_string, reset_directory, run_command, write_text_file class TestCase0031LocalDirectoryRenamePropagationValidation(E2ETestCase): @@ -21,13 +15,10 @@ class TestCase0031LocalDirectoryRenamePropagationValidation(E2ETestCase): name = "local directory rename propagation validation" description = "Validate that renaming a local directory tree is correctly propagated to remote state" - def _write_config(self, config_path: Path) -> None: - write_onedrive_config( - config_path, - ( - "# tc0031 config\n" - 'bypass_data_preservation = "true"\n' - ), + def _config_text(self) -> str: + return ( + "# tc0031 config\n" + 'bypass_data_preservation = "true"\n' ) def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: @@ -54,11 +45,8 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - context.prepare_minimal_config_dir(conf_main) - context.prepare_minimal_config_dir(conf_verify) - - self._write_config(conf_main / "config") - self._write_config(conf_verify / "config") + context.prepare_minimal_config_dir(conf_main, self._config_text()) + context.prepare_minimal_config_dir(conf_verify, self._config_text()) root_name = f"ZZ_E2E_TC0031_{context.run_id}_{os.getpid()}" source_dir_relative = f"{root_name}/SourceDirectory" @@ -109,7 +97,6 @@ def run(self, context: E2EContext) -> TestResult: "verify_root": str(verify_root), } - # Phase 1: create initial state and upload original directory tree write_text_file(source_file_1, file1_content) write_text_file(source_file_2, file2_content) @@ -134,44 +121,21 @@ def run(self, context: E2EContext) -> TestResult: if phase1_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( - self.case_id, - self.name, - f"seed phase failed with status {phase1_result.returncode}", - artifacts, - details, - ) - - if not source_dir.is_dir(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "initial local source directory is missing after seed phase", - artifacts, - details, + self.case_id, self.name, f"seed phase failed with status {phase1_result.returncode}", artifacts, details ) - # Phase 2: rename locally and push using the same runtime state source_dir.rename(renamed_dir) if source_dir.exists(): self._write_metadata(metadata_file, details) return TestResult.fail_result( - self.case_id, - self.name, - "local original directory still exists immediately after rename", - artifacts, - details, + self.case_id, self.name, "local original directory still exists immediately after rename", artifacts, details ) if not renamed_dir.is_dir(): self._write_metadata(metadata_file, details) return TestResult.fail_result( - self.case_id, - self.name, - "local renamed directory does not exist immediately after rename", - artifacts, - details, + self.case_id, self.name, "local renamed directory does not exist immediately after rename", artifacts, details ) phase2_command = [ @@ -195,14 +159,9 @@ def run(self, context: E2EContext) -> TestResult: if phase2_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( - self.case_id, - self.name, - f"directory rename propagation phase failed with status {phase2_result.returncode}", - artifacts, - details, + self.case_id, self.name, f"directory rename propagation phase failed with status {phase2_result.returncode}", artifacts, details ) - # Phase 3: verify final remote state using a separate clean config dir verify_command = [ context.onedrive_bin, "--display-running-config", @@ -241,12 +200,8 @@ def run(self, context: E2EContext) -> TestResult: details["verify_new_file_1_exists"] = verify_new_file_1.exists() details["verify_new_file_2_exists"] = verify_new_file_2.exists() - verify_new_file_1_content = "" - verify_new_file_2_content = "" - if verify_new_file_1.is_file(): - verify_new_file_1_content = verify_new_file_1.read_text(encoding="utf-8") - if verify_new_file_2.is_file(): - verify_new_file_2_content = verify_new_file_2.read_text(encoding="utf-8") + verify_new_file_1_content = verify_new_file_1.read_text(encoding="utf-8") if verify_new_file_1.is_file() else "" + verify_new_file_2_content = verify_new_file_2.read_text(encoding="utf-8") if verify_new_file_2.is_file() else "" details["verify_new_file_1_content"] = verify_new_file_1_content details["verify_new_file_2_content"] = verify_new_file_2_content @@ -255,70 +210,37 @@ def run(self, context: E2EContext) -> TestResult: if verify_result.returncode != 0: return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification failed with status {verify_result.returncode}", - artifacts, - details, + self.case_id, self.name, f"remote verification failed with status {verify_result.returncode}", artifacts, details ) if verify_old_dir.exists() or verify_old_file_1.exists() or verify_old_file_2.exists(): return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification still contains original directory tree: {source_dir_relative}", - artifacts, - details, + self.case_id, self.name, f"remote verification still contains original directory tree: {source_dir_relative}", artifacts, details ) if not verify_new_dir.is_dir(): return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification is missing renamed directory: {renamed_dir_relative}", - artifacts, - details, + self.case_id, self.name, f"remote verification is missing renamed directory: {renamed_dir_relative}", artifacts, details ) if not verify_new_file_1.is_file(): return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification is missing renamed top-level file: {renamed_file_1_relative}", - artifacts, - details, + self.case_id, self.name, f"remote verification is missing renamed top-level file: {renamed_file_1_relative}", artifacts, details ) if not verify_new_file_2.is_file(): return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification is missing renamed nested file: {renamed_file_2_relative}", - artifacts, - details, + self.case_id, self.name, f"remote verification is missing renamed nested file: {renamed_file_2_relative}", artifacts, details ) if verify_new_file_1_content != file1_content: return TestResult.fail_result( - self.case_id, - self.name, - "renamed top-level file content did not match expected content", - artifacts, - details, + self.case_id, self.name, "renamed top-level file content did not match expected content", artifacts, details ) if verify_new_file_2_content != file2_content: return TestResult.fail_result( - self.case_id, - self.name, - "renamed nested file content did not match expected content", - artifacts, - details, + self.case_id, self.name, "renamed nested file content did not match expected content", artifacts, details ) - return TestResult.pass_result( - self.case_id, - self.name, - artifacts, - details, - ) \ No newline at end of file + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From f56f55b47c51658ed60c7e7e632ce35c3068c950 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 24 Mar 2026 07:47:08 +1100 Subject: [PATCH 116/245] switch to debug logging for tc0030 and tc0031 switch to debug logging for tc0030 and tc0031 --- ci/e2e/testcases/tc0030_local_rename_propagation_validation.py | 3 +++ .../tc0031_local_directory_rename_propagation_validation.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index 4ad152896..0b70fcbc4 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -97,6 +97,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--syncdir", @@ -135,6 +136,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--syncdir", @@ -160,6 +162,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index 062a59773..8776c583e 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -105,6 +105,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--syncdir", @@ -143,6 +144,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--syncdir", @@ -168,6 +170,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", From 0f147b436b49dc7849f3254ea6479d612e9c7778 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 25 Mar 2026 04:24:21 +1100 Subject: [PATCH 117/245] Update tc0030 and tc0031 - remove double --syncdir Update tc0030 and tc0031 - remove double --syncdir --- .../tc0030_local_rename_propagation_validation.py | 13 ++++--------- ...local_directory_rename_propagation_validation.py | 13 ++++--------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index 0b70fcbc4..0edd23457 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -15,9 +15,10 @@ class TestCase0030LocalRenamePropagationValidation(E2ETestCase): name = "local rename propagation validation" description = "Validate that renaming a local file is correctly propagated to remote state" - def _config_text(self) -> str: + def _config_text(self, sync_dir: Path) -> str: return ( "# tc0030 config\n" + f'sync_dir = "{sync_dir}"\n' 'bypass_data_preservation = "true"\n' ) @@ -45,8 +46,8 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - context.prepare_minimal_config_dir(conf_main, self._config_text()) - context.prepare_minimal_config_dir(conf_verify, self._config_text()) + context.prepare_minimal_config_dir(conf_main, self._config_text(local_root)) + context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) root_name = f"ZZ_E2E_TC0030_{context.run_id}_{os.getpid()}" old_relative = f"{root_name}/original-name.txt" @@ -100,8 +101,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--single-directory", root_name, - "--syncdir", - str(local_root), "--confdir", str(conf_main), ] @@ -139,8 +138,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--single-directory", root_name, - "--syncdir", - str(local_root), "--confdir", str(conf_main), ] @@ -167,8 +164,6 @@ def run(self, context: E2EContext) -> TestResult: "--resync-auth", "--single-directory", root_name, - "--syncdir", - str(verify_root), "--confdir", str(conf_verify), ] diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index 8776c583e..c7e322464 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -15,9 +15,10 @@ class TestCase0031LocalDirectoryRenamePropagationValidation(E2ETestCase): name = "local directory rename propagation validation" description = "Validate that renaming a local directory tree is correctly propagated to remote state" - def _config_text(self) -> str: + def _config_text(self, sync_dir: Path) -> str: return ( "# tc0031 config\n" + f'sync_dir = "{sync_dir}"\n' 'bypass_data_preservation = "true"\n' ) @@ -45,8 +46,8 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - context.prepare_minimal_config_dir(conf_main, self._config_text()) - context.prepare_minimal_config_dir(conf_verify, self._config_text()) + context.prepare_minimal_config_dir(conf_main, self._config_text(local_root)) + context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) root_name = f"ZZ_E2E_TC0031_{context.run_id}_{os.getpid()}" source_dir_relative = f"{root_name}/SourceDirectory" @@ -108,8 +109,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--single-directory", root_name, - "--syncdir", - str(local_root), "--confdir", str(conf_main), ] @@ -147,8 +146,6 @@ def run(self, context: E2EContext) -> TestResult: "--verbose", "--single-directory", root_name, - "--syncdir", - str(local_root), "--confdir", str(conf_main), ] @@ -175,8 +172,6 @@ def run(self, context: E2EContext) -> TestResult: "--resync-auth", "--single-directory", root_name, - "--syncdir", - str(verify_root), "--confdir", str(conf_verify), ] From 3e8d577fa163cec85592bf9e78b51c1fc5576ff4 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 25 Mar 2026 05:12:54 +1100 Subject: [PATCH 118/245] Update tc0031 Update tc0031 --- ...31_local_directory_rename_propagation_validation.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index c7e322464..2fc17bd4b 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import time from pathlib import Path from framework.base import E2ETestCase @@ -19,7 +20,6 @@ def _config_text(self, sync_dir: Path) -> str: return ( "# tc0031 config\n" f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n' ) def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: @@ -96,6 +96,7 @@ def run(self, context: E2EContext) -> TestResult: "verify_conf_dir": str(conf_verify), "local_root": str(local_root), "verify_root": str(verify_root), + "post_phase2_settle_seconds": 10, } write_text_file(source_file_1, file1_content) @@ -161,6 +162,11 @@ def run(self, context: E2EContext) -> TestResult: self.case_id, self.name, f"directory rename propagation phase failed with status {phase2_result.returncode}", artifacts, details ) + context.log( + f"Executing Test Case {self.case_id}: waiting {details['post_phase2_settle_seconds']} seconds before verify to allow remote state to settle" + ) + time.sleep(int(details["post_phase2_settle_seconds"])) + verify_command = [ context.onedrive_bin, "--display-running-config", @@ -241,4 +247,4 @@ def run(self, context: E2EContext) -> TestResult: self.case_id, self.name, "renamed nested file content did not match expected content", artifacts, details ) - return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file + return TestResult.pass_result(self.case_id, self.name, artifacts, details) From 8a3c6ee373fbf0203d21b5e64064deeb3e530e5f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 25 Mar 2026 05:29:01 +1100 Subject: [PATCH 119/245] Update tc0031 Update tc0031 --- ...0030_local_rename_propagation_validation.py | 3 --- ..._directory_rename_propagation_validation.py | 18 ++++-------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index 0edd23457..2fe973666 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -98,7 +98,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", @@ -135,7 +134,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", @@ -159,7 +157,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index 2fc17bd4b..620baa1ff 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import time from pathlib import Path from framework.base import E2ETestCase @@ -58,15 +57,14 @@ def run(self, context: E2EContext) -> TestResult: source_file_1_relative = f"{source_dir_relative}/top-level.txt" source_file_2_relative = f"{source_dir_relative}/Nested/child.txt" - renamed_file_1_relative = f"{renamed_dir_relative}/top-level.txt" renamed_file_2_relative = f"{renamed_dir_relative}/Nested/child.txt" source_file_1 = local_root / source_file_1_relative source_file_2 = local_root / source_file_2_relative - file1_content = "TC0031 top level file\n" - file2_content = "TC0031 nested child file\n" + file1_content = "top\n" + file2_content = "child\n" phase1_stdout = case_log_dir / "phase1_seed_stdout.log" phase1_stderr = case_log_dir / "phase1_seed_stderr.log" @@ -96,20 +94,18 @@ def run(self, context: E2EContext) -> TestResult: "verify_conf_dir": str(conf_verify), "local_root": str(local_root), "verify_root": str(verify_root), - "post_phase2_settle_seconds": 10, } write_text_file(source_file_1, file1_content) write_text_file(source_file_2, file2_content) + # Match the proven standalone reproduction as closely as possible. phase1_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--verbose", "--verbose", - "--single-directory", - root_name, "--confdir", str(conf_main), ] @@ -145,8 +141,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--verbose", "--verbose", - "--single-directory", - root_name, "--confdir", str(conf_main), ] @@ -155,6 +149,7 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(phase2_stdout, phase2_result.stdout) write_text_file(phase2_stderr, phase2_result.stderr) details["phase2_returncode"] = phase2_result.returncode + details["phase2_deleted_old_directory_online"] = f"Deleting item from Microsoft OneDrive: {root_name}/SourceDirectory" in phase2_result.stdout if phase2_result.returncode != 0: self._write_metadata(metadata_file, details) @@ -162,11 +157,6 @@ def run(self, context: E2EContext) -> TestResult: self.case_id, self.name, f"directory rename propagation phase failed with status {phase2_result.returncode}", artifacts, details ) - context.log( - f"Executing Test Case {self.case_id}: waiting {details['post_phase2_settle_seconds']} seconds before verify to allow remote state to settle" - ) - time.sleep(int(details["post_phase2_settle_seconds"])) - verify_command = [ context.onedrive_bin, "--display-running-config", From 1d1b5e3876024957e422304661558bfa56678895 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 25 Mar 2026 05:34:09 +1100 Subject: [PATCH 120/245] Update tc0031 - remove debug Update tc0031 - remove debug --- .../tc0031_local_directory_rename_propagation_validation.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index 620baa1ff..28e629c99 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -105,7 +105,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--confdir", str(conf_main), ] @@ -140,7 +139,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--confdir", str(conf_main), ] @@ -163,7 +161,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", From 3567c0532c403f3f1adcafe17c392d7bf26a3a47 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 25 Mar 2026 07:28:36 +1100 Subject: [PATCH 121/245] Test full list Test full list before tc0032 work --- ci/e2e/run.py | 58 +++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index fb53463d3..1209886bc 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -48,35 +48,35 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - #TestCase0001BasicResync(), - #TestCase0002SyncListValidation(), - #TestCase0003DryRunValidation(), - #TestCase0004SingleDirectorySync(), - #TestCase0005ForceSyncOverride(), - #TestCase0006DownloadOnly(), - #TestCase0007DownloadOnlyCleanupLocalFiles(), - #TestCase0008UploadOnly(), - #TestCase0009UploadOnlyNoRemoteDelete(), - #TestCase0010UploadOnlyRemoveSourceFiles(), - #TestCase0011SkipFileValidation(), - #TestCase0012SkipDirValidation(), - #TestCase0013SkipDotfilesValidation(), - #TestCase0014SkipSizeValidation(), - #TestCase0015SkipSymlinksValidation(), - #TestCase0016CheckNosyncValidation(), - #TestCase0017CheckNomountValidation(), - #TestCase0018RecycleBinValidation(), - #TestCase0019LoggingAndRunningConfig(), - #TestCase0020MonitorModeValidation(), - #TestCase0021ResumableTransfersValidation(), - #TestCase0022LocalFirstValidation(), - #TestCase0023BypassDataPreservationValidation(), - #TestCase0024BigDeleteSafeguardValidation(), - #TestCase0025InvalidCharacterFilenameValidation(), - #TestCase0026ReservedDeviceNameValidation(), - #TestCase0027WhitespaceTrailingDotValidation(), - #TestCase0028ControlCharacterNonUtf8FilenameValidation(), - #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + TestCase0001BasicResync(), + TestCase0002SyncListValidation(), + TestCase0003DryRunValidation(), + TestCase0004SingleDirectorySync(), + TestCase0005ForceSyncOverride(), + TestCase0006DownloadOnly(), + TestCase0007DownloadOnlyCleanupLocalFiles(), + TestCase0008UploadOnly(), + TestCase0009UploadOnlyNoRemoteDelete(), + TestCase0010UploadOnlyRemoveSourceFiles(), + TestCase0011SkipFileValidation(), + TestCase0012SkipDirValidation(), + TestCase0013SkipDotfilesValidation(), + TestCase0014SkipSizeValidation(), + TestCase0015SkipSymlinksValidation(), + TestCase0016CheckNosyncValidation(), + TestCase0017CheckNomountValidation(), + TestCase0018RecycleBinValidation(), + TestCase0019LoggingAndRunningConfig(), + TestCase0020MonitorModeValidation(), + TestCase0021ResumableTransfersValidation(), + TestCase0022LocalFirstValidation(), + TestCase0023BypassDataPreservationValidation(), + TestCase0024BigDeleteSafeguardValidation(), + TestCase0025InvalidCharacterFilenameValidation(), + TestCase0026ReservedDeviceNameValidation(), + TestCase0027WhitespaceTrailingDotValidation(), + TestCase0028ControlCharacterNonUtf8FilenameValidation(), + TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), TestCase0030LocalRenamePropagationValidation(), TestCase0031LocalDirectoryRenamePropagationValidation(), ] From 270ed979e9d12daa2189090c9436d676f7b6401b Mon Sep 17 00:00:00 2001 From: abraunegg Date: Thu, 26 Mar 2026 06:15:12 +1100 Subject: [PATCH 122/245] Add tc0032 Add tc0032 --- ci/e2e/run.py | 64 +-- .../tc0032_remote_rename_reconciliation.py | 369 ++++++++++++++++++ 2 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 ci/e2e/testcases/tc0032_remote_rename_reconciliation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 1209886bc..7e8c581ba 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -40,6 +40,7 @@ from testcases.tc0029_local_first_upload_only_timestamp_preservation_validation import TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation from testcases.tc0030_local_rename_propagation_validation import TestCase0030LocalRenamePropagationValidation from testcases.tc0031_local_directory_rename_propagation_validation import TestCase0031LocalDirectoryRenamePropagationValidation +from testcases.tc0032_remote_rename_reconciliation import TestCase0032RemoteRenameReconciliation def build_test_suite() -> list: """ @@ -48,37 +49,38 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - TestCase0001BasicResync(), - TestCase0002SyncListValidation(), - TestCase0003DryRunValidation(), - TestCase0004SingleDirectorySync(), - TestCase0005ForceSyncOverride(), - TestCase0006DownloadOnly(), - TestCase0007DownloadOnlyCleanupLocalFiles(), - TestCase0008UploadOnly(), - TestCase0009UploadOnlyNoRemoteDelete(), - TestCase0010UploadOnlyRemoveSourceFiles(), - TestCase0011SkipFileValidation(), - TestCase0012SkipDirValidation(), - TestCase0013SkipDotfilesValidation(), - TestCase0014SkipSizeValidation(), - TestCase0015SkipSymlinksValidation(), - TestCase0016CheckNosyncValidation(), - TestCase0017CheckNomountValidation(), - TestCase0018RecycleBinValidation(), - TestCase0019LoggingAndRunningConfig(), - TestCase0020MonitorModeValidation(), - TestCase0021ResumableTransfersValidation(), - TestCase0022LocalFirstValidation(), - TestCase0023BypassDataPreservationValidation(), - TestCase0024BigDeleteSafeguardValidation(), - TestCase0025InvalidCharacterFilenameValidation(), - TestCase0026ReservedDeviceNameValidation(), - TestCase0027WhitespaceTrailingDotValidation(), - TestCase0028ControlCharacterNonUtf8FilenameValidation(), - TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), - TestCase0030LocalRenamePropagationValidation(), - TestCase0031LocalDirectoryRenamePropagationValidation(), + #TestCase0001BasicResync(), + #TestCase0002SyncListValidation(), + #TestCase0003DryRunValidation(), + #TestCase0004SingleDirectorySync(), + #TestCase0005ForceSyncOverride(), + #TestCase0006DownloadOnly(), + #TestCase0007DownloadOnlyCleanupLocalFiles(), + #TestCase0008UploadOnly(), + #TestCase0009UploadOnlyNoRemoteDelete(), + #TestCase0010UploadOnlyRemoveSourceFiles(), + #TestCase0011SkipFileValidation(), + #TestCase0012SkipDirValidation(), + #TestCase0013SkipDotfilesValidation(), + #TestCase0014SkipSizeValidation(), + #TestCase0015SkipSymlinksValidation(), + #TestCase0016CheckNosyncValidation(), + #TestCase0017CheckNomountValidation(), + #TestCase0018RecycleBinValidation(), + #TestCase0019LoggingAndRunningConfig(), + #TestCase0020MonitorModeValidation(), + #TestCase0021ResumableTransfersValidation(), + #TestCase0022LocalFirstValidation(), + #TestCase0023BypassDataPreservationValidation(), + #TestCase0024BigDeleteSafeguardValidation(), + #TestCase0025InvalidCharacterFilenameValidation(), + #TestCase0026ReservedDeviceNameValidation(), + #TestCase0027WhitespaceTrailingDotValidation(), + #TestCase0028ControlCharacterNonUtf8FilenameValidation(), + #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + #TestCase0030LocalRenamePropagationValidation(), + #TestCase0031LocalDirectoryRenamePropagationValidation(), + TestCase0032RemoteRenameReconciliation(), ] diff --git a/ci/e2e/testcases/tc0032_remote_rename_reconciliation.py b/ci/e2e/testcases/tc0032_remote_rename_reconciliation.py new file mode 100644 index 000000000..a2fc310a8 --- /dev/null +++ b/ci/e2e/testcases/tc0032_remote_rename_reconciliation.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) + + +class TestCase0032RemoteRenameReconciliation(E2ETestCase): + case_id = "0032" + name = "remote rename reconciliation" + description = ( + "Validate that a stale local client correctly reconciles a remote-side " + "file rename without leaving stale local leftovers" + ) + + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( + "# tc0032 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) + + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0032" + case_log_dir = context.logs_dir / "tc0032" + state_dir = context.state_dir / "tc0032" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + seed_root = case_work_dir / "seedroot" + stale_root = case_work_dir / "staleroot" + verify_root = case_work_dir / "verifyroot" + + conf_seed = case_work_dir / "conf-seed" + conf_stale = case_work_dir / "conf-stale" + conf_verify = case_work_dir / "conf-verify" + + reset_directory(seed_root) + reset_directory(verify_root) + + context.prepare_minimal_config_dir(conf_seed, "") + context.prepare_minimal_config_dir(conf_verify, "") + + self._write_config(conf_seed, seed_root) + self._write_config(conf_verify, verify_root) + + root_name = f"ZZ_E2E_TC0032_{context.run_id}_{os.getpid()}" + old_relative = f"{root_name}/remote-original-name.txt" + new_relative = f"{root_name}/remote-renamed-name.txt" + + seed_old_path = seed_root / old_relative + seed_new_path = seed_root / new_relative + stale_old_path = stale_root / old_relative + stale_new_path = stale_root / new_relative + verify_old_path = verify_root / old_relative + verify_new_path = verify_root / new_relative + + initial_content = ( + "TC0032 remote rename reconciliation\n" + "This file is renamed remotely and must reconcile locally.\n" + ) + + seed_stdout = case_log_dir / "phase1_seed_stdout.log" + seed_stderr = case_log_dir / "phase1_seed_stderr.log" + remote_rename_stdout = case_log_dir / "phase2_remote_rename_stdout.log" + remote_rename_stderr = case_log_dir / "phase2_remote_rename_stderr.log" + stale_sync_stdout = case_log_dir / "phase3_stale_reconcile_stdout.log" + stale_sync_stderr = case_log_dir / "phase3_stale_reconcile_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + stale_manifest_file = state_dir / "stale_manifest.txt" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(remote_rename_stdout), + str(remote_rename_stderr), + str(stale_sync_stdout), + str(stale_sync_stderr), + str(verify_stdout), + str(verify_stderr), + str(stale_manifest_file), + str(verify_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "old_relative": old_relative, + "new_relative": new_relative, + "seed_root": str(seed_root), + "stale_root": str(stale_root), + "verify_root": str(verify_root), + "seed_conf_dir": str(conf_seed), + "stale_conf_dir": str(conf_stale), + "verify_conf_dir": str(conf_verify), + } + + # Phase 1: seed original remote state + write_text_file(seed_old_path, initial_content) + + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} phase1 seed: {command_to_string(seed_command)}") + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout) + write_text_file(seed_stderr, seed_result.stderr) + details["seed_returncode"] = seed_result.returncode + + if seed_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {seed_result.returncode}", + artifacts, + details, + ) + + # Snapshot the synchronised local + config/db state to create a stale client. + # This stale client represents a second machine that has not yet seen the rename. + if conf_stale.exists(): + shutil.rmtree(conf_stale) + if stale_root.exists(): + shutil.rmtree(stale_root) + + shutil.copytree(conf_seed, conf_stale) + shutil.copytree(seed_root, stale_root) + + # Rewrite stale runtime config so it points at stale_root while preserving DB state. + self._write_config(conf_stale, stale_root) + + details["stale_snapshot_old_exists_before_reconcile"] = stale_old_path.exists() + details["stale_snapshot_new_exists_before_reconcile"] = stale_new_path.exists() + + if not stale_old_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "stale snapshot did not preserve original local file before reconciliation", + artifacts, + details, + ) + + # Phase 2: perform the rename through the seed client. + # This is our remote-side rename mechanism. + seed_old_path.rename(seed_new_path) + + if seed_old_path.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seed local old filename still exists immediately after rename", + artifacts, + details, + ) + + if not seed_new_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seed local renamed file does not exist immediately after rename", + artifacts, + details, + ) + + remote_rename_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} phase2 remote rename: {command_to_string(remote_rename_command)}") + remote_rename_result = run_command(remote_rename_command, cwd=context.repo_root) + write_text_file(remote_rename_stdout, remote_rename_result.stdout) + write_text_file(remote_rename_stderr, remote_rename_result.stderr) + details["remote_rename_returncode"] = remote_rename_result.returncode + + if remote_rename_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"remote rename propagation phase failed with status {remote_rename_result.returncode}", + artifacts, + details, + ) + + # Phase 3: stale client reconciles the remote rename using existing DB/local state. + # No --resync here, because this is specifically a reconciliation test. + stale_sync_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_stale), + ] + context.log(f"Executing Test Case {self.case_id} phase3 stale reconcile: {command_to_string(stale_sync_command)}") + stale_sync_result = run_command(stale_sync_command, cwd=context.repo_root) + write_text_file(stale_sync_stdout, stale_sync_result.stdout) + write_text_file(stale_sync_stderr, stale_sync_result.stderr) + details["stale_reconcile_returncode"] = stale_sync_result.returncode + + stale_manifest = build_manifest(stale_root) + write_manifest(stale_manifest_file, stale_manifest) + + details["stale_old_exists_after_reconcile"] = stale_old_path.exists() + details["stale_new_exists_after_reconcile"] = stale_new_path.exists() + stale_new_content = stale_new_path.read_text(encoding="utf-8") if stale_new_path.is_file() else "" + details["stale_new_content"] = stale_new_content + + if stale_sync_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"stale reconciliation phase failed with status {stale_sync_result.returncode}", + artifacts, + details, + ) + + # Final clean remote verification from scratch. + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + details["verify_old_exists"] = verify_old_path.exists() + details["verify_new_exists"] = verify_new_path.exists() + verify_new_content = verify_new_path.read_text(encoding="utf-8") if verify_new_path.is_file() else "" + details["verify_new_content"] = verify_new_content + + self._write_metadata(metadata_file, details) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + + if stale_old_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client still contains old filename after reconciliation: {old_relative}", + artifacts, + details, + ) + + if not stale_new_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client is missing renamed file after reconciliation: {new_relative}", + artifacts, + details, + ) + + if stale_new_content != initial_content: + return TestResult.fail_result( + self.case_id, + self.name, + "stale client renamed file content did not match expected content after reconciliation", + artifacts, + details, + ) + + if verify_old_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification still contains old filename: {old_relative}", + artifacts, + details, + ) + + if not verify_new_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification is missing renamed file: {new_relative}", + artifacts, + details, + ) + + if verify_new_content != initial_content: + return TestResult.fail_result( + self.case_id, + self.name, + "fresh remote verification file content did not match expected content", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From e6f3ae9c4c0f75b2150b30c756aa2bb4ad083813 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Thu, 26 Mar 2026 06:35:38 +1100 Subject: [PATCH 123/245] Add tc0033 Add tc0033 --- ci/e2e/run.py | 2 + ..._remote_directory_rename_reconciliation.py | 554 ++++++++++++++++++ 2 files changed, 556 insertions(+) create mode 100644 ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 7e8c581ba..2274c089a 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -41,6 +41,7 @@ from testcases.tc0030_local_rename_propagation_validation import TestCase0030LocalRenamePropagationValidation from testcases.tc0031_local_directory_rename_propagation_validation import TestCase0031LocalDirectoryRenamePropagationValidation from testcases.tc0032_remote_rename_reconciliation import TestCase0032RemoteRenameReconciliation +from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033RemoteDirectoryRenameReconciliation def build_test_suite() -> list: """ @@ -81,6 +82,7 @@ def build_test_suite() -> list: #TestCase0030LocalRenamePropagationValidation(), #TestCase0031LocalDirectoryRenamePropagationValidation(), TestCase0032RemoteRenameReconciliation(), + TestCase0033RemoteDirectoryRenameReconciliation(), ] diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py new file mode 100644 index 000000000..b50463750 --- /dev/null +++ b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py @@ -0,0 +1,554 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) + + +class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): + case_id = "0033" + name = "remote directory rename reconciliation" + description = ( + "Validate that a stale local client correctly reconciles a remote-side " + "directory rename without leaving stale local leftovers" + ) + + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( + "# tc0033 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) + + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0033" + case_log_dir = context.logs_dir / "tc0033" + state_dir = context.state_dir / "tc0033" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + seed_root = case_work_dir / "seedroot" + stale_root = case_work_dir / "staleroot" + verify_root = case_work_dir / "verifyroot" + + conf_seed = case_work_dir / "conf-seed" + conf_stale = case_work_dir / "conf-stale" + conf_verify = case_work_dir / "conf-verify" + + reset_directory(seed_root) + reset_directory(verify_root) + + context.prepare_minimal_config_dir(conf_seed, "") + context.prepare_minimal_config_dir(conf_verify, "") + + self._write_config(conf_seed, seed_root) + self._write_config(conf_verify, verify_root) + + root_name = f"ZZ_E2E_TC0033_{context.run_id}_{os.getpid()}" + old_dir_relative = f"{root_name}/OriginalDirectory" + new_dir_relative = f"{root_name}/RenamedDirectory" + + old_top_file_relative = f"{old_dir_relative}/top-level.txt" + old_nested_file_relative = f"{old_dir_relative}/Nested/child.txt" + + new_top_file_relative = f"{new_dir_relative}/top-level.txt" + new_nested_file_relative = f"{new_dir_relative}/Nested/child.txt" + + seed_old_dir = seed_root / old_dir_relative + seed_new_dir = seed_root / new_dir_relative + + stale_old_dir = stale_root / old_dir_relative + stale_new_dir = stale_root / new_dir_relative + + verify_old_dir = verify_root / old_dir_relative + verify_new_dir = verify_root / new_dir_relative + + stale_old_top_file = stale_root / old_top_file_relative + stale_old_nested_file = stale_root / old_nested_file_relative + stale_new_top_file = stale_root / new_top_file_relative + stale_new_nested_file = stale_root / new_nested_file_relative + + verify_old_top_file = verify_root / old_top_file_relative + verify_old_nested_file = verify_root / old_nested_file_relative + verify_new_top_file = verify_root / new_top_file_relative + verify_new_nested_file = verify_root / new_nested_file_relative + + top_level_content = ( + "TC0033 remote directory rename reconciliation\n" + "Top-level file content must be preserved.\n" + ) + nested_content = ( + "TC0033 remote directory rename reconciliation\n" + "Nested file content must be preserved.\n" + ) + + seed_stdout = case_log_dir / "phase1_seed_stdout.log" + seed_stderr = case_log_dir / "phase1_seed_stderr.log" + remote_rename_stdout = case_log_dir / "phase2_remote_rename_stdout.log" + remote_rename_stderr = case_log_dir / "phase2_remote_rename_stderr.log" + stale_sync_stdout = case_log_dir / "phase3_stale_reconcile_stdout.log" + stale_sync_stderr = case_log_dir / "phase3_stale_reconcile_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + stale_manifest_file = state_dir / "stale_manifest.txt" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(remote_rename_stdout), + str(remote_rename_stderr), + str(stale_sync_stdout), + str(stale_sync_stderr), + str(verify_stdout), + str(verify_stderr), + str(stale_manifest_file), + str(verify_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "old_dir_relative": old_dir_relative, + "new_dir_relative": new_dir_relative, + "old_top_file_relative": old_top_file_relative, + "old_nested_file_relative": old_nested_file_relative, + "new_top_file_relative": new_top_file_relative, + "new_nested_file_relative": new_nested_file_relative, + "seed_root": str(seed_root), + "stale_root": str(stale_root), + "verify_root": str(verify_root), + "seed_conf_dir": str(conf_seed), + "stale_conf_dir": str(conf_stale), + "verify_conf_dir": str(conf_verify), + } + + # Phase 1: seed original remote state. + (seed_root / old_top_file_relative).parent.mkdir(parents=True, exist_ok=True) + (seed_root / old_nested_file_relative).parent.mkdir(parents=True, exist_ok=True) + write_text_file(seed_root / old_top_file_relative, top_level_content) + write_text_file(seed_root / old_nested_file_relative, nested_content) + + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} phase1 seed: {command_to_string(seed_command)}") + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout) + write_text_file(seed_stderr, seed_result.stderr) + details["seed_returncode"] = seed_result.returncode + + if seed_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {seed_result.returncode}", + artifacts, + details, + ) + + # Snapshot the synchronised local + config/db state to create a stale client. + if conf_stale.exists(): + shutil.rmtree(conf_stale) + if stale_root.exists(): + shutil.rmtree(stale_root) + + shutil.copytree(conf_seed, conf_stale) + shutil.copytree(seed_root, stale_root) + + # Rewrite stale runtime config so it points at stale_root while preserving DB state. + self._write_config(conf_stale, stale_root) + + details["stale_snapshot_old_dir_exists_before_reconcile"] = stale_old_dir.is_dir() + details["stale_snapshot_new_dir_exists_before_reconcile"] = stale_new_dir.exists() + details["stale_snapshot_old_top_file_exists_before_reconcile"] = stale_old_top_file.is_file() + details["stale_snapshot_old_nested_file_exists_before_reconcile"] = stale_old_nested_file.is_file() + + if not stale_old_dir.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "stale snapshot did not preserve original local directory before reconciliation", + artifacts, + details, + ) + + if not stale_old_top_file.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "stale snapshot did not preserve original top-level file before reconciliation", + artifacts, + details, + ) + + if not stale_old_nested_file.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "stale snapshot did not preserve original nested file before reconciliation", + artifacts, + details, + ) + + # Phase 2: perform the rename through the seed client. + # This is our remote-side directory rename mechanism. + seed_old_dir.rename(seed_new_dir) + + details["seed_old_dir_exists_immediately_after_rename"] = seed_old_dir.exists() + details["seed_new_dir_exists_immediately_after_rename"] = seed_new_dir.is_dir() + + if seed_old_dir.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seed local old directory still exists immediately after rename", + artifacts, + details, + ) + + if not seed_new_dir.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seed local renamed directory does not exist immediately after rename", + artifacts, + details, + ) + + if not (seed_new_dir / "top-level.txt").is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seed local renamed directory is missing top-level file immediately after rename", + artifacts, + details, + ) + + if not (seed_new_dir / "Nested" / "child.txt").is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seed local renamed directory is missing nested file immediately after rename", + artifacts, + details, + ) + + remote_rename_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} phase2 remote rename: {command_to_string(remote_rename_command)}") + remote_rename_result = run_command(remote_rename_command, cwd=context.repo_root) + write_text_file(remote_rename_stdout, remote_rename_result.stdout) + write_text_file(remote_rename_stderr, remote_rename_result.stderr) + details["remote_rename_returncode"] = remote_rename_result.returncode + + if remote_rename_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"remote rename propagation phase failed with status {remote_rename_result.returncode}", + artifacts, + details, + ) + + # Phase 3: stale client reconciles the remote rename using existing DB/local state. + # No --resync here, because this is specifically a reconciliation test. + stale_sync_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_stale), + ] + context.log(f"Executing Test Case {self.case_id} phase3 stale reconcile: {command_to_string(stale_sync_command)}") + stale_sync_result = run_command(stale_sync_command, cwd=context.repo_root) + write_text_file(stale_sync_stdout, stale_sync_result.stdout) + write_text_file(stale_sync_stderr, stale_sync_result.stderr) + details["stale_reconcile_returncode"] = stale_sync_result.returncode + + stale_manifest = build_manifest(stale_root) + write_manifest(stale_manifest_file, stale_manifest) + + details["stale_old_dir_exists_after_reconcile"] = stale_old_dir.exists() + details["stale_new_dir_exists_after_reconcile"] = stale_new_dir.is_dir() + details["stale_old_top_file_exists_after_reconcile"] = stale_old_top_file.exists() + details["stale_old_nested_file_exists_after_reconcile"] = stale_old_nested_file.exists() + details["stale_new_top_file_exists_after_reconcile"] = stale_new_top_file.is_file() + details["stale_new_nested_file_exists_after_reconcile"] = stale_new_nested_file.is_file() + + stale_new_top_content = ( + stale_new_top_file.read_text(encoding="utf-8") if stale_new_top_file.is_file() else "" + ) + stale_new_nested_content = ( + stale_new_nested_file.read_text(encoding="utf-8") if stale_new_nested_file.is_file() else "" + ) + details["stale_new_top_content"] = stale_new_top_content + details["stale_new_nested_content"] = stale_new_nested_content + + if stale_sync_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"stale reconciliation phase failed with status {stale_sync_result.returncode}", + artifacts, + details, + ) + + # Final clean remote verification from scratch. + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + details["verify_old_dir_exists"] = verify_old_dir.exists() + details["verify_new_dir_exists"] = verify_new_dir.is_dir() + details["verify_old_top_file_exists"] = verify_old_top_file.exists() + details["verify_old_nested_file_exists"] = verify_old_nested_file.exists() + details["verify_new_top_file_exists"] = verify_new_top_file.is_file() + details["verify_new_nested_file_exists"] = verify_new_nested_file.is_file() + + verify_new_top_content = ( + verify_new_top_file.read_text(encoding="utf-8") if verify_new_top_file.is_file() else "" + ) + verify_new_nested_content = ( + verify_new_nested_file.read_text(encoding="utf-8") if verify_new_nested_file.is_file() else "" + ) + details["verify_new_top_content"] = verify_new_top_content + details["verify_new_nested_content"] = verify_new_nested_content + + self._write_metadata(metadata_file, details) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + + if stale_old_dir.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client still contains old directory after reconciliation: {old_dir_relative}", + artifacts, + details, + ) + + if stale_old_top_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client still contains old top-level file after reconciliation: {old_top_file_relative}", + artifacts, + details, + ) + + if stale_old_nested_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client still contains old nested file after reconciliation: {old_nested_file_relative}", + artifacts, + details, + ) + + if not stale_new_dir.is_dir(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client is missing renamed directory after reconciliation: {new_dir_relative}", + artifacts, + details, + ) + + if not stale_new_top_file.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client is missing renamed top-level file after reconciliation: {new_top_file_relative}", + artifacts, + details, + ) + + if not stale_new_nested_file.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client is missing renamed nested file after reconciliation: {new_nested_file_relative}", + artifacts, + details, + ) + + if stale_new_top_content != top_level_content: + return TestResult.fail_result( + self.case_id, + self.name, + "stale client renamed top-level file content did not match expected content", + artifacts, + details, + ) + + if stale_new_nested_content != nested_content: + return TestResult.fail_result( + self.case_id, + self.name, + "stale client renamed nested file content did not match expected content", + artifacts, + details, + ) + + if verify_old_dir.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification still contains old directory: {old_dir_relative}", + artifacts, + details, + ) + + if verify_old_top_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification still contains old top-level file: {old_top_file_relative}", + artifacts, + details, + ) + + if verify_old_nested_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification still contains old nested file: {old_nested_file_relative}", + artifacts, + details, + ) + + if not verify_new_dir.is_dir(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification is missing renamed directory: {new_dir_relative}", + artifacts, + details, + ) + + if not verify_new_top_file.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification is missing renamed top-level file: {new_top_file_relative}", + artifacts, + details, + ) + + if not verify_new_nested_file.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification is missing renamed nested file: {new_nested_file_relative}", + artifacts, + details, + ) + + if verify_new_top_content != top_level_content: + return TestResult.fail_result( + self.case_id, + self.name, + "fresh remote verification top-level file content did not match expected content", + artifacts, + details, + ) + + if verify_new_nested_content != nested_content: + return TestResult.fail_result( + self.case_id, + self.name, + "fresh remote verification nested file content did not match expected content", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 435907f98ac95b05f5cb9776756297ab82adf78d Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 28 Mar 2026 07:46:32 +1100 Subject: [PATCH 124/245] Update tc0033 Update tc0033 --- ..._remote_directory_rename_reconciliation.py | 443 +++++++++--------- 1 file changed, 228 insertions(+), 215 deletions(-) diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py index b50463750..3df28d0f4 100644 --- a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py +++ b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py @@ -1,55 +1,46 @@ from __future__ import annotations import os -import shutil from pathlib import Path from framework.base import E2ETestCase from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import ( - command_to_string, - compute_quickxor_hash_file, - reset_directory, - run_command, - write_onedrive_config, - write_text_file, -) +from framework.utils import command_to_string, reset_directory, run_command, write_text_file class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): case_id = "0033" name = "remote directory rename reconciliation" description = ( - "Validate that a stale local client correctly reconciles a remote-side " - "directory rename without leaving stale local leftovers" + "Validate that a second client with existing local and database state correctly " + "reconciles a remotely observed directory rename performed by another synchronising client" ) - def _write_config(self, config_dir: Path, sync_dir: Path) -> None: - config_path = config_dir / "config" - backup_path = config_dir / ".config.backup" - hash_path = config_dir / ".config.hash" - - config_text = ( + def _config_text(self, sync_dir: Path) -> str: + return ( "# tc0033 config\n" f'sync_dir = "{sync_dir}"\n' 'bypass_data_preservation = "true"\n' ) - write_onedrive_config(config_path, config_text) - write_onedrive_config(backup_path, config_text) - hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") - os.chmod(config_path, 0o600) - os.chmod(backup_path, 0o600) - os.chmod(hash_path, 0o600) - def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: write_text_file( metadata_file, "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", ) + def _list_files_under(self, root: Path) -> list[str]: + if not root.exists(): + return [] + return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_file()) + + def _list_dirs_under(self, root: Path) -> list[str]: + if not root.exists(): + return [] + return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_dir()) + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0033" case_log_dir = context.logs_dir / "tc0033" @@ -60,108 +51,105 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(state_dir) context.ensure_refresh_token_available() - seed_root = case_work_dir / "seedroot" - stale_root = case_work_dir / "staleroot" - verify_root = case_work_dir / "verifyroot" + seeder_root = case_work_dir / "seeder-root" + validation_root = case_work_dir / "validation-root" + verify_root = case_work_dir / "verify-root" - conf_seed = case_work_dir / "conf-seed" - conf_stale = case_work_dir / "conf-stale" + conf_seeder = case_work_dir / "conf-seeder" + conf_validation = case_work_dir / "conf-validation" conf_verify = case_work_dir / "conf-verify" - reset_directory(seed_root) + reset_directory(seeder_root) + reset_directory(validation_root) reset_directory(verify_root) - context.prepare_minimal_config_dir(conf_seed, "") - context.prepare_minimal_config_dir(conf_verify, "") - - self._write_config(conf_seed, seed_root) - self._write_config(conf_verify, verify_root) + context.prepare_minimal_config_dir(conf_seeder, self._config_text(seeder_root)) + context.prepare_minimal_config_dir(conf_validation, self._config_text(validation_root)) + context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) root_name = f"ZZ_E2E_TC0033_{context.run_id}_{os.getpid()}" - old_dir_relative = f"{root_name}/OriginalDirectory" - new_dir_relative = f"{root_name}/RenamedDirectory" - old_top_file_relative = f"{old_dir_relative}/top-level.txt" - old_nested_file_relative = f"{old_dir_relative}/Nested/child.txt" + original_dir_relative = f"{root_name}/OriginalDirectory" + renamed_dir_relative = f"{root_name}/RenamedDirectory" - new_top_file_relative = f"{new_dir_relative}/top-level.txt" - new_nested_file_relative = f"{new_dir_relative}/Nested/child.txt" + original_top_file_relative = f"{original_dir_relative}/top-level.txt" + original_nested_file_relative = f"{original_dir_relative}/Nested/child.txt" - seed_old_dir = seed_root / old_dir_relative - seed_new_dir = seed_root / new_dir_relative + renamed_top_file_relative = f"{renamed_dir_relative}/top-level.txt" + renamed_nested_file_relative = f"{renamed_dir_relative}/Nested/child.txt" - stale_old_dir = stale_root / old_dir_relative - stale_new_dir = stale_root / new_dir_relative + seeder_original_dir = seeder_root / original_dir_relative + seeder_renamed_dir = seeder_root / renamed_dir_relative - verify_old_dir = verify_root / old_dir_relative - verify_new_dir = verify_root / new_dir_relative + validation_original_dir = validation_root / original_dir_relative + validation_renamed_dir = validation_root / renamed_dir_relative - stale_old_top_file = stale_root / old_top_file_relative - stale_old_nested_file = stale_root / old_nested_file_relative - stale_new_top_file = stale_root / new_top_file_relative - stale_new_nested_file = stale_root / new_nested_file_relative + verify_original_dir = verify_root / original_dir_relative + verify_renamed_dir = verify_root / renamed_dir_relative - verify_old_top_file = verify_root / old_top_file_relative - verify_old_nested_file = verify_root / old_nested_file_relative - verify_new_top_file = verify_root / new_top_file_relative - verify_new_nested_file = verify_root / new_nested_file_relative + validation_original_top_file = validation_root / original_top_file_relative + validation_original_nested_file = validation_root / original_nested_file_relative + validation_renamed_top_file = validation_root / renamed_top_file_relative + validation_renamed_nested_file = validation_root / renamed_nested_file_relative - top_level_content = ( - "TC0033 remote directory rename reconciliation\n" - "Top-level file content must be preserved.\n" - ) - nested_content = ( - "TC0033 remote directory rename reconciliation\n" - "Nested file content must be preserved.\n" - ) + verify_original_top_file = verify_root / original_top_file_relative + verify_original_nested_file = verify_root / original_nested_file_relative + verify_renamed_top_file = verify_root / renamed_top_file_relative + verify_renamed_nested_file = verify_root / renamed_nested_file_relative + + top_level_content = "tc0033 top level file\n" + nested_content = "tc0033 nested child file\n" seed_stdout = case_log_dir / "phase1_seed_stdout.log" seed_stderr = case_log_dir / "phase1_seed_stderr.log" - remote_rename_stdout = case_log_dir / "phase2_remote_rename_stdout.log" - remote_rename_stderr = case_log_dir / "phase2_remote_rename_stderr.log" - stale_sync_stdout = case_log_dir / "phase3_stale_reconcile_stdout.log" - stale_sync_stderr = case_log_dir / "phase3_stale_reconcile_stderr.log" + validation_initial_stdout = case_log_dir / "phase2_validation_initial_stdout.log" + validation_initial_stderr = case_log_dir / "phase2_validation_initial_stderr.log" + seeder_rename_stdout = case_log_dir / "phase3_seeder_rename_stdout.log" + seeder_rename_stderr = case_log_dir / "phase3_seeder_rename_stderr.log" + validation_reconcile_stdout = case_log_dir / "phase4_validation_reconcile_stdout.log" + validation_reconcile_stderr = case_log_dir / "phase4_validation_reconcile_stderr.log" verify_stdout = case_log_dir / "verify_stdout.log" verify_stderr = case_log_dir / "verify_stderr.log" - stale_manifest_file = state_dir / "stale_manifest.txt" + + validation_manifest_file = state_dir / "validation_manifest.txt" verify_manifest_file = state_dir / "verify_manifest.txt" metadata_file = state_dir / "metadata.txt" artifacts = [ str(seed_stdout), str(seed_stderr), - str(remote_rename_stdout), - str(remote_rename_stderr), - str(stale_sync_stdout), - str(stale_sync_stderr), + str(validation_initial_stdout), + str(validation_initial_stderr), + str(seeder_rename_stdout), + str(seeder_rename_stderr), + str(validation_reconcile_stdout), + str(validation_reconcile_stderr), str(verify_stdout), str(verify_stderr), - str(stale_manifest_file), + str(validation_manifest_file), str(verify_manifest_file), str(metadata_file), ] details: dict[str, object] = { "root_name": root_name, - "old_dir_relative": old_dir_relative, - "new_dir_relative": new_dir_relative, - "old_top_file_relative": old_top_file_relative, - "old_nested_file_relative": old_nested_file_relative, - "new_top_file_relative": new_top_file_relative, - "new_nested_file_relative": new_nested_file_relative, - "seed_root": str(seed_root), - "stale_root": str(stale_root), - "verify_root": str(verify_root), - "seed_conf_dir": str(conf_seed), - "stale_conf_dir": str(conf_stale), + "original_dir_relative": original_dir_relative, + "renamed_dir_relative": renamed_dir_relative, + "original_top_file_relative": original_top_file_relative, + "original_nested_file_relative": original_nested_file_relative, + "renamed_top_file_relative": renamed_top_file_relative, + "renamed_nested_file_relative": renamed_nested_file_relative, + "seeder_conf_dir": str(conf_seeder), + "validation_conf_dir": str(conf_validation), "verify_conf_dir": str(conf_verify), + "seeder_root": str(seeder_root), + "validation_root": str(validation_root), + "verify_root": str(verify_root), } - # Phase 1: seed original remote state. - (seed_root / old_top_file_relative).parent.mkdir(parents=True, exist_ok=True) - (seed_root / old_nested_file_relative).parent.mkdir(parents=True, exist_ok=True) - write_text_file(seed_root / old_top_file_relative, top_level_content) - write_text_file(seed_root / old_nested_file_relative, nested_content) + # Phase 1: Seeder creates the original directory tree locally and syncs it. + write_text_file(seeder_root / original_top_file_relative, top_level_content) + write_text_file(seeder_root / original_nested_file_relative, nested_content) seed_command = [ context.onedrive_bin, @@ -171,13 +159,13 @@ def run(self, context: E2EContext) -> TestResult: "--single-directory", root_name, "--confdir", - str(conf_seed), + str(conf_seeder), ] context.log(f"Executing Test Case {self.case_id} phase1 seed: {command_to_string(seed_command)}") seed_result = run_command(seed_command, cwd=context.repo_root) write_text_file(seed_stdout, seed_result.stdout) write_text_file(seed_stderr, seed_result.stderr) - details["seed_returncode"] = seed_result.returncode + details["phase1_seed_returncode"] = seed_result.returncode if seed_result.returncode != 0: self._write_metadata(metadata_file, details) @@ -189,101 +177,99 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Snapshot the synchronised local + config/db state to create a stale client. - if conf_stale.exists(): - shutil.rmtree(conf_stale) - if stale_root.exists(): - shutil.rmtree(stale_root) - - shutil.copytree(conf_seed, conf_stale) - shutil.copytree(seed_root, stale_root) - - # Rewrite stale runtime config so it points at stale_root while preserving DB state. - self._write_config(conf_stale, stale_root) + # Phase 2: Validation client downloads the initial original directory tree. + validation_initial_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_validation), + ] + context.log( + f"Executing Test Case {self.case_id} phase2 validation initial download: " + f"{command_to_string(validation_initial_command)}" + ) + validation_initial_result = run_command(validation_initial_command, cwd=context.repo_root) + write_text_file(validation_initial_stdout, validation_initial_result.stdout) + write_text_file(validation_initial_stderr, validation_initial_result.stderr) + details["phase2_validation_initial_returncode"] = validation_initial_result.returncode - details["stale_snapshot_old_dir_exists_before_reconcile"] = stale_old_dir.is_dir() - details["stale_snapshot_new_dir_exists_before_reconcile"] = stale_new_dir.exists() - details["stale_snapshot_old_top_file_exists_before_reconcile"] = stale_old_top_file.is_file() - details["stale_snapshot_old_nested_file_exists_before_reconcile"] = stale_old_nested_file.is_file() + details["validation_initial_original_dir_exists"] = validation_original_dir.is_dir() + details["validation_initial_original_top_file_exists"] = validation_original_top_file.is_file() + details["validation_initial_original_nested_file_exists"] = validation_original_nested_file.is_file() + details["validation_initial_renamed_dir_exists"] = validation_renamed_dir.exists() - if not stale_old_dir.is_dir(): + if validation_initial_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "stale snapshot did not preserve original local directory before reconciliation", + f"validation initial download phase failed with status {validation_initial_result.returncode}", artifacts, details, ) - if not stale_old_top_file.is_file(): + if not validation_original_dir.is_dir(): self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "stale snapshot did not preserve original top-level file before reconciliation", + f"validation client failed to download original directory: {original_dir_relative}", artifacts, details, ) - if not stale_old_nested_file.is_file(): + if not validation_original_top_file.is_file(): self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "stale snapshot did not preserve original nested file before reconciliation", + f"validation client failed to download original top-level file: {original_top_file_relative}", artifacts, details, ) - # Phase 2: perform the rename through the seed client. - # This is our remote-side directory rename mechanism. - seed_old_dir.rename(seed_new_dir) - - details["seed_old_dir_exists_immediately_after_rename"] = seed_old_dir.exists() - details["seed_new_dir_exists_immediately_after_rename"] = seed_new_dir.is_dir() - - if seed_old_dir.exists(): + if not validation_original_nested_file.is_file(): self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "seed local old directory still exists immediately after rename", + f"validation client failed to download original nested file: {original_nested_file_relative}", artifacts, details, ) - if not seed_new_dir.is_dir(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "seed local renamed directory does not exist immediately after rename", - artifacts, - details, - ) + # Phase 3: Seeder renames the directory locally and performs a normal sync. + seeder_original_dir.rename(seeder_renamed_dir) - if not (seed_new_dir / "top-level.txt").is_file(): + details["seeder_original_dir_exists_after_local_rename"] = seeder_original_dir.exists() + details["seeder_renamed_dir_exists_after_local_rename"] = seeder_renamed_dir.is_dir() + + if seeder_original_dir.exists(): self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "seed local renamed directory is missing top-level file immediately after rename", + "seeder original directory still exists immediately after local rename", artifacts, details, ) - if not (seed_new_dir / "Nested" / "child.txt").is_file(): + if not seeder_renamed_dir.is_dir(): self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "seed local renamed directory is missing nested file immediately after rename", + "seeder renamed directory does not exist immediately after local rename", artifacts, details, ) - remote_rename_command = [ + seeder_rename_command = [ context.onedrive_bin, "--display-running-config", "--sync", @@ -291,27 +277,29 @@ def run(self, context: E2EContext) -> TestResult: "--single-directory", root_name, "--confdir", - str(conf_seed), + str(conf_seeder), ] - context.log(f"Executing Test Case {self.case_id} phase2 remote rename: {command_to_string(remote_rename_command)}") - remote_rename_result = run_command(remote_rename_command, cwd=context.repo_root) - write_text_file(remote_rename_stdout, remote_rename_result.stdout) - write_text_file(remote_rename_stderr, remote_rename_result.stderr) - details["remote_rename_returncode"] = remote_rename_result.returncode + context.log( + f"Executing Test Case {self.case_id} phase3 seeder rename sync: " + f"{command_to_string(seeder_rename_command)}" + ) + seeder_rename_result = run_command(seeder_rename_command, cwd=context.repo_root) + write_text_file(seeder_rename_stdout, seeder_rename_result.stdout) + write_text_file(seeder_rename_stderr, seeder_rename_result.stderr) + details["phase3_seeder_rename_returncode"] = seeder_rename_result.returncode - if remote_rename_result.returncode != 0: + if seeder_rename_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"remote rename propagation phase failed with status {remote_rename_result.returncode}", + f"seeder rename sync phase failed with status {seeder_rename_result.returncode}", artifacts, details, ) - # Phase 3: stale client reconciles the remote rename using existing DB/local state. - # No --resync here, because this is specifically a reconciliation test. - stale_sync_command = [ + # Phase 4: Validation client re-runs download-only using its existing local/database state. + validation_reconcile_command = [ context.onedrive_bin, "--display-running-config", "--sync", @@ -320,44 +308,56 @@ def run(self, context: E2EContext) -> TestResult: "--single-directory", root_name, "--confdir", - str(conf_stale), + str(conf_validation), ] - context.log(f"Executing Test Case {self.case_id} phase3 stale reconcile: {command_to_string(stale_sync_command)}") - stale_sync_result = run_command(stale_sync_command, cwd=context.repo_root) - write_text_file(stale_sync_stdout, stale_sync_result.stdout) - write_text_file(stale_sync_stderr, stale_sync_result.stderr) - details["stale_reconcile_returncode"] = stale_sync_result.returncode - - stale_manifest = build_manifest(stale_root) - write_manifest(stale_manifest_file, stale_manifest) - - details["stale_old_dir_exists_after_reconcile"] = stale_old_dir.exists() - details["stale_new_dir_exists_after_reconcile"] = stale_new_dir.is_dir() - details["stale_old_top_file_exists_after_reconcile"] = stale_old_top_file.exists() - details["stale_old_nested_file_exists_after_reconcile"] = stale_old_nested_file.exists() - details["stale_new_top_file_exists_after_reconcile"] = stale_new_top_file.is_file() - details["stale_new_nested_file_exists_after_reconcile"] = stale_new_nested_file.is_file() - - stale_new_top_content = ( - stale_new_top_file.read_text(encoding="utf-8") if stale_new_top_file.is_file() else "" + context.log( + f"Executing Test Case {self.case_id} phase4 validation reconcile: " + f"{command_to_string(validation_reconcile_command)}" ) - stale_new_nested_content = ( - stale_new_nested_file.read_text(encoding="utf-8") if stale_new_nested_file.is_file() else "" + validation_reconcile_result = run_command(validation_reconcile_command, cwd=context.repo_root) + write_text_file(validation_reconcile_stdout, validation_reconcile_result.stdout) + write_text_file(validation_reconcile_stderr, validation_reconcile_result.stderr) + details["phase4_validation_reconcile_returncode"] = validation_reconcile_result.returncode + + validation_manifest = build_manifest(validation_root) + write_manifest(validation_manifest_file, validation_manifest) + + details["validation_original_dir_exists_after_reconcile"] = validation_original_dir.exists() + details["validation_renamed_dir_exists_after_reconcile"] = validation_renamed_dir.is_dir() + details["validation_original_top_file_exists_after_reconcile"] = validation_original_top_file.exists() + details["validation_original_nested_file_exists_after_reconcile"] = validation_original_nested_file.exists() + details["validation_renamed_top_file_exists_after_reconcile"] = validation_renamed_top_file.is_file() + details["validation_renamed_nested_file_exists_after_reconcile"] = validation_renamed_nested_file.is_file() + + validation_old_tree_files = self._list_files_under(validation_original_dir) + validation_old_tree_dirs = self._list_dirs_under(validation_original_dir) + details["validation_old_tree_files_after_reconcile"] = validation_old_tree_files + details["validation_old_tree_dirs_after_reconcile"] = validation_old_tree_dirs + + validation_renamed_top_file_content = ( + validation_renamed_top_file.read_text(encoding="utf-8") + if validation_renamed_top_file.is_file() + else "" ) - details["stale_new_top_content"] = stale_new_top_content - details["stale_new_nested_content"] = stale_new_nested_content + validation_renamed_nested_file_content = ( + validation_renamed_nested_file.read_text(encoding="utf-8") + if validation_renamed_nested_file.is_file() + else "" + ) + details["validation_renamed_top_file_content"] = validation_renamed_top_file_content + details["validation_renamed_nested_file_content"] = validation_renamed_nested_file_content - if stale_sync_result.returncode != 0: + if validation_reconcile_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"stale reconciliation phase failed with status {stale_sync_result.returncode}", + f"validation reconcile phase failed with status {validation_reconcile_result.returncode}", artifacts, details, ) - # Final clean remote verification from scratch. + # Final verification from scratch against current remote truth. verify_command = [ context.onedrive_bin, "--display-running-config", @@ -380,21 +380,30 @@ def run(self, context: E2EContext) -> TestResult: verify_manifest = build_manifest(verify_root) write_manifest(verify_manifest_file, verify_manifest) - details["verify_old_dir_exists"] = verify_old_dir.exists() - details["verify_new_dir_exists"] = verify_new_dir.is_dir() - details["verify_old_top_file_exists"] = verify_old_top_file.exists() - details["verify_old_nested_file_exists"] = verify_old_nested_file.exists() - details["verify_new_top_file_exists"] = verify_new_top_file.is_file() - details["verify_new_nested_file_exists"] = verify_new_nested_file.is_file() - - verify_new_top_content = ( - verify_new_top_file.read_text(encoding="utf-8") if verify_new_top_file.is_file() else "" + details["verify_original_dir_exists"] = verify_original_dir.exists() + details["verify_renamed_dir_exists"] = verify_renamed_dir.is_dir() + details["verify_original_top_file_exists"] = verify_original_top_file.exists() + details["verify_original_nested_file_exists"] = verify_original_nested_file.exists() + details["verify_renamed_top_file_exists"] = verify_renamed_top_file.is_file() + details["verify_renamed_nested_file_exists"] = verify_renamed_nested_file.is_file() + + verify_old_tree_files = self._list_files_under(verify_original_dir) + verify_old_tree_dirs = self._list_dirs_under(verify_original_dir) + details["verify_old_tree_files"] = verify_old_tree_files + details["verify_old_tree_dirs"] = verify_old_tree_dirs + + verify_renamed_top_file_content = ( + verify_renamed_top_file.read_text(encoding="utf-8") + if verify_renamed_top_file.is_file() + else "" ) - verify_new_nested_content = ( - verify_new_nested_file.read_text(encoding="utf-8") if verify_new_nested_file.is_file() else "" + verify_renamed_nested_file_content = ( + verify_renamed_nested_file.read_text(encoding="utf-8") + if verify_renamed_nested_file.is_file() + else "" ) - details["verify_new_top_content"] = verify_new_top_content - details["verify_new_nested_content"] = verify_new_nested_content + details["verify_renamed_top_file_content"] = verify_renamed_top_file_content + details["verify_renamed_nested_file_content"] = verify_renamed_nested_file_content self._write_metadata(metadata_file, details) @@ -407,133 +416,137 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if stale_old_dir.exists(): + # Validation client must not retain any old payload files under the original tree. + if validation_original_top_file.exists(): return TestResult.fail_result( self.case_id, self.name, - f"stale client still contains old directory after reconciliation: {old_dir_relative}", + f"validation client still contains old top-level file after reconciliation: {original_top_file_relative}", artifacts, details, ) - if stale_old_top_file.exists(): + if validation_original_nested_file.exists(): return TestResult.fail_result( self.case_id, self.name, - f"stale client still contains old top-level file after reconciliation: {old_top_file_relative}", + f"validation client still contains old nested file after reconciliation: {original_nested_file_relative}", artifacts, details, ) - if stale_old_nested_file.exists(): + if validation_old_tree_files: return TestResult.fail_result( self.case_id, self.name, - f"stale client still contains old nested file after reconciliation: {old_nested_file_relative}", + "validation client retained old payload files somewhere under the original directory tree " + f"after reconciliation: {validation_old_tree_files}", artifacts, details, ) - if not stale_new_dir.is_dir(): + if not validation_renamed_dir.is_dir(): return TestResult.fail_result( self.case_id, self.name, - f"stale client is missing renamed directory after reconciliation: {new_dir_relative}", + f"validation client is missing renamed directory after reconciliation: {renamed_dir_relative}", artifacts, details, ) - if not stale_new_top_file.is_file(): + if not validation_renamed_top_file.is_file(): return TestResult.fail_result( self.case_id, self.name, - f"stale client is missing renamed top-level file after reconciliation: {new_top_file_relative}", + f"validation client is missing renamed top-level file after reconciliation: {renamed_top_file_relative}", artifacts, details, ) - if not stale_new_nested_file.is_file(): + if not validation_renamed_nested_file.is_file(): return TestResult.fail_result( self.case_id, self.name, - f"stale client is missing renamed nested file after reconciliation: {new_nested_file_relative}", + f"validation client is missing renamed nested file after reconciliation: {renamed_nested_file_relative}", artifacts, details, ) - if stale_new_top_content != top_level_content: + if validation_renamed_top_file_content != top_level_content: return TestResult.fail_result( self.case_id, self.name, - "stale client renamed top-level file content did not match expected content", + "validation client renamed top-level file content did not match expected content", artifacts, details, ) - if stale_new_nested_content != nested_content: + if validation_renamed_nested_file_content != nested_content: return TestResult.fail_result( self.case_id, self.name, - "stale client renamed nested file content did not match expected content", + "validation client renamed nested file content did not match expected content", artifacts, details, ) - if verify_old_dir.exists(): + # Fresh verification must also show no old payload files anywhere under the original tree. + if verify_original_top_file.exists(): return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification still contains old directory: {old_dir_relative}", + f"fresh remote verification still contains old top-level file: {original_top_file_relative}", artifacts, details, ) - if verify_old_top_file.exists(): + if verify_original_nested_file.exists(): return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification still contains old top-level file: {old_top_file_relative}", + f"fresh remote verification still contains old nested file: {original_nested_file_relative}", artifacts, details, ) - if verify_old_nested_file.exists(): + if verify_old_tree_files: return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification still contains old nested file: {old_nested_file_relative}", + "fresh remote verification retained old payload files somewhere under the original " + f"directory tree: {verify_old_tree_files}", artifacts, details, ) - if not verify_new_dir.is_dir(): + if not verify_renamed_dir.is_dir(): return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification is missing renamed directory: {new_dir_relative}", + f"fresh remote verification is missing renamed directory: {renamed_dir_relative}", artifacts, details, ) - if not verify_new_top_file.is_file(): + if not verify_renamed_top_file.is_file(): return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification is missing renamed top-level file: {new_top_file_relative}", + f"fresh remote verification is missing renamed top-level file: {renamed_top_file_relative}", artifacts, details, ) - if not verify_new_nested_file.is_file(): + if not verify_renamed_nested_file.is_file(): return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification is missing renamed nested file: {new_nested_file_relative}", + f"fresh remote verification is missing renamed nested file: {renamed_nested_file_relative}", artifacts, details, ) - if verify_new_top_content != top_level_content: + if verify_renamed_top_file_content != top_level_content: return TestResult.fail_result( self.case_id, self.name, @@ -542,7 +555,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if verify_new_nested_content != nested_content: + if verify_renamed_nested_file_content != nested_content: return TestResult.fail_result( self.case_id, self.name, From 57a0e8a9a6112def140bd7e843ed2751e77b4d06 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 28 Mar 2026 10:54:58 +1100 Subject: [PATCH 125/245] Update tc0033 Update tc0033 --- ...irectory_rename_reconciliation-original.py | 567 ++++++++++++++++++ ..._remote_directory_rename_reconciliation.py | 260 +++++++- 2 files changed, 798 insertions(+), 29 deletions(-) create mode 100644 ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation-original.py diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation-original.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation-original.py new file mode 100644 index 000000000..3df28d0f4 --- /dev/null +++ b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation-original.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import command_to_string, reset_directory, run_command, write_text_file + + +class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): + case_id = "0033" + name = "remote directory rename reconciliation" + description = ( + "Validate that a second client with existing local and database state correctly " + "reconciles a remotely observed directory rename performed by another synchronising client" + ) + + def _config_text(self, sync_dir: Path) -> str: + return ( + "# tc0033 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) + + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + def _list_files_under(self, root: Path) -> list[str]: + if not root.exists(): + return [] + return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_file()) + + def _list_dirs_under(self, root: Path) -> list[str]: + if not root.exists(): + return [] + return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_dir()) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0033" + case_log_dir = context.logs_dir / "tc0033" + state_dir = context.state_dir / "tc0033" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + seeder_root = case_work_dir / "seeder-root" + validation_root = case_work_dir / "validation-root" + verify_root = case_work_dir / "verify-root" + + conf_seeder = case_work_dir / "conf-seeder" + conf_validation = case_work_dir / "conf-validation" + conf_verify = case_work_dir / "conf-verify" + + reset_directory(seeder_root) + reset_directory(validation_root) + reset_directory(verify_root) + + context.prepare_minimal_config_dir(conf_seeder, self._config_text(seeder_root)) + context.prepare_minimal_config_dir(conf_validation, self._config_text(validation_root)) + context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) + + root_name = f"ZZ_E2E_TC0033_{context.run_id}_{os.getpid()}" + + original_dir_relative = f"{root_name}/OriginalDirectory" + renamed_dir_relative = f"{root_name}/RenamedDirectory" + + original_top_file_relative = f"{original_dir_relative}/top-level.txt" + original_nested_file_relative = f"{original_dir_relative}/Nested/child.txt" + + renamed_top_file_relative = f"{renamed_dir_relative}/top-level.txt" + renamed_nested_file_relative = f"{renamed_dir_relative}/Nested/child.txt" + + seeder_original_dir = seeder_root / original_dir_relative + seeder_renamed_dir = seeder_root / renamed_dir_relative + + validation_original_dir = validation_root / original_dir_relative + validation_renamed_dir = validation_root / renamed_dir_relative + + verify_original_dir = verify_root / original_dir_relative + verify_renamed_dir = verify_root / renamed_dir_relative + + validation_original_top_file = validation_root / original_top_file_relative + validation_original_nested_file = validation_root / original_nested_file_relative + validation_renamed_top_file = validation_root / renamed_top_file_relative + validation_renamed_nested_file = validation_root / renamed_nested_file_relative + + verify_original_top_file = verify_root / original_top_file_relative + verify_original_nested_file = verify_root / original_nested_file_relative + verify_renamed_top_file = verify_root / renamed_top_file_relative + verify_renamed_nested_file = verify_root / renamed_nested_file_relative + + top_level_content = "tc0033 top level file\n" + nested_content = "tc0033 nested child file\n" + + seed_stdout = case_log_dir / "phase1_seed_stdout.log" + seed_stderr = case_log_dir / "phase1_seed_stderr.log" + validation_initial_stdout = case_log_dir / "phase2_validation_initial_stdout.log" + validation_initial_stderr = case_log_dir / "phase2_validation_initial_stderr.log" + seeder_rename_stdout = case_log_dir / "phase3_seeder_rename_stdout.log" + seeder_rename_stderr = case_log_dir / "phase3_seeder_rename_stderr.log" + validation_reconcile_stdout = case_log_dir / "phase4_validation_reconcile_stdout.log" + validation_reconcile_stderr = case_log_dir / "phase4_validation_reconcile_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + + validation_manifest_file = state_dir / "validation_manifest.txt" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(validation_initial_stdout), + str(validation_initial_stderr), + str(seeder_rename_stdout), + str(seeder_rename_stderr), + str(validation_reconcile_stdout), + str(validation_reconcile_stderr), + str(verify_stdout), + str(verify_stderr), + str(validation_manifest_file), + str(verify_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "original_dir_relative": original_dir_relative, + "renamed_dir_relative": renamed_dir_relative, + "original_top_file_relative": original_top_file_relative, + "original_nested_file_relative": original_nested_file_relative, + "renamed_top_file_relative": renamed_top_file_relative, + "renamed_nested_file_relative": renamed_nested_file_relative, + "seeder_conf_dir": str(conf_seeder), + "validation_conf_dir": str(conf_validation), + "verify_conf_dir": str(conf_verify), + "seeder_root": str(seeder_root), + "validation_root": str(validation_root), + "verify_root": str(verify_root), + } + + # Phase 1: Seeder creates the original directory tree locally and syncs it. + write_text_file(seeder_root / original_top_file_relative, top_level_content) + write_text_file(seeder_root / original_nested_file_relative, nested_content) + + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_seeder), + ] + context.log(f"Executing Test Case {self.case_id} phase1 seed: {command_to_string(seed_command)}") + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout) + write_text_file(seed_stderr, seed_result.stderr) + details["phase1_seed_returncode"] = seed_result.returncode + + if seed_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {seed_result.returncode}", + artifacts, + details, + ) + + # Phase 2: Validation client downloads the initial original directory tree. + validation_initial_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_validation), + ] + context.log( + f"Executing Test Case {self.case_id} phase2 validation initial download: " + f"{command_to_string(validation_initial_command)}" + ) + validation_initial_result = run_command(validation_initial_command, cwd=context.repo_root) + write_text_file(validation_initial_stdout, validation_initial_result.stdout) + write_text_file(validation_initial_stderr, validation_initial_result.stderr) + details["phase2_validation_initial_returncode"] = validation_initial_result.returncode + + details["validation_initial_original_dir_exists"] = validation_original_dir.is_dir() + details["validation_initial_original_top_file_exists"] = validation_original_top_file.is_file() + details["validation_initial_original_nested_file_exists"] = validation_original_nested_file.is_file() + details["validation_initial_renamed_dir_exists"] = validation_renamed_dir.exists() + + if validation_initial_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"validation initial download phase failed with status {validation_initial_result.returncode}", + artifacts, + details, + ) + + if not validation_original_dir.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client failed to download original directory: {original_dir_relative}", + artifacts, + details, + ) + + if not validation_original_top_file.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client failed to download original top-level file: {original_top_file_relative}", + artifacts, + details, + ) + + if not validation_original_nested_file.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client failed to download original nested file: {original_nested_file_relative}", + artifacts, + details, + ) + + # Phase 3: Seeder renames the directory locally and performs a normal sync. + seeder_original_dir.rename(seeder_renamed_dir) + + details["seeder_original_dir_exists_after_local_rename"] = seeder_original_dir.exists() + details["seeder_renamed_dir_exists_after_local_rename"] = seeder_renamed_dir.is_dir() + + if seeder_original_dir.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seeder original directory still exists immediately after local rename", + artifacts, + details, + ) + + if not seeder_renamed_dir.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seeder renamed directory does not exist immediately after local rename", + artifacts, + details, + ) + + seeder_rename_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_seeder), + ] + context.log( + f"Executing Test Case {self.case_id} phase3 seeder rename sync: " + f"{command_to_string(seeder_rename_command)}" + ) + seeder_rename_result = run_command(seeder_rename_command, cwd=context.repo_root) + write_text_file(seeder_rename_stdout, seeder_rename_result.stdout) + write_text_file(seeder_rename_stderr, seeder_rename_result.stderr) + details["phase3_seeder_rename_returncode"] = seeder_rename_result.returncode + + if seeder_rename_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seeder rename sync phase failed with status {seeder_rename_result.returncode}", + artifacts, + details, + ) + + # Phase 4: Validation client re-runs download-only using its existing local/database state. + validation_reconcile_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_validation), + ] + context.log( + f"Executing Test Case {self.case_id} phase4 validation reconcile: " + f"{command_to_string(validation_reconcile_command)}" + ) + validation_reconcile_result = run_command(validation_reconcile_command, cwd=context.repo_root) + write_text_file(validation_reconcile_stdout, validation_reconcile_result.stdout) + write_text_file(validation_reconcile_stderr, validation_reconcile_result.stderr) + details["phase4_validation_reconcile_returncode"] = validation_reconcile_result.returncode + + validation_manifest = build_manifest(validation_root) + write_manifest(validation_manifest_file, validation_manifest) + + details["validation_original_dir_exists_after_reconcile"] = validation_original_dir.exists() + details["validation_renamed_dir_exists_after_reconcile"] = validation_renamed_dir.is_dir() + details["validation_original_top_file_exists_after_reconcile"] = validation_original_top_file.exists() + details["validation_original_nested_file_exists_after_reconcile"] = validation_original_nested_file.exists() + details["validation_renamed_top_file_exists_after_reconcile"] = validation_renamed_top_file.is_file() + details["validation_renamed_nested_file_exists_after_reconcile"] = validation_renamed_nested_file.is_file() + + validation_old_tree_files = self._list_files_under(validation_original_dir) + validation_old_tree_dirs = self._list_dirs_under(validation_original_dir) + details["validation_old_tree_files_after_reconcile"] = validation_old_tree_files + details["validation_old_tree_dirs_after_reconcile"] = validation_old_tree_dirs + + validation_renamed_top_file_content = ( + validation_renamed_top_file.read_text(encoding="utf-8") + if validation_renamed_top_file.is_file() + else "" + ) + validation_renamed_nested_file_content = ( + validation_renamed_nested_file.read_text(encoding="utf-8") + if validation_renamed_nested_file.is_file() + else "" + ) + details["validation_renamed_top_file_content"] = validation_renamed_top_file_content + details["validation_renamed_nested_file_content"] = validation_renamed_nested_file_content + + if validation_reconcile_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"validation reconcile phase failed with status {validation_reconcile_result.returncode}", + artifacts, + details, + ) + + # Final verification from scratch against current remote truth. + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + details["verify_original_dir_exists"] = verify_original_dir.exists() + details["verify_renamed_dir_exists"] = verify_renamed_dir.is_dir() + details["verify_original_top_file_exists"] = verify_original_top_file.exists() + details["verify_original_nested_file_exists"] = verify_original_nested_file.exists() + details["verify_renamed_top_file_exists"] = verify_renamed_top_file.is_file() + details["verify_renamed_nested_file_exists"] = verify_renamed_nested_file.is_file() + + verify_old_tree_files = self._list_files_under(verify_original_dir) + verify_old_tree_dirs = self._list_dirs_under(verify_original_dir) + details["verify_old_tree_files"] = verify_old_tree_files + details["verify_old_tree_dirs"] = verify_old_tree_dirs + + verify_renamed_top_file_content = ( + verify_renamed_top_file.read_text(encoding="utf-8") + if verify_renamed_top_file.is_file() + else "" + ) + verify_renamed_nested_file_content = ( + verify_renamed_nested_file.read_text(encoding="utf-8") + if verify_renamed_nested_file.is_file() + else "" + ) + details["verify_renamed_top_file_content"] = verify_renamed_top_file_content + details["verify_renamed_nested_file_content"] = verify_renamed_nested_file_content + + self._write_metadata(metadata_file, details) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + + # Validation client must not retain any old payload files under the original tree. + if validation_original_top_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client still contains old top-level file after reconciliation: {original_top_file_relative}", + artifacts, + details, + ) + + if validation_original_nested_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client still contains old nested file after reconciliation: {original_nested_file_relative}", + artifacts, + details, + ) + + if validation_old_tree_files: + return TestResult.fail_result( + self.case_id, + self.name, + "validation client retained old payload files somewhere under the original directory tree " + f"after reconciliation: {validation_old_tree_files}", + artifacts, + details, + ) + + if not validation_renamed_dir.is_dir(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client is missing renamed directory after reconciliation: {renamed_dir_relative}", + artifacts, + details, + ) + + if not validation_renamed_top_file.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client is missing renamed top-level file after reconciliation: {renamed_top_file_relative}", + artifacts, + details, + ) + + if not validation_renamed_nested_file.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client is missing renamed nested file after reconciliation: {renamed_nested_file_relative}", + artifacts, + details, + ) + + if validation_renamed_top_file_content != top_level_content: + return TestResult.fail_result( + self.case_id, + self.name, + "validation client renamed top-level file content did not match expected content", + artifacts, + details, + ) + + if validation_renamed_nested_file_content != nested_content: + return TestResult.fail_result( + self.case_id, + self.name, + "validation client renamed nested file content did not match expected content", + artifacts, + details, + ) + + # Fresh verification must also show no old payload files anywhere under the original tree. + if verify_original_top_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification still contains old top-level file: {original_top_file_relative}", + artifacts, + details, + ) + + if verify_original_nested_file.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification still contains old nested file: {original_nested_file_relative}", + artifacts, + details, + ) + + if verify_old_tree_files: + return TestResult.fail_result( + self.case_id, + self.name, + "fresh remote verification retained old payload files somewhere under the original " + f"directory tree: {verify_old_tree_files}", + artifacts, + details, + ) + + if not verify_renamed_dir.is_dir(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification is missing renamed directory: {renamed_dir_relative}", + artifacts, + details, + ) + + if not verify_renamed_top_file.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification is missing renamed top-level file: {renamed_top_file_relative}", + artifacts, + details, + ) + + if not verify_renamed_nested_file.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification is missing renamed nested file: {renamed_nested_file_relative}", + artifacts, + details, + ) + + if verify_renamed_top_file_content != top_level_content: + return TestResult.fail_result( + self.case_id, + self.name, + "fresh remote verification top-level file content did not match expected content", + artifacts, + details, + ) + + if verify_renamed_nested_file_content != nested_content: + return TestResult.fail_result( + self.case_id, + self.name, + "fresh remote verification nested file content did not match expected content", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py index 3df28d0f4..f7fcbf396 100644 --- a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py +++ b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py @@ -1,6 +1,9 @@ from __future__ import annotations import os +import signal +import subprocess +import time from pathlib import Path from framework.base import E2ETestCase @@ -14,8 +17,8 @@ class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): case_id = "0033" name = "remote directory rename reconciliation" description = ( - "Validate that a second client with existing local and database state correctly " - "reconciles a remotely observed directory rename performed by another synchronising client" + "Validate that a second client correctly reconciles an online directory rename " + "performed by an independent client running in monitor mode" ) def _config_text(self, sync_dir: Path) -> str: @@ -41,6 +44,110 @@ def _list_dirs_under(self, root: Path) -> list[str]: return [] return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_dir()) + def _read_text_safe(self, path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8", errors="replace") + + def _wait_for_path(self, path: Path, timeout_seconds: int = 60) -> bool: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + if path.exists(): + return True + time.sleep(1) + return path.exists() + + def _wait_for_absence(self, path: Path, timeout_seconds: int = 60) -> bool: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + if not path.exists(): + return True + time.sleep(1) + return not path.exists() + + def _wait_for_log_patterns( + self, + log_path: Path, + required_patterns: list[str], + timeout_seconds: int = 120, + ) -> tuple[bool, str]: + deadline = time.time() + timeout_seconds + latest_text = "" + + while time.time() < deadline: + latest_text = self._read_text_safe(log_path) + if all(pattern in latest_text for pattern in required_patterns): + return True, latest_text + time.sleep(2) + + latest_text = self._read_text_safe(log_path) + return all(pattern in latest_text for pattern in required_patterns), latest_text + + def _start_monitor( + self, + context: E2EContext, + confdir: Path, + stdout_path: Path, + stderr_path: Path, + root_name: str, + ) -> subprocess.Popen: + stdout_handle = open(stdout_path, "w", encoding="utf-8") + stderr_handle = open(stderr_path, "w", encoding="utf-8") + + command = [ + context.onedrive_bin, + "--display-running-config", + "--monitor", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(confdir), + ] + context.log( + f"Executing Test Case {self.case_id} monitor start: {command_to_string(command)}" + ) + + # Start new process group so we can signal it cleanly. + return subprocess.Popen( + command, + cwd=context.repo_root, + stdout=stdout_handle, + stderr=stderr_handle, + text=True, + preexec_fn=os.setsid, + ) + + def _stop_monitor( + self, + process: subprocess.Popen, + timeout_seconds: int = 60, + ) -> int: + if process.poll() is not None: + return process.returncode + + try: + os.killpg(os.getpgid(process.pid), signal.SIGINT) + except ProcessLookupError: + return process.returncode if process.returncode is not None else 0 + + try: + return process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + except ProcessLookupError: + pass + + try: + return process.wait(timeout=20) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + except ProcessLookupError: + pass + return process.wait(timeout=10) + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0033" case_log_dir = context.logs_dir / "tc0033" @@ -97,15 +204,21 @@ def run(self, context: E2EContext) -> TestResult: verify_renamed_top_file = verify_root / renamed_top_file_relative verify_renamed_nested_file = verify_root / renamed_nested_file_relative - top_level_content = "tc0033 top level file\n" - nested_content = "tc0033 nested child file\n" + top_level_content = ( + "TC0033 remote directory rename reconciliation\n" + "Top-level file content must be preserved.\n" + ) + nested_content = ( + "TC0033 remote directory rename reconciliation\n" + "Nested file content must be preserved.\n" + ) seed_stdout = case_log_dir / "phase1_seed_stdout.log" seed_stderr = case_log_dir / "phase1_seed_stderr.log" validation_initial_stdout = case_log_dir / "phase2_validation_initial_stdout.log" validation_initial_stderr = case_log_dir / "phase2_validation_initial_stderr.log" - seeder_rename_stdout = case_log_dir / "phase3_seeder_rename_stdout.log" - seeder_rename_stderr = case_log_dir / "phase3_seeder_rename_stderr.log" + monitor_stdout = case_log_dir / "phase3_monitor_stdout.log" + monitor_stderr = case_log_dir / "phase3_monitor_stderr.log" validation_reconcile_stdout = case_log_dir / "phase4_validation_reconcile_stdout.log" validation_reconcile_stderr = case_log_dir / "phase4_validation_reconcile_stderr.log" verify_stdout = case_log_dir / "verify_stdout.log" @@ -120,8 +233,8 @@ def run(self, context: E2EContext) -> TestResult: str(seed_stderr), str(validation_initial_stdout), str(validation_initial_stderr), - str(seeder_rename_stdout), - str(seeder_rename_stderr), + str(monitor_stdout), + str(monitor_stderr), str(validation_reconcile_stdout), str(validation_reconcile_stderr), str(verify_stdout), @@ -243,13 +356,50 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 3: Seeder renames the directory locally and performs a normal sync. + # Phase 3: Seeder runs in monitor mode and propagates the local rename online. + monitor_process = self._start_monitor( + context=context, + confdir=conf_seeder, + stdout_path=monitor_stdout, + stderr_path=monitor_stderr, + root_name=root_name, + ) + + details["monitor_pid"] = monitor_process.pid + + monitor_ready, monitor_ready_log = self._wait_for_log_patterns( + monitor_stdout, + [ + "Application has been initalised", + "Configuring Global HTTP instance", + ], + timeout_seconds=90, + ) + details["monitor_ready_detected"] = monitor_ready + + if not monitor_ready: + monitor_returncode = self._stop_monitor(monitor_process) + details["monitor_returncode"] = monitor_returncode + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "monitor mode did not become ready before rename operation", + artifacts, + details, + ) + + # Small stabilisation delay so inotify watches are definitely active. + time.sleep(5) + seeder_original_dir.rename(seeder_renamed_dir) details["seeder_original_dir_exists_after_local_rename"] = seeder_original_dir.exists() details["seeder_renamed_dir_exists_after_local_rename"] = seeder_renamed_dir.is_dir() if seeder_original_dir.exists(): + monitor_returncode = self._stop_monitor(monitor_process) + details["monitor_returncode"] = monitor_returncode self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, @@ -260,6 +410,8 @@ def run(self, context: E2EContext) -> TestResult: ) if not seeder_renamed_dir.is_dir(): + monitor_returncode = self._stop_monitor(monitor_process) + details["monitor_returncode"] = monitor_returncode self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, @@ -269,31 +421,43 @@ def run(self, context: E2EContext) -> TestResult: details, ) - seeder_rename_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--verbose", - "--single-directory", - root_name, - "--confdir", - str(conf_seeder), + # Wait for monitor processing to complete sufficiently for downstream reconciliation. + # We require evidence that the renamed tree has been acted on. + monitor_patterns = [ + "RenamedDirectory", + "top-level.txt", + "child.txt", ] - context.log( - f"Executing Test Case {self.case_id} phase3 seeder rename sync: " - f"{command_to_string(seeder_rename_command)}" + rename_seen, monitor_after_rename_log = self._wait_for_log_patterns( + monitor_stdout, + monitor_patterns, + timeout_seconds=180, ) - seeder_rename_result = run_command(seeder_rename_command, cwd=context.repo_root) - write_text_file(seeder_rename_stdout, seeder_rename_result.stdout) - write_text_file(seeder_rename_stderr, seeder_rename_result.stderr) - details["phase3_seeder_rename_returncode"] = seeder_rename_result.returncode + details["monitor_detected_rename_activity"] = rename_seen - if seeder_rename_result.returncode != 0: + # Give monitor a short additional settle period before shutdown. + time.sleep(10) + + monitor_returncode = self._stop_monitor(monitor_process) + details["monitor_returncode"] = monitor_returncode + + # 0 is expected. Some environments may return 130 after SIGINT; accept that. + if monitor_returncode not in (0, 130): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"monitor phase exited with unexpected status {monitor_returncode}", + artifacts, + details, + ) + + if not rename_seen: self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"seeder rename sync phase failed with status {seeder_rename_result.returncode}", + "monitor phase did not show sufficient evidence of rename propagation activity", artifacts, details, ) @@ -416,7 +580,16 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Validation client must not retain any old payload files under the original tree. + # Validation client must no longer have the old directory tree at all. + if validation_original_dir.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validation client still contains old directory after reconciliation: {original_dir_relative}", + artifacts, + details, + ) + if validation_original_top_file.exists(): return TestResult.fail_result( self.case_id, @@ -445,6 +618,16 @@ def run(self, context: E2EContext) -> TestResult: details, ) + if validation_old_tree_dirs: + return TestResult.fail_result( + self.case_id, + self.name, + "validation client retained old directories somewhere under the original directory tree " + f"after reconciliation: {validation_old_tree_dirs}", + artifacts, + details, + ) + if not validation_renamed_dir.is_dir(): return TestResult.fail_result( self.case_id, @@ -490,7 +673,16 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Fresh verification must also show no old payload files anywhere under the original tree. + # Fresh verification must also show the old directory path is gone. + if verify_original_dir.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"fresh remote verification still contains old directory: {original_dir_relative}", + artifacts, + details, + ) + if verify_original_top_file.exists(): return TestResult.fail_result( self.case_id, @@ -519,6 +711,16 @@ def run(self, context: E2EContext) -> TestResult: details, ) + if verify_old_tree_dirs: + return TestResult.fail_result( + self.case_id, + self.name, + "fresh remote verification retained old directories somewhere under the original " + f"directory tree: {verify_old_tree_dirs}", + artifacts, + details, + ) + if not verify_renamed_dir.is_dir(): return TestResult.fail_result( self.case_id, From 7c54992104bc91270da1be5390049258d814f7c5 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 29 Mar 2026 08:42:42 +1100 Subject: [PATCH 126/245] Update tc0033 Update tc0033 --- ..._remote_directory_rename_reconciliation.py | 476 ++---------------- 1 file changed, 36 insertions(+), 440 deletions(-) diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py index f7fcbf396..27c7a7aa0 100644 --- a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py +++ b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py @@ -1,9 +1,6 @@ from __future__ import annotations import os -import signal -import subprocess -import time from pathlib import Path from framework.base import E2ETestCase @@ -13,12 +10,12 @@ from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): +class TestCase0033SeederDirectoryRenameOnlineTruth(E2ETestCase): case_id = "0033" - name = "remote directory rename reconciliation" + name = "seeder directory rename online truth" description = ( - "Validate that a second client correctly reconciles an online directory rename " - "performed by an independent client running in monitor mode" + "Validate that a single syncing client can rename a directory locally, " + "propagate that change online, and that a fresh download sees only the renamed tree" ) def _config_text(self, sync_dir: Path) -> str: @@ -44,110 +41,6 @@ def _list_dirs_under(self, root: Path) -> list[str]: return [] return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_dir()) - def _read_text_safe(self, path: Path) -> str: - if not path.exists(): - return "" - return path.read_text(encoding="utf-8", errors="replace") - - def _wait_for_path(self, path: Path, timeout_seconds: int = 60) -> bool: - deadline = time.time() + timeout_seconds - while time.time() < deadline: - if path.exists(): - return True - time.sleep(1) - return path.exists() - - def _wait_for_absence(self, path: Path, timeout_seconds: int = 60) -> bool: - deadline = time.time() + timeout_seconds - while time.time() < deadline: - if not path.exists(): - return True - time.sleep(1) - return not path.exists() - - def _wait_for_log_patterns( - self, - log_path: Path, - required_patterns: list[str], - timeout_seconds: int = 120, - ) -> tuple[bool, str]: - deadline = time.time() + timeout_seconds - latest_text = "" - - while time.time() < deadline: - latest_text = self._read_text_safe(log_path) - if all(pattern in latest_text for pattern in required_patterns): - return True, latest_text - time.sleep(2) - - latest_text = self._read_text_safe(log_path) - return all(pattern in latest_text for pattern in required_patterns), latest_text - - def _start_monitor( - self, - context: E2EContext, - confdir: Path, - stdout_path: Path, - stderr_path: Path, - root_name: str, - ) -> subprocess.Popen: - stdout_handle = open(stdout_path, "w", encoding="utf-8") - stderr_handle = open(stderr_path, "w", encoding="utf-8") - - command = [ - context.onedrive_bin, - "--display-running-config", - "--monitor", - "--verbose", - "--single-directory", - root_name, - "--confdir", - str(confdir), - ] - context.log( - f"Executing Test Case {self.case_id} monitor start: {command_to_string(command)}" - ) - - # Start new process group so we can signal it cleanly. - return subprocess.Popen( - command, - cwd=context.repo_root, - stdout=stdout_handle, - stderr=stderr_handle, - text=True, - preexec_fn=os.setsid, - ) - - def _stop_monitor( - self, - process: subprocess.Popen, - timeout_seconds: int = 60, - ) -> int: - if process.poll() is not None: - return process.returncode - - try: - os.killpg(os.getpgid(process.pid), signal.SIGINT) - except ProcessLookupError: - return process.returncode if process.returncode is not None else 0 - - try: - return process.wait(timeout=timeout_seconds) - except subprocess.TimeoutExpired: - try: - os.killpg(os.getpgid(process.pid), signal.SIGTERM) - except ProcessLookupError: - pass - - try: - return process.wait(timeout=20) - except subprocess.TimeoutExpired: - try: - os.killpg(os.getpgid(process.pid), signal.SIGKILL) - except ProcessLookupError: - pass - return process.wait(timeout=10) - def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0033" case_log_dir = context.logs_dir / "tc0033" @@ -159,19 +52,15 @@ def run(self, context: E2EContext) -> TestResult: context.ensure_refresh_token_available() seeder_root = case_work_dir / "seeder-root" - validation_root = case_work_dir / "validation-root" verify_root = case_work_dir / "verify-root" conf_seeder = case_work_dir / "conf-seeder" - conf_validation = case_work_dir / "conf-validation" conf_verify = case_work_dir / "conf-verify" reset_directory(seeder_root) - reset_directory(validation_root) reset_directory(verify_root) context.prepare_minimal_config_dir(conf_seeder, self._config_text(seeder_root)) - context.prepare_minimal_config_dir(conf_validation, self._config_text(validation_root)) context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) root_name = f"ZZ_E2E_TC0033_{context.run_id}_{os.getpid()}" @@ -188,58 +77,39 @@ def run(self, context: E2EContext) -> TestResult: seeder_original_dir = seeder_root / original_dir_relative seeder_renamed_dir = seeder_root / renamed_dir_relative - validation_original_dir = validation_root / original_dir_relative - validation_renamed_dir = validation_root / renamed_dir_relative - verify_original_dir = verify_root / original_dir_relative verify_renamed_dir = verify_root / renamed_dir_relative - validation_original_top_file = validation_root / original_top_file_relative - validation_original_nested_file = validation_root / original_nested_file_relative - validation_renamed_top_file = validation_root / renamed_top_file_relative - validation_renamed_nested_file = validation_root / renamed_nested_file_relative - verify_original_top_file = verify_root / original_top_file_relative verify_original_nested_file = verify_root / original_nested_file_relative verify_renamed_top_file = verify_root / renamed_top_file_relative verify_renamed_nested_file = verify_root / renamed_nested_file_relative top_level_content = ( - "TC0033 remote directory rename reconciliation\n" + "TC0033 seeder-only directory rename verification\n" "Top-level file content must be preserved.\n" ) nested_content = ( - "TC0033 remote directory rename reconciliation\n" + "TC0033 seeder-only directory rename verification\n" "Nested file content must be preserved.\n" ) seed_stdout = case_log_dir / "phase1_seed_stdout.log" seed_stderr = case_log_dir / "phase1_seed_stderr.log" - validation_initial_stdout = case_log_dir / "phase2_validation_initial_stdout.log" - validation_initial_stderr = case_log_dir / "phase2_validation_initial_stderr.log" - monitor_stdout = case_log_dir / "phase3_monitor_stdout.log" - monitor_stderr = case_log_dir / "phase3_monitor_stderr.log" - validation_reconcile_stdout = case_log_dir / "phase4_validation_reconcile_stdout.log" - validation_reconcile_stderr = case_log_dir / "phase4_validation_reconcile_stderr.log" - verify_stdout = case_log_dir / "verify_stdout.log" - verify_stderr = case_log_dir / "verify_stderr.log" - - validation_manifest_file = state_dir / "validation_manifest.txt" + rename_sync_stdout = case_log_dir / "phase2_rename_sync_stdout.log" + rename_sync_stderr = case_log_dir / "phase2_rename_sync_stderr.log" + verify_stdout = case_log_dir / "phase3_verify_stdout.log" + verify_stderr = case_log_dir / "phase3_verify_stderr.log" verify_manifest_file = state_dir / "verify_manifest.txt" metadata_file = state_dir / "metadata.txt" artifacts = [ str(seed_stdout), str(seed_stderr), - str(validation_initial_stdout), - str(validation_initial_stderr), - str(monitor_stdout), - str(monitor_stderr), - str(validation_reconcile_stdout), - str(validation_reconcile_stderr), + str(rename_sync_stdout), + str(rename_sync_stderr), str(verify_stdout), str(verify_stderr), - str(validation_manifest_file), str(verify_manifest_file), str(metadata_file), ] @@ -253,14 +123,12 @@ def run(self, context: E2EContext) -> TestResult: "renamed_top_file_relative": renamed_top_file_relative, "renamed_nested_file_relative": renamed_nested_file_relative, "seeder_conf_dir": str(conf_seeder), - "validation_conf_dir": str(conf_validation), "verify_conf_dir": str(conf_verify), "seeder_root": str(seeder_root), - "validation_root": str(validation_root), "verify_root": str(verify_root), } - # Phase 1: Seeder creates the original directory tree locally and syncs it. + # Phase 1: create original tree and sync it online write_text_file(seeder_root / original_top_file_relative, top_level_content) write_text_file(seeder_root / original_nested_file_relative, nested_content) @@ -290,116 +158,13 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 2: Validation client downloads the initial original directory tree. - validation_initial_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--download-only", - "--verbose", - "--single-directory", - root_name, - "--confdir", - str(conf_validation), - ] - context.log( - f"Executing Test Case {self.case_id} phase2 validation initial download: " - f"{command_to_string(validation_initial_command)}" - ) - validation_initial_result = run_command(validation_initial_command, cwd=context.repo_root) - write_text_file(validation_initial_stdout, validation_initial_result.stdout) - write_text_file(validation_initial_stderr, validation_initial_result.stderr) - details["phase2_validation_initial_returncode"] = validation_initial_result.returncode - - details["validation_initial_original_dir_exists"] = validation_original_dir.is_dir() - details["validation_initial_original_top_file_exists"] = validation_original_top_file.is_file() - details["validation_initial_original_nested_file_exists"] = validation_original_nested_file.is_file() - details["validation_initial_renamed_dir_exists"] = validation_renamed_dir.exists() - - if validation_initial_result.returncode != 0: - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation initial download phase failed with status {validation_initial_result.returncode}", - artifacts, - details, - ) - - if not validation_original_dir.is_dir(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client failed to download original directory: {original_dir_relative}", - artifacts, - details, - ) - - if not validation_original_top_file.is_file(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client failed to download original top-level file: {original_top_file_relative}", - artifacts, - details, - ) - - if not validation_original_nested_file.is_file(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client failed to download original nested file: {original_nested_file_relative}", - artifacts, - details, - ) - - # Phase 3: Seeder runs in monitor mode and propagates the local rename online. - monitor_process = self._start_monitor( - context=context, - confdir=conf_seeder, - stdout_path=monitor_stdout, - stderr_path=monitor_stderr, - root_name=root_name, - ) - - details["monitor_pid"] = monitor_process.pid - - monitor_ready, monitor_ready_log = self._wait_for_log_patterns( - monitor_stdout, - [ - "Application has been initalised", - "Configuring Global HTTP instance", - ], - timeout_seconds=90, - ) - details["monitor_ready_detected"] = monitor_ready - - if not monitor_ready: - monitor_returncode = self._stop_monitor(monitor_process) - details["monitor_returncode"] = monitor_returncode - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "monitor mode did not become ready before rename operation", - artifacts, - details, - ) - - # Small stabilisation delay so inotify watches are definitely active. - time.sleep(5) - + # Phase 2: rename locally and sync the rename online seeder_original_dir.rename(seeder_renamed_dir) details["seeder_original_dir_exists_after_local_rename"] = seeder_original_dir.exists() details["seeder_renamed_dir_exists_after_local_rename"] = seeder_renamed_dir.is_dir() if seeder_original_dir.exists(): - monitor_returncode = self._stop_monitor(monitor_process) - details["monitor_returncode"] = monitor_returncode self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, @@ -410,8 +175,6 @@ def run(self, context: E2EContext) -> TestResult: ) if not seeder_renamed_dir.is_dir(): - monitor_returncode = self._stop_monitor(monitor_process) - details["monitor_returncode"] = monitor_returncode self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, @@ -421,107 +184,35 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Wait for monitor processing to complete sufficiently for downstream reconciliation. - # We require evidence that the renamed tree has been acted on. - monitor_patterns = [ - "RenamedDirectory", - "top-level.txt", - "child.txt", - ] - rename_seen, monitor_after_rename_log = self._wait_for_log_patterns( - monitor_stdout, - monitor_patterns, - timeout_seconds=180, - ) - details["monitor_detected_rename_activity"] = rename_seen - - # Give monitor a short additional settle period before shutdown. - time.sleep(10) - - monitor_returncode = self._stop_monitor(monitor_process) - details["monitor_returncode"] = monitor_returncode - - # 0 is expected. Some environments may return 130 after SIGINT; accept that. - if monitor_returncode not in (0, 130): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"monitor phase exited with unexpected status {monitor_returncode}", - artifacts, - details, - ) - - if not rename_seen: - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "monitor phase did not show sufficient evidence of rename propagation activity", - artifacts, - details, - ) - - # Phase 4: Validation client re-runs download-only using its existing local/database state. - validation_reconcile_command = [ + rename_sync_command = [ context.onedrive_bin, "--display-running-config", "--sync", - "--download-only", "--verbose", "--single-directory", root_name, "--confdir", - str(conf_validation), + str(conf_seeder), ] context.log( - f"Executing Test Case {self.case_id} phase4 validation reconcile: " - f"{command_to_string(validation_reconcile_command)}" + f"Executing Test Case {self.case_id} phase2 rename sync: {command_to_string(rename_sync_command)}" ) - validation_reconcile_result = run_command(validation_reconcile_command, cwd=context.repo_root) - write_text_file(validation_reconcile_stdout, validation_reconcile_result.stdout) - write_text_file(validation_reconcile_stderr, validation_reconcile_result.stderr) - details["phase4_validation_reconcile_returncode"] = validation_reconcile_result.returncode - - validation_manifest = build_manifest(validation_root) - write_manifest(validation_manifest_file, validation_manifest) - - details["validation_original_dir_exists_after_reconcile"] = validation_original_dir.exists() - details["validation_renamed_dir_exists_after_reconcile"] = validation_renamed_dir.is_dir() - details["validation_original_top_file_exists_after_reconcile"] = validation_original_top_file.exists() - details["validation_original_nested_file_exists_after_reconcile"] = validation_original_nested_file.exists() - details["validation_renamed_top_file_exists_after_reconcile"] = validation_renamed_top_file.is_file() - details["validation_renamed_nested_file_exists_after_reconcile"] = validation_renamed_nested_file.is_file() - - validation_old_tree_files = self._list_files_under(validation_original_dir) - validation_old_tree_dirs = self._list_dirs_under(validation_original_dir) - details["validation_old_tree_files_after_reconcile"] = validation_old_tree_files - details["validation_old_tree_dirs_after_reconcile"] = validation_old_tree_dirs - - validation_renamed_top_file_content = ( - validation_renamed_top_file.read_text(encoding="utf-8") - if validation_renamed_top_file.is_file() - else "" - ) - validation_renamed_nested_file_content = ( - validation_renamed_nested_file.read_text(encoding="utf-8") - if validation_renamed_nested_file.is_file() - else "" - ) - details["validation_renamed_top_file_content"] = validation_renamed_top_file_content - details["validation_renamed_nested_file_content"] = validation_renamed_nested_file_content + rename_sync_result = run_command(rename_sync_command, cwd=context.repo_root) + write_text_file(rename_sync_stdout, rename_sync_result.stdout) + write_text_file(rename_sync_stderr, rename_sync_result.stderr) + details["phase2_rename_sync_returncode"] = rename_sync_result.returncode - if validation_reconcile_result.returncode != 0: + if rename_sync_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"validation reconcile phase failed with status {validation_reconcile_result.returncode}", + f"rename sync phase failed with status {rename_sync_result.returncode}", artifacts, details, ) - # Final verification from scratch against current remote truth. + # Phase 3: fresh verify client downloads current remote truth verify_command = [ context.onedrive_bin, "--display-running-config", @@ -535,11 +226,11 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_verify), ] - context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + context.log(f"Executing Test Case {self.case_id} phase3 verify: {command_to_string(verify_command)}") verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) - details["verify_returncode"] = verify_result.returncode + details["phase3_verify_returncode"] = verify_result.returncode verify_manifest = build_manifest(verify_root) write_manifest(verify_manifest_file, verify_manifest) @@ -556,18 +247,18 @@ def run(self, context: E2EContext) -> TestResult: details["verify_old_tree_files"] = verify_old_tree_files details["verify_old_tree_dirs"] = verify_old_tree_dirs - verify_renamed_top_file_content = ( + verify_new_top_content = ( verify_renamed_top_file.read_text(encoding="utf-8") if verify_renamed_top_file.is_file() else "" ) - verify_renamed_nested_file_content = ( + verify_new_nested_content = ( verify_renamed_nested_file.read_text(encoding="utf-8") if verify_renamed_nested_file.is_file() else "" ) - details["verify_renamed_top_file_content"] = verify_renamed_top_file_content - details["verify_renamed_nested_file_content"] = verify_renamed_nested_file_content + details["verify_renamed_top_file_content"] = verify_new_top_content + details["verify_renamed_nested_file_content"] = verify_new_nested_content self._write_metadata(metadata_file, details) @@ -575,105 +266,12 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - f"remote verification failed with status {verify_result.returncode}", - artifacts, - details, - ) - - # Validation client must no longer have the old directory tree at all. - if validation_original_dir.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client still contains old directory after reconciliation: {original_dir_relative}", - artifacts, - details, - ) - - if validation_original_top_file.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client still contains old top-level file after reconciliation: {original_top_file_relative}", - artifacts, - details, - ) - - if validation_original_nested_file.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client still contains old nested file after reconciliation: {original_nested_file_relative}", - artifacts, - details, - ) - - if validation_old_tree_files: - return TestResult.fail_result( - self.case_id, - self.name, - "validation client retained old payload files somewhere under the original directory tree " - f"after reconciliation: {validation_old_tree_files}", - artifacts, - details, - ) - - if validation_old_tree_dirs: - return TestResult.fail_result( - self.case_id, - self.name, - "validation client retained old directories somewhere under the original directory tree " - f"after reconciliation: {validation_old_tree_dirs}", - artifacts, - details, - ) - - if not validation_renamed_dir.is_dir(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client is missing renamed directory after reconciliation: {renamed_dir_relative}", - artifacts, - details, - ) - - if not validation_renamed_top_file.is_file(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client is missing renamed top-level file after reconciliation: {renamed_top_file_relative}", - artifacts, - details, - ) - - if not validation_renamed_nested_file.is_file(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client is missing renamed nested file after reconciliation: {renamed_nested_file_relative}", - artifacts, - details, - ) - - if validation_renamed_top_file_content != top_level_content: - return TestResult.fail_result( - self.case_id, - self.name, - "validation client renamed top-level file content did not match expected content", - artifacts, - details, - ) - - if validation_renamed_nested_file_content != nested_content: - return TestResult.fail_result( - self.case_id, - self.name, - "validation client renamed nested file content did not match expected content", + f"verify phase failed with status {verify_result.returncode}", artifacts, details, ) - # Fresh verification must also show the old directory path is gone. + # Strict assertions: original tree must be gone if verify_original_dir.exists(): return TestResult.fail_result( self.case_id, @@ -705,8 +303,7 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - "fresh remote verification retained old payload files somewhere under the original " - f"directory tree: {verify_old_tree_files}", + f"fresh remote verification retained old files under original tree: {verify_old_tree_files}", artifacts, details, ) @@ -715,8 +312,7 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - "fresh remote verification retained old directories somewhere under the original " - f"directory tree: {verify_old_tree_dirs}", + f"fresh remote verification retained old directories under original tree: {verify_old_tree_dirs}", artifacts, details, ) @@ -748,7 +344,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if verify_renamed_top_file_content != top_level_content: + if verify_new_top_content != top_level_content: return TestResult.fail_result( self.case_id, self.name, @@ -757,7 +353,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if verify_renamed_nested_file_content != nested_content: + if verify_new_nested_content != nested_content: return TestResult.fail_result( self.case_id, self.name, From 4c44903a8f52c7f8f6627d2e52c1b2371cfdca19 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 29 Mar 2026 08:45:42 +1100 Subject: [PATCH 127/245] Add tc0033 test Add tc0033 test --- ci/e2e/run.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 2274c089a..71c116f54 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -41,7 +41,10 @@ from testcases.tc0030_local_rename_propagation_validation import TestCase0030LocalRenamePropagationValidation from testcases.tc0031_local_directory_rename_propagation_validation import TestCase0031LocalDirectoryRenamePropagationValidation from testcases.tc0032_remote_rename_reconciliation import TestCase0032RemoteRenameReconciliation -from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033RemoteDirectoryRenameReconciliation +#from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033RemoteDirectoryRenameReconciliation +from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033SeederDirectoryRenameOnlineTruth + + def build_test_suite() -> list: """ @@ -82,7 +85,10 @@ def build_test_suite() -> list: #TestCase0030LocalRenamePropagationValidation(), #TestCase0031LocalDirectoryRenamePropagationValidation(), TestCase0032RemoteRenameReconciliation(), - TestCase0033RemoteDirectoryRenameReconciliation(), + #TestCase0033RemoteDirectoryRenameReconciliation(), + + TestCase0033SeederDirectoryRenameOnlineTruth(), + ] From 1877d75c7a141f8e99675dddea00adfae39d9292 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 29 Mar 2026 08:56:14 +1100 Subject: [PATCH 128/245] re-enable tc0031 re-enable tc0031 --- ci/e2e/run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 71c116f54..c97846586 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -82,11 +82,11 @@ def build_test_suite() -> list: #TestCase0027WhitespaceTrailingDotValidation(), #TestCase0028ControlCharacterNonUtf8FilenameValidation(), #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), - #TestCase0030LocalRenamePropagationValidation(), - #TestCase0031LocalDirectoryRenamePropagationValidation(), + TestCase0030LocalRenamePropagationValidation(), + TestCase0031LocalDirectoryRenamePropagationValidation(), TestCase0032RemoteRenameReconciliation(), - #TestCase0033RemoteDirectoryRenameReconciliation(), + #TestCase0033RemoteDirectoryRenameReconciliation(), TestCase0033SeederDirectoryRenameOnlineTruth(), ] From 698deffda7190fe6a88ad09204c5ed089a80a3ac Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 29 Mar 2026 09:22:42 +1100 Subject: [PATCH 129/245] Update tc0033 Update tc0033 --- ci/e2e/run.py | 9 +- ..._remote_directory_rename_reconciliation.py | 408 +++++++++++++----- 2 files changed, 302 insertions(+), 115 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index c97846586..e05a17c38 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -41,9 +41,7 @@ from testcases.tc0030_local_rename_propagation_validation import TestCase0030LocalRenamePropagationValidation from testcases.tc0031_local_directory_rename_propagation_validation import TestCase0031LocalDirectoryRenamePropagationValidation from testcases.tc0032_remote_rename_reconciliation import TestCase0032RemoteRenameReconciliation -#from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033RemoteDirectoryRenameReconciliation -from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033SeederDirectoryRenameOnlineTruth - +from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033RemoteDirectoryRenameReconciliation def build_test_suite() -> list: @@ -85,10 +83,7 @@ def build_test_suite() -> list: TestCase0030LocalRenamePropagationValidation(), TestCase0031LocalDirectoryRenamePropagationValidation(), TestCase0032RemoteRenameReconciliation(), - - #TestCase0033RemoteDirectoryRenameReconciliation(), - TestCase0033SeederDirectoryRenameOnlineTruth(), - + TestCase0033RemoteDirectoryRenameReconciliation(), ] diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py index 27c7a7aa0..5ecafef08 100644 --- a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py +++ b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py @@ -10,19 +10,18 @@ from framework.utils import command_to_string, reset_directory, run_command, write_text_file -class TestCase0033SeederDirectoryRenameOnlineTruth(E2ETestCase): +class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): case_id = "0033" - name = "seeder directory rename online truth" + name = "remote directory rename reconciliation" description = ( - "Validate that a single syncing client can rename a directory locally, " - "propagate that change online, and that a fresh download sees only the renamed tree" + "Validate that a second client with existing local and database state correctly " + "reconciles a remote directory rename propagated by another synchronising client" ) def _config_text(self, sync_dir: Path) -> str: return ( "# tc0033 config\n" f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n' ) def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: @@ -52,124 +51,204 @@ def run(self, context: E2EContext) -> TestResult: context.ensure_refresh_token_available() seeder_root = case_work_dir / "seeder-root" + validator_root = case_work_dir / "validator-root" verify_root = case_work_dir / "verify-root" conf_seeder = case_work_dir / "conf-seeder" + conf_validator = case_work_dir / "conf-validator" conf_verify = case_work_dir / "conf-verify" reset_directory(seeder_root) + reset_directory(validator_root) reset_directory(verify_root) context.prepare_minimal_config_dir(conf_seeder, self._config_text(seeder_root)) + context.prepare_minimal_config_dir(conf_validator, self._config_text(validator_root)) context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) root_name = f"ZZ_E2E_TC0033_{context.run_id}_{os.getpid()}" - - original_dir_relative = f"{root_name}/OriginalDirectory" + source_dir_relative = f"{root_name}/SourceDirectory" renamed_dir_relative = f"{root_name}/RenamedDirectory" - original_top_file_relative = f"{original_dir_relative}/top-level.txt" - original_nested_file_relative = f"{original_dir_relative}/Nested/child.txt" - - renamed_top_file_relative = f"{renamed_dir_relative}/top-level.txt" - renamed_nested_file_relative = f"{renamed_dir_relative}/Nested/child.txt" + source_file_1_relative = f"{source_dir_relative}/top-level.txt" + source_file_2_relative = f"{source_dir_relative}/Nested/child.txt" + renamed_file_1_relative = f"{renamed_dir_relative}/top-level.txt" + renamed_file_2_relative = f"{renamed_dir_relative}/Nested/child.txt" - seeder_original_dir = seeder_root / original_dir_relative + seeder_source_dir = seeder_root / source_dir_relative seeder_renamed_dir = seeder_root / renamed_dir_relative - verify_original_dir = verify_root / original_dir_relative - verify_renamed_dir = verify_root / renamed_dir_relative - - verify_original_top_file = verify_root / original_top_file_relative - verify_original_nested_file = verify_root / original_nested_file_relative - verify_renamed_top_file = verify_root / renamed_top_file_relative - verify_renamed_nested_file = verify_root / renamed_nested_file_relative + validator_source_dir = validator_root / source_dir_relative + validator_renamed_dir = validator_root / renamed_dir_relative - top_level_content = ( - "TC0033 seeder-only directory rename verification\n" - "Top-level file content must be preserved.\n" - ) - nested_content = ( - "TC0033 seeder-only directory rename verification\n" - "Nested file content must be preserved.\n" - ) + verify_source_dir = verify_root / source_dir_relative + verify_renamed_dir = verify_root / renamed_dir_relative - seed_stdout = case_log_dir / "phase1_seed_stdout.log" - seed_stderr = case_log_dir / "phase1_seed_stderr.log" - rename_sync_stdout = case_log_dir / "phase2_rename_sync_stdout.log" - rename_sync_stderr = case_log_dir / "phase2_rename_sync_stderr.log" - verify_stdout = case_log_dir / "phase3_verify_stdout.log" - verify_stderr = case_log_dir / "phase3_verify_stderr.log" + validator_source_file_1 = validator_root / source_file_1_relative + validator_source_file_2 = validator_root / source_file_2_relative + validator_renamed_file_1 = validator_root / renamed_file_1_relative + validator_renamed_file_2 = validator_root / renamed_file_2_relative + + verify_source_file_1 = verify_root / source_file_1_relative + verify_source_file_2 = verify_root / source_file_2_relative + verify_renamed_file_1 = verify_root / renamed_file_1_relative + verify_renamed_file_2 = verify_root / renamed_file_2_relative + + file1_content = "top\n" + file2_content = "child\n" + + phase1_seed_stdout = case_log_dir / "phase1_seed_stdout.log" + phase1_seed_stderr = case_log_dir / "phase1_seed_stderr.log" + phase2_validator_initial_stdout = case_log_dir / "phase2_validator_initial_stdout.log" + phase2_validator_initial_stderr = case_log_dir / "phase2_validator_initial_stderr.log" + phase3_rename_stdout = case_log_dir / "phase3_directory_rename_stdout.log" + phase3_rename_stderr = case_log_dir / "phase3_directory_rename_stderr.log" + phase4_validator_reconcile_stdout = case_log_dir / "phase4_validator_reconcile_stdout.log" + phase4_validator_reconcile_stderr = case_log_dir / "phase4_validator_reconcile_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + validator_manifest_file = state_dir / "validator_manifest.txt" verify_manifest_file = state_dir / "verify_manifest.txt" metadata_file = state_dir / "metadata.txt" artifacts = [ - str(seed_stdout), - str(seed_stderr), - str(rename_sync_stdout), - str(rename_sync_stderr), + str(phase1_seed_stdout), + str(phase1_seed_stderr), + str(phase2_validator_initial_stdout), + str(phase2_validator_initial_stderr), + str(phase3_rename_stdout), + str(phase3_rename_stderr), + str(phase4_validator_reconcile_stdout), + str(phase4_validator_reconcile_stderr), str(verify_stdout), str(verify_stderr), + str(validator_manifest_file), str(verify_manifest_file), str(metadata_file), ] details: dict[str, object] = { "root_name": root_name, - "original_dir_relative": original_dir_relative, + "source_dir_relative": source_dir_relative, "renamed_dir_relative": renamed_dir_relative, - "original_top_file_relative": original_top_file_relative, - "original_nested_file_relative": original_nested_file_relative, - "renamed_top_file_relative": renamed_top_file_relative, - "renamed_nested_file_relative": renamed_nested_file_relative, + "source_file_1_relative": source_file_1_relative, + "source_file_2_relative": source_file_2_relative, + "renamed_file_1_relative": renamed_file_1_relative, + "renamed_file_2_relative": renamed_file_2_relative, "seeder_conf_dir": str(conf_seeder), + "validator_conf_dir": str(conf_validator), "verify_conf_dir": str(conf_verify), "seeder_root": str(seeder_root), + "validator_root": str(validator_root), "verify_root": str(verify_root), } - # Phase 1: create original tree and sync it online - write_text_file(seeder_root / original_top_file_relative, top_level_content) - write_text_file(seeder_root / original_nested_file_relative, nested_content) + # Phase 1: Seeder creates the original local directory tree and syncs it online. + write_text_file(seeder_root / source_file_1_relative, file1_content) + write_text_file(seeder_root / source_file_2_relative, file2_content) + + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--confdir", + str(conf_seeder), + ] + context.log(f"Executing Test Case {self.case_id} phase1 seed: {command_to_string(phase1_command)}") + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_seed_stdout, phase1_result.stdout) + write_text_file(phase1_seed_stderr, phase1_result.stderr) + details["phase1_returncode"] = phase1_result.returncode + + if phase1_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {phase1_result.returncode}", + artifacts, + details, + ) - seed_command = [ + # Phase 2: Validator downloads the original tree into its own local/database state. + phase2_command = [ context.onedrive_bin, "--display-running-config", "--sync", + "--download-only", "--verbose", "--single-directory", root_name, "--confdir", - str(conf_seeder), + str(conf_validator), ] - context.log(f"Executing Test Case {self.case_id} phase1 seed: {command_to_string(seed_command)}") - seed_result = run_command(seed_command, cwd=context.repo_root) - write_text_file(seed_stdout, seed_result.stdout) - write_text_file(seed_stderr, seed_result.stderr) - details["phase1_seed_returncode"] = seed_result.returncode + context.log( + f"Executing Test Case {self.case_id} phase2 validator initial download: " + f"{command_to_string(phase2_command)}" + ) + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_validator_initial_stdout, phase2_result.stdout) + write_text_file(phase2_validator_initial_stderr, phase2_result.stderr) + details["phase2_returncode"] = phase2_result.returncode - if seed_result.returncode != 0: + details["validator_initial_source_dir_exists"] = validator_source_dir.is_dir() + details["validator_initial_source_file_1_exists"] = validator_source_file_1.is_file() + details["validator_initial_source_file_2_exists"] = validator_source_file_2.is_file() + details["validator_initial_renamed_dir_exists"] = validator_renamed_dir.exists() + + if phase2_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"seed phase failed with status {seed_result.returncode}", + f"validator initial download phase failed with status {phase2_result.returncode}", artifacts, details, ) - # Phase 2: rename locally and sync the rename online - seeder_original_dir.rename(seeder_renamed_dir) + if not validator_source_dir.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"validator failed to download original directory: {source_dir_relative}", + artifacts, + details, + ) - details["seeder_original_dir_exists_after_local_rename"] = seeder_original_dir.exists() + if not validator_source_file_1.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"validator failed to download original top-level file: {source_file_1_relative}", + artifacts, + details, + ) + + if not validator_source_file_2.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"validator failed to download original nested file: {source_file_2_relative}", + artifacts, + details, + ) + + # Phase 3: Seeder renames the directory locally and syncs the change online. + seeder_source_dir.rename(seeder_renamed_dir) + + details["seeder_source_dir_exists_after_local_rename"] = seeder_source_dir.exists() details["seeder_renamed_dir_exists_after_local_rename"] = seeder_renamed_dir.is_dir() - if seeder_original_dir.exists(): + if seeder_source_dir.exists(): self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "seeder original directory still exists immediately after local rename", + "seeder original directory still exists immediately after rename", artifacts, details, ) @@ -179,40 +258,98 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - "seeder renamed directory does not exist immediately after local rename", + "seeder renamed directory does not exist immediately after rename", artifacts, details, ) - rename_sync_command = [ + phase3_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--verbose", + "--confdir", + str(conf_seeder), + ] + context.log(f"Executing Test Case {self.case_id} phase3 rename sync: {command_to_string(phase3_command)}") + phase3_result = run_command(phase3_command, cwd=context.repo_root) + write_text_file(phase3_rename_stdout, phase3_result.stdout) + write_text_file(phase3_rename_stderr, phase3_result.stderr) + details["phase3_returncode"] = phase3_result.returncode + details["phase3_deleted_old_directory_online"] = ( + f"Deleting item from Microsoft OneDrive: {root_name}/SourceDirectory" in phase3_result.stdout + ) + + if phase3_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"directory rename propagation phase failed with status {phase3_result.returncode}", + artifacts, + details, + ) + + # Phase 4: Validator re-runs download-only against its existing local/database state. + phase4_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", "--single-directory", root_name, "--confdir", - str(conf_seeder), + str(conf_validator), ] context.log( - f"Executing Test Case {self.case_id} phase2 rename sync: {command_to_string(rename_sync_command)}" + f"Executing Test Case {self.case_id} phase4 validator reconcile: " + f"{command_to_string(phase4_command)}" ) - rename_sync_result = run_command(rename_sync_command, cwd=context.repo_root) - write_text_file(rename_sync_stdout, rename_sync_result.stdout) - write_text_file(rename_sync_stderr, rename_sync_result.stderr) - details["phase2_rename_sync_returncode"] = rename_sync_result.returncode + phase4_result = run_command(phase4_command, cwd=context.repo_root) + write_text_file(phase4_validator_reconcile_stdout, phase4_result.stdout) + write_text_file(phase4_validator_reconcile_stderr, phase4_result.stderr) + details["phase4_returncode"] = phase4_result.returncode + + validator_manifest = build_manifest(validator_root) + write_manifest(validator_manifest_file, validator_manifest) + + details["validator_source_dir_exists_after_reconcile"] = validator_source_dir.exists() + details["validator_renamed_dir_exists_after_reconcile"] = validator_renamed_dir.exists() + details["validator_source_file_1_exists_after_reconcile"] = validator_source_file_1.exists() + details["validator_source_file_2_exists_after_reconcile"] = validator_source_file_2.exists() + details["validator_renamed_file_1_exists_after_reconcile"] = validator_renamed_file_1.exists() + details["validator_renamed_file_2_exists_after_reconcile"] = validator_renamed_file_2.exists() + + validator_old_tree_files = self._list_files_under(validator_source_dir) + validator_old_tree_dirs = self._list_dirs_under(validator_source_dir) + details["validator_old_tree_files_after_reconcile"] = validator_old_tree_files + details["validator_old_tree_dirs_after_reconcile"] = validator_old_tree_dirs + + validator_new_file_1_content = ( + validator_renamed_file_1.read_text(encoding="utf-8") + if validator_renamed_file_1.is_file() + else "" + ) + validator_new_file_2_content = ( + validator_renamed_file_2.read_text(encoding="utf-8") + if validator_renamed_file_2.is_file() + else "" + ) + details["validator_renamed_file_1_content"] = validator_new_file_1_content + details["validator_renamed_file_2_content"] = validator_new_file_2_content - if rename_sync_result.returncode != 0: + if phase4_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"rename sync phase failed with status {rename_sync_result.returncode}", + f"validator reconcile phase failed with status {phase4_result.returncode}", artifacts, details, ) - # Phase 3: fresh verify client downloads current remote truth + # Verify: fresh client downloads current remote truth independently. verify_command = [ context.onedrive_bin, "--display-running-config", @@ -226,39 +363,39 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_verify), ] - context.log(f"Executing Test Case {self.case_id} phase3 verify: {command_to_string(verify_command)}") + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) - details["phase3_verify_returncode"] = verify_result.returncode + details["verify_returncode"] = verify_result.returncode verify_manifest = build_manifest(verify_root) write_manifest(verify_manifest_file, verify_manifest) - details["verify_original_dir_exists"] = verify_original_dir.exists() - details["verify_renamed_dir_exists"] = verify_renamed_dir.is_dir() - details["verify_original_top_file_exists"] = verify_original_top_file.exists() - details["verify_original_nested_file_exists"] = verify_original_nested_file.exists() - details["verify_renamed_top_file_exists"] = verify_renamed_top_file.is_file() - details["verify_renamed_nested_file_exists"] = verify_renamed_nested_file.is_file() + details["verify_source_dir_exists"] = verify_source_dir.exists() + details["verify_renamed_dir_exists"] = verify_renamed_dir.exists() + details["verify_source_file_1_exists"] = verify_source_file_1.exists() + details["verify_source_file_2_exists"] = verify_source_file_2.exists() + details["verify_renamed_file_1_exists"] = verify_renamed_file_1.exists() + details["verify_renamed_file_2_exists"] = verify_renamed_file_2.exists() - verify_old_tree_files = self._list_files_under(verify_original_dir) - verify_old_tree_dirs = self._list_dirs_under(verify_original_dir) + verify_old_tree_files = self._list_files_under(verify_source_dir) + verify_old_tree_dirs = self._list_dirs_under(verify_source_dir) details["verify_old_tree_files"] = verify_old_tree_files details["verify_old_tree_dirs"] = verify_old_tree_dirs - verify_new_top_content = ( - verify_renamed_top_file.read_text(encoding="utf-8") - if verify_renamed_top_file.is_file() + verify_new_file_1_content = ( + verify_renamed_file_1.read_text(encoding="utf-8") + if verify_renamed_file_1.is_file() else "" ) - verify_new_nested_content = ( - verify_renamed_nested_file.read_text(encoding="utf-8") - if verify_renamed_nested_file.is_file() + verify_new_file_2_content = ( + verify_renamed_file_2.read_text(encoding="utf-8") + if verify_renamed_file_2.is_file() else "" ) - details["verify_renamed_top_file_content"] = verify_new_top_content - details["verify_renamed_nested_file_content"] = verify_new_nested_content + details["verify_renamed_file_1_content"] = verify_new_file_1_content + details["verify_renamed_file_2_content"] = verify_new_file_2_content self._write_metadata(metadata_file, details) @@ -266,35 +403,90 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - f"verify phase failed with status {verify_result.returncode}", + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + + # Validator assertions: existing-state client must reconcile cleanly. + if validator_source_dir.exists() or validator_source_file_1.exists() or validator_source_file_2.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validator still contains original directory tree after reconciliation: {source_dir_relative}", + artifacts, + details, + ) + + if validator_old_tree_files: + return TestResult.fail_result( + self.case_id, + self.name, + f"validator retained old files under original directory tree after reconciliation: {validator_old_tree_files}", + artifacts, + details, + ) + + if validator_old_tree_dirs: + return TestResult.fail_result( + self.case_id, + self.name, + f"validator retained old directories under original directory tree after reconciliation: {validator_old_tree_dirs}", + artifacts, + details, + ) + + if not validator_renamed_dir.is_dir(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validator is missing renamed directory after reconciliation: {renamed_dir_relative}", + artifacts, + details, + ) + + if not validator_renamed_file_1.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validator is missing renamed top-level file after reconciliation: {renamed_file_1_relative}", + artifacts, + details, + ) + + if not validator_renamed_file_2.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"validator is missing renamed nested file after reconciliation: {renamed_file_2_relative}", artifacts, details, ) - # Strict assertions: original tree must be gone - if verify_original_dir.exists(): + if validator_new_file_1_content != file1_content: return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification still contains old directory: {original_dir_relative}", + "validator renamed top-level file content did not match expected content", artifacts, details, ) - if verify_original_top_file.exists(): + if validator_new_file_2_content != file2_content: return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification still contains old top-level file: {original_top_file_relative}", + "validator renamed nested file content did not match expected content", artifacts, details, ) - if verify_original_nested_file.exists(): + # Verify assertions: fresh remote truth must also be correct. + if verify_source_dir.exists() or verify_source_file_1.exists() or verify_source_file_2.exists(): return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification still contains old nested file: {original_nested_file_relative}", + f"remote verification still contains original directory tree: {source_dir_relative}", artifacts, details, ) @@ -303,7 +495,7 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification retained old files under original tree: {verify_old_tree_files}", + f"remote verification retained old files under original directory tree: {verify_old_tree_files}", artifacts, details, ) @@ -312,7 +504,7 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification retained old directories under original tree: {verify_old_tree_dirs}", + f"remote verification retained old directories under original directory tree: {verify_old_tree_dirs}", artifacts, details, ) @@ -321,43 +513,43 @@ def run(self, context: E2EContext) -> TestResult: return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification is missing renamed directory: {renamed_dir_relative}", + f"remote verification is missing renamed directory: {renamed_dir_relative}", artifacts, details, ) - if not verify_renamed_top_file.is_file(): + if not verify_renamed_file_1.is_file(): return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification is missing renamed top-level file: {renamed_top_file_relative}", + f"remote verification is missing renamed top-level file: {renamed_file_1_relative}", artifacts, details, ) - if not verify_renamed_nested_file.is_file(): + if not verify_renamed_file_2.is_file(): return TestResult.fail_result( self.case_id, self.name, - f"fresh remote verification is missing renamed nested file: {renamed_nested_file_relative}", + f"remote verification is missing renamed nested file: {renamed_file_2_relative}", artifacts, details, ) - if verify_new_top_content != top_level_content: + if verify_new_file_1_content != file1_content: return TestResult.fail_result( self.case_id, self.name, - "fresh remote verification top-level file content did not match expected content", + "remote verification renamed top-level file content did not match expected content", artifacts, details, ) - if verify_new_nested_content != nested_content: + if verify_new_file_2_content != file2_content: return TestResult.fail_result( self.case_id, self.name, - "fresh remote verification nested file content did not match expected content", + "remote verification renamed nested file content did not match expected content", artifacts, details, ) From 9cee8a5a9a759c50f323c82f3e169146a23a1053 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 29 Mar 2026 09:54:49 +1100 Subject: [PATCH 130/245] Update testcases Update testcases --- ci/e2e/framework/context.py | 55 +- ...030_local_rename_propagation_validation.py | 31 +- ...directory_rename_propagation_validation.py | 31 +- ...irectory_rename_reconciliation-original.py | 567 ------------------ ..._remote_directory_rename_reconciliation.py | 34 +- 5 files changed, 129 insertions(+), 589 deletions(-) delete mode 100644 ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation-original.py diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py index 330faa0eb..c5bd99b54 100644 --- a/ci/e2e/framework/context.py +++ b/ci/e2e/framework/context.py @@ -82,6 +82,44 @@ def ensure_refresh_token_available(self) -> None: f"Required refresh_token file not found at: {self.default_refresh_token_path}" ) + def _extract_config_value(self, config_text: str, key: str) -> str: + for raw_line in config_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + + lhs, rhs = line.split("=", 1) + if lhs.strip() != key: + continue + + value = rhs.strip() + if value.startswith('"') and value.endswith('"') and len(value) >= 2: + value = value[1:-1] + return value.strip() + + return "" + + def validate_generated_config_dir(self, config_dir: Path) -> None: + """ + Validate a generated runtime config dir so target-specific bootstrap + mistakes fail immediately and explicitly. + """ + config_path = config_dir / "config" + if not config_path.is_file(): + raise RuntimeError(f"Generated config file is missing: {config_path}") + + if self.e2e_target != "sharepoint": + return + + config_text = config_path.read_text(encoding="utf-8") + drive_id = self._extract_config_value(config_text, "drive_id") + if not drive_id: + raise RuntimeError( + f"SharePoint target requested but generated config has empty or missing drive_id: {config_path}" + ) + def bootstrap_config_dir(self, config_dir: Path) -> Path: """ Copy the existing refresh_token into a per-test/per-scenario config dir. @@ -99,7 +137,9 @@ def bootstrap_config_dir(self, config_dir: Path) -> Path: base_config_text = get_optional_base_config_text() if base_config_text: write_text_file(config_dir / "config", base_config_text) + os.chmod(config_dir / "config", 0o600) + self.validate_generated_config_dir(config_dir) return destination def prepare_minimal_config_dir(self, config_dir: Path, config_text: str) -> Path: @@ -125,26 +165,29 @@ def prepare_minimal_config_dir(self, config_dir: Path, config_text: str) -> Path shutil.copy2(self.default_refresh_token_path, refresh_token_destination) os.chmod(refresh_token_destination, 0o600) + full_config_text = get_optional_base_config_text() + config_text + config_path = config_dir / "config" - config_path.write_text(config_text, encoding="utf-8") + config_path.write_text(full_config_text, encoding="utf-8") os.chmod(config_path, 0o600) backup_path = config_dir / ".config.backup" - backup_path.write_text(config_text, encoding="utf-8") + backup_path.write_text(full_config_text, encoding="utf-8") os.chmod(backup_path, 0o600) hash_path = config_dir / ".config.hash" hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") os.chmod(hash_path, 0o600) + self.validate_generated_config_dir(config_dir) return config_path - + def log(self, message: str) -> None: ensure_directory(self.out_dir) line = f"[{timestamp_now()}] {message}\n" print(line, end="") write_text_file_append(self.master_log_file, line) - + @property def default_sync_dir(self) -> Path: home = os.environ.get("HOME", "").strip() @@ -159,8 +202,8 @@ def suite_cleanup_config_dir(self) -> Path: @property def suite_cleanup_log_dir(self) -> Path: return self.logs_dir / "_suite_cleanup" - + def bootstrap_suite_cleanup_config_dir(self) -> Path: if self.suite_cleanup_config_dir.exists(): shutil.rmtree(self.suite_cleanup_config_dir) - return self.bootstrap_config_dir(self.suite_cleanup_config_dir) \ No newline at end of file + return self.bootstrap_config_dir(self.suite_cleanup_config_dir) diff --git a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py index 2fe973666..40a556cdb 100644 --- a/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0030_local_rename_propagation_validation.py @@ -7,7 +7,14 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) class TestCase0030LocalRenamePropagationValidation(E2ETestCase): @@ -15,13 +22,24 @@ class TestCase0030LocalRenamePropagationValidation(E2ETestCase): name = "local rename propagation validation" description = "Validate that renaming a local file is correctly propagated to remote state" - def _config_text(self, sync_dir: Path) -> str: - return ( + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( "# tc0030 config\n" f'sync_dir = "{sync_dir}"\n' 'bypass_data_preservation = "true"\n' ) + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: write_text_file( metadata_file, @@ -46,8 +64,11 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - context.prepare_minimal_config_dir(conf_main, self._config_text(local_root)) - context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) + context.prepare_minimal_config_dir(conf_main, "") + context.prepare_minimal_config_dir(conf_verify, "") + + self._write_config(conf_main, local_root) + self._write_config(conf_verify, verify_root) root_name = f"ZZ_E2E_TC0030_{context.run_id}_{os.getpid()}" old_relative = f"{root_name}/original-name.txt" diff --git a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py index 28e629c99..9589f0813 100644 --- a/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py +++ b/ci/e2e/testcases/tc0031_local_directory_rename_propagation_validation.py @@ -7,7 +7,14 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) class TestCase0031LocalDirectoryRenamePropagationValidation(E2ETestCase): @@ -15,12 +22,23 @@ class TestCase0031LocalDirectoryRenamePropagationValidation(E2ETestCase): name = "local directory rename propagation validation" description = "Validate that renaming a local directory tree is correctly propagated to remote state" - def _config_text(self, sync_dir: Path) -> str: - return ( + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( "# tc0031 config\n" f'sync_dir = "{sync_dir}"\n' ) + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: write_text_file( metadata_file, @@ -45,8 +63,11 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(local_root) reset_directory(verify_root) - context.prepare_minimal_config_dir(conf_main, self._config_text(local_root)) - context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) + context.prepare_minimal_config_dir(conf_main, "") + context.prepare_minimal_config_dir(conf_verify, "") + + self._write_config(conf_main, local_root) + self._write_config(conf_verify, verify_root) root_name = f"ZZ_E2E_TC0031_{context.run_id}_{os.getpid()}" source_dir_relative = f"{root_name}/SourceDirectory" diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation-original.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation-original.py deleted file mode 100644 index 3df28d0f4..000000000 --- a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation-original.py +++ /dev/null @@ -1,567 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path - -from framework.base import E2ETestCase -from framework.context import E2EContext -from framework.manifest import build_manifest, write_manifest -from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file - - -class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): - case_id = "0033" - name = "remote directory rename reconciliation" - description = ( - "Validate that a second client with existing local and database state correctly " - "reconciles a remotely observed directory rename performed by another synchronising client" - ) - - def _config_text(self, sync_dir: Path) -> str: - return ( - "# tc0033 config\n" - f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n' - ) - - def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: - write_text_file( - metadata_file, - "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", - ) - - def _list_files_under(self, root: Path) -> list[str]: - if not root.exists(): - return [] - return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_file()) - - def _list_dirs_under(self, root: Path) -> list[str]: - if not root.exists(): - return [] - return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_dir()) - - def run(self, context: E2EContext) -> TestResult: - case_work_dir = context.work_root / "tc0033" - case_log_dir = context.logs_dir / "tc0033" - state_dir = context.state_dir / "tc0033" - - reset_directory(case_work_dir) - reset_directory(case_log_dir) - reset_directory(state_dir) - context.ensure_refresh_token_available() - - seeder_root = case_work_dir / "seeder-root" - validation_root = case_work_dir / "validation-root" - verify_root = case_work_dir / "verify-root" - - conf_seeder = case_work_dir / "conf-seeder" - conf_validation = case_work_dir / "conf-validation" - conf_verify = case_work_dir / "conf-verify" - - reset_directory(seeder_root) - reset_directory(validation_root) - reset_directory(verify_root) - - context.prepare_minimal_config_dir(conf_seeder, self._config_text(seeder_root)) - context.prepare_minimal_config_dir(conf_validation, self._config_text(validation_root)) - context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) - - root_name = f"ZZ_E2E_TC0033_{context.run_id}_{os.getpid()}" - - original_dir_relative = f"{root_name}/OriginalDirectory" - renamed_dir_relative = f"{root_name}/RenamedDirectory" - - original_top_file_relative = f"{original_dir_relative}/top-level.txt" - original_nested_file_relative = f"{original_dir_relative}/Nested/child.txt" - - renamed_top_file_relative = f"{renamed_dir_relative}/top-level.txt" - renamed_nested_file_relative = f"{renamed_dir_relative}/Nested/child.txt" - - seeder_original_dir = seeder_root / original_dir_relative - seeder_renamed_dir = seeder_root / renamed_dir_relative - - validation_original_dir = validation_root / original_dir_relative - validation_renamed_dir = validation_root / renamed_dir_relative - - verify_original_dir = verify_root / original_dir_relative - verify_renamed_dir = verify_root / renamed_dir_relative - - validation_original_top_file = validation_root / original_top_file_relative - validation_original_nested_file = validation_root / original_nested_file_relative - validation_renamed_top_file = validation_root / renamed_top_file_relative - validation_renamed_nested_file = validation_root / renamed_nested_file_relative - - verify_original_top_file = verify_root / original_top_file_relative - verify_original_nested_file = verify_root / original_nested_file_relative - verify_renamed_top_file = verify_root / renamed_top_file_relative - verify_renamed_nested_file = verify_root / renamed_nested_file_relative - - top_level_content = "tc0033 top level file\n" - nested_content = "tc0033 nested child file\n" - - seed_stdout = case_log_dir / "phase1_seed_stdout.log" - seed_stderr = case_log_dir / "phase1_seed_stderr.log" - validation_initial_stdout = case_log_dir / "phase2_validation_initial_stdout.log" - validation_initial_stderr = case_log_dir / "phase2_validation_initial_stderr.log" - seeder_rename_stdout = case_log_dir / "phase3_seeder_rename_stdout.log" - seeder_rename_stderr = case_log_dir / "phase3_seeder_rename_stderr.log" - validation_reconcile_stdout = case_log_dir / "phase4_validation_reconcile_stdout.log" - validation_reconcile_stderr = case_log_dir / "phase4_validation_reconcile_stderr.log" - verify_stdout = case_log_dir / "verify_stdout.log" - verify_stderr = case_log_dir / "verify_stderr.log" - - validation_manifest_file = state_dir / "validation_manifest.txt" - verify_manifest_file = state_dir / "verify_manifest.txt" - metadata_file = state_dir / "metadata.txt" - - artifacts = [ - str(seed_stdout), - str(seed_stderr), - str(validation_initial_stdout), - str(validation_initial_stderr), - str(seeder_rename_stdout), - str(seeder_rename_stderr), - str(validation_reconcile_stdout), - str(validation_reconcile_stderr), - str(verify_stdout), - str(verify_stderr), - str(validation_manifest_file), - str(verify_manifest_file), - str(metadata_file), - ] - - details: dict[str, object] = { - "root_name": root_name, - "original_dir_relative": original_dir_relative, - "renamed_dir_relative": renamed_dir_relative, - "original_top_file_relative": original_top_file_relative, - "original_nested_file_relative": original_nested_file_relative, - "renamed_top_file_relative": renamed_top_file_relative, - "renamed_nested_file_relative": renamed_nested_file_relative, - "seeder_conf_dir": str(conf_seeder), - "validation_conf_dir": str(conf_validation), - "verify_conf_dir": str(conf_verify), - "seeder_root": str(seeder_root), - "validation_root": str(validation_root), - "verify_root": str(verify_root), - } - - # Phase 1: Seeder creates the original directory tree locally and syncs it. - write_text_file(seeder_root / original_top_file_relative, top_level_content) - write_text_file(seeder_root / original_nested_file_relative, nested_content) - - seed_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--verbose", - "--single-directory", - root_name, - "--confdir", - str(conf_seeder), - ] - context.log(f"Executing Test Case {self.case_id} phase1 seed: {command_to_string(seed_command)}") - seed_result = run_command(seed_command, cwd=context.repo_root) - write_text_file(seed_stdout, seed_result.stdout) - write_text_file(seed_stderr, seed_result.stderr) - details["phase1_seed_returncode"] = seed_result.returncode - - if seed_result.returncode != 0: - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"seed phase failed with status {seed_result.returncode}", - artifacts, - details, - ) - - # Phase 2: Validation client downloads the initial original directory tree. - validation_initial_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--download-only", - "--verbose", - "--single-directory", - root_name, - "--confdir", - str(conf_validation), - ] - context.log( - f"Executing Test Case {self.case_id} phase2 validation initial download: " - f"{command_to_string(validation_initial_command)}" - ) - validation_initial_result = run_command(validation_initial_command, cwd=context.repo_root) - write_text_file(validation_initial_stdout, validation_initial_result.stdout) - write_text_file(validation_initial_stderr, validation_initial_result.stderr) - details["phase2_validation_initial_returncode"] = validation_initial_result.returncode - - details["validation_initial_original_dir_exists"] = validation_original_dir.is_dir() - details["validation_initial_original_top_file_exists"] = validation_original_top_file.is_file() - details["validation_initial_original_nested_file_exists"] = validation_original_nested_file.is_file() - details["validation_initial_renamed_dir_exists"] = validation_renamed_dir.exists() - - if validation_initial_result.returncode != 0: - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation initial download phase failed with status {validation_initial_result.returncode}", - artifacts, - details, - ) - - if not validation_original_dir.is_dir(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client failed to download original directory: {original_dir_relative}", - artifacts, - details, - ) - - if not validation_original_top_file.is_file(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client failed to download original top-level file: {original_top_file_relative}", - artifacts, - details, - ) - - if not validation_original_nested_file.is_file(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client failed to download original nested file: {original_nested_file_relative}", - artifacts, - details, - ) - - # Phase 3: Seeder renames the directory locally and performs a normal sync. - seeder_original_dir.rename(seeder_renamed_dir) - - details["seeder_original_dir_exists_after_local_rename"] = seeder_original_dir.exists() - details["seeder_renamed_dir_exists_after_local_rename"] = seeder_renamed_dir.is_dir() - - if seeder_original_dir.exists(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "seeder original directory still exists immediately after local rename", - artifacts, - details, - ) - - if not seeder_renamed_dir.is_dir(): - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "seeder renamed directory does not exist immediately after local rename", - artifacts, - details, - ) - - seeder_rename_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--verbose", - "--single-directory", - root_name, - "--confdir", - str(conf_seeder), - ] - context.log( - f"Executing Test Case {self.case_id} phase3 seeder rename sync: " - f"{command_to_string(seeder_rename_command)}" - ) - seeder_rename_result = run_command(seeder_rename_command, cwd=context.repo_root) - write_text_file(seeder_rename_stdout, seeder_rename_result.stdout) - write_text_file(seeder_rename_stderr, seeder_rename_result.stderr) - details["phase3_seeder_rename_returncode"] = seeder_rename_result.returncode - - if seeder_rename_result.returncode != 0: - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"seeder rename sync phase failed with status {seeder_rename_result.returncode}", - artifacts, - details, - ) - - # Phase 4: Validation client re-runs download-only using its existing local/database state. - validation_reconcile_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--download-only", - "--verbose", - "--single-directory", - root_name, - "--confdir", - str(conf_validation), - ] - context.log( - f"Executing Test Case {self.case_id} phase4 validation reconcile: " - f"{command_to_string(validation_reconcile_command)}" - ) - validation_reconcile_result = run_command(validation_reconcile_command, cwd=context.repo_root) - write_text_file(validation_reconcile_stdout, validation_reconcile_result.stdout) - write_text_file(validation_reconcile_stderr, validation_reconcile_result.stderr) - details["phase4_validation_reconcile_returncode"] = validation_reconcile_result.returncode - - validation_manifest = build_manifest(validation_root) - write_manifest(validation_manifest_file, validation_manifest) - - details["validation_original_dir_exists_after_reconcile"] = validation_original_dir.exists() - details["validation_renamed_dir_exists_after_reconcile"] = validation_renamed_dir.is_dir() - details["validation_original_top_file_exists_after_reconcile"] = validation_original_top_file.exists() - details["validation_original_nested_file_exists_after_reconcile"] = validation_original_nested_file.exists() - details["validation_renamed_top_file_exists_after_reconcile"] = validation_renamed_top_file.is_file() - details["validation_renamed_nested_file_exists_after_reconcile"] = validation_renamed_nested_file.is_file() - - validation_old_tree_files = self._list_files_under(validation_original_dir) - validation_old_tree_dirs = self._list_dirs_under(validation_original_dir) - details["validation_old_tree_files_after_reconcile"] = validation_old_tree_files - details["validation_old_tree_dirs_after_reconcile"] = validation_old_tree_dirs - - validation_renamed_top_file_content = ( - validation_renamed_top_file.read_text(encoding="utf-8") - if validation_renamed_top_file.is_file() - else "" - ) - validation_renamed_nested_file_content = ( - validation_renamed_nested_file.read_text(encoding="utf-8") - if validation_renamed_nested_file.is_file() - else "" - ) - details["validation_renamed_top_file_content"] = validation_renamed_top_file_content - details["validation_renamed_nested_file_content"] = validation_renamed_nested_file_content - - if validation_reconcile_result.returncode != 0: - self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"validation reconcile phase failed with status {validation_reconcile_result.returncode}", - artifacts, - details, - ) - - # Final verification from scratch against current remote truth. - verify_command = [ - context.onedrive_bin, - "--display-running-config", - "--sync", - "--download-only", - "--verbose", - "--resync", - "--resync-auth", - "--single-directory", - root_name, - "--confdir", - str(conf_verify), - ] - context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") - verify_result = run_command(verify_command, cwd=context.repo_root) - write_text_file(verify_stdout, verify_result.stdout) - write_text_file(verify_stderr, verify_result.stderr) - details["verify_returncode"] = verify_result.returncode - - verify_manifest = build_manifest(verify_root) - write_manifest(verify_manifest_file, verify_manifest) - - details["verify_original_dir_exists"] = verify_original_dir.exists() - details["verify_renamed_dir_exists"] = verify_renamed_dir.is_dir() - details["verify_original_top_file_exists"] = verify_original_top_file.exists() - details["verify_original_nested_file_exists"] = verify_original_nested_file.exists() - details["verify_renamed_top_file_exists"] = verify_renamed_top_file.is_file() - details["verify_renamed_nested_file_exists"] = verify_renamed_nested_file.is_file() - - verify_old_tree_files = self._list_files_under(verify_original_dir) - verify_old_tree_dirs = self._list_dirs_under(verify_original_dir) - details["verify_old_tree_files"] = verify_old_tree_files - details["verify_old_tree_dirs"] = verify_old_tree_dirs - - verify_renamed_top_file_content = ( - verify_renamed_top_file.read_text(encoding="utf-8") - if verify_renamed_top_file.is_file() - else "" - ) - verify_renamed_nested_file_content = ( - verify_renamed_nested_file.read_text(encoding="utf-8") - if verify_renamed_nested_file.is_file() - else "" - ) - details["verify_renamed_top_file_content"] = verify_renamed_top_file_content - details["verify_renamed_nested_file_content"] = verify_renamed_nested_file_content - - self._write_metadata(metadata_file, details) - - if verify_result.returncode != 0: - return TestResult.fail_result( - self.case_id, - self.name, - f"remote verification failed with status {verify_result.returncode}", - artifacts, - details, - ) - - # Validation client must not retain any old payload files under the original tree. - if validation_original_top_file.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client still contains old top-level file after reconciliation: {original_top_file_relative}", - artifacts, - details, - ) - - if validation_original_nested_file.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client still contains old nested file after reconciliation: {original_nested_file_relative}", - artifacts, - details, - ) - - if validation_old_tree_files: - return TestResult.fail_result( - self.case_id, - self.name, - "validation client retained old payload files somewhere under the original directory tree " - f"after reconciliation: {validation_old_tree_files}", - artifacts, - details, - ) - - if not validation_renamed_dir.is_dir(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client is missing renamed directory after reconciliation: {renamed_dir_relative}", - artifacts, - details, - ) - - if not validation_renamed_top_file.is_file(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client is missing renamed top-level file after reconciliation: {renamed_top_file_relative}", - artifacts, - details, - ) - - if not validation_renamed_nested_file.is_file(): - return TestResult.fail_result( - self.case_id, - self.name, - f"validation client is missing renamed nested file after reconciliation: {renamed_nested_file_relative}", - artifacts, - details, - ) - - if validation_renamed_top_file_content != top_level_content: - return TestResult.fail_result( - self.case_id, - self.name, - "validation client renamed top-level file content did not match expected content", - artifacts, - details, - ) - - if validation_renamed_nested_file_content != nested_content: - return TestResult.fail_result( - self.case_id, - self.name, - "validation client renamed nested file content did not match expected content", - artifacts, - details, - ) - - # Fresh verification must also show no old payload files anywhere under the original tree. - if verify_original_top_file.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - f"fresh remote verification still contains old top-level file: {original_top_file_relative}", - artifacts, - details, - ) - - if verify_original_nested_file.exists(): - return TestResult.fail_result( - self.case_id, - self.name, - f"fresh remote verification still contains old nested file: {original_nested_file_relative}", - artifacts, - details, - ) - - if verify_old_tree_files: - return TestResult.fail_result( - self.case_id, - self.name, - "fresh remote verification retained old payload files somewhere under the original " - f"directory tree: {verify_old_tree_files}", - artifacts, - details, - ) - - if not verify_renamed_dir.is_dir(): - return TestResult.fail_result( - self.case_id, - self.name, - f"fresh remote verification is missing renamed directory: {renamed_dir_relative}", - artifacts, - details, - ) - - if not verify_renamed_top_file.is_file(): - return TestResult.fail_result( - self.case_id, - self.name, - f"fresh remote verification is missing renamed top-level file: {renamed_top_file_relative}", - artifacts, - details, - ) - - if not verify_renamed_nested_file.is_file(): - return TestResult.fail_result( - self.case_id, - self.name, - f"fresh remote verification is missing renamed nested file: {renamed_nested_file_relative}", - artifacts, - details, - ) - - if verify_renamed_top_file_content != top_level_content: - return TestResult.fail_result( - self.case_id, - self.name, - "fresh remote verification top-level file content did not match expected content", - artifacts, - details, - ) - - if verify_renamed_nested_file_content != nested_content: - return TestResult.fail_result( - self.case_id, - self.name, - "fresh remote verification nested file content did not match expected content", - artifacts, - details, - ) - - return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py index 5ecafef08..ffc8d70e4 100644 --- a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py +++ b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py @@ -7,7 +7,14 @@ from framework.context import E2EContext from framework.manifest import build_manifest, write_manifest from framework.result import TestResult -from framework.utils import command_to_string, reset_directory, run_command, write_text_file +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): @@ -18,12 +25,23 @@ class TestCase0033RemoteDirectoryRenameReconciliation(E2ETestCase): "reconciles a remote directory rename propagated by another synchronising client" ) - def _config_text(self, sync_dir: Path) -> str: - return ( + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( "# tc0033 config\n" f'sync_dir = "{sync_dir}"\n' ) + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: write_text_file( metadata_file, @@ -62,9 +80,13 @@ def run(self, context: E2EContext) -> TestResult: reset_directory(validator_root) reset_directory(verify_root) - context.prepare_minimal_config_dir(conf_seeder, self._config_text(seeder_root)) - context.prepare_minimal_config_dir(conf_validator, self._config_text(validator_root)) - context.prepare_minimal_config_dir(conf_verify, self._config_text(verify_root)) + context.prepare_minimal_config_dir(conf_seeder, "") + context.prepare_minimal_config_dir(conf_validator, "") + context.prepare_minimal_config_dir(conf_verify, "") + + self._write_config(conf_seeder, seeder_root) + self._write_config(conf_validator, validator_root) + self._write_config(conf_verify, verify_root) root_name = f"ZZ_E2E_TC0033_{context.run_id}_{os.getpid()}" source_dir_relative = f"{root_name}/SourceDirectory" From e5a1f0c868004b8a4091a51a40b3f4b58ccad4ba Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 29 Mar 2026 10:02:48 +1100 Subject: [PATCH 131/245] Update context.py Update context.py --- ci/e2e/framework/context.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ci/e2e/framework/context.py b/ci/e2e/framework/context.py index c5bd99b54..5626cecae 100644 --- a/ci/e2e/framework/context.py +++ b/ci/e2e/framework/context.py @@ -105,14 +105,17 @@ def validate_generated_config_dir(self, config_dir: Path) -> None: """ Validate a generated runtime config dir so target-specific bootstrap mistakes fail immediately and explicitly. + Only SharePoint requires a seeded config containing drive_id. """ - config_path = config_dir / "config" - if not config_path.is_file(): - raise RuntimeError(f"Generated config file is missing: {config_path}") - if self.e2e_target != "sharepoint": return + config_path = config_dir / "config" + if not config_path.is_file(): + raise RuntimeError( + f"SharePoint target requested but generated config file is missing: {config_path}" + ) + config_text = config_path.read_text(encoding="utf-8") drive_id = self._extract_config_value(config_text, "drive_id") if not drive_id: From 641a7986db3ee4fa9724acd4f49b23638121613a Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 29 Mar 2026 12:48:37 +1100 Subject: [PATCH 132/245] Test Full Run Test Full Run post tc0032 and tc0033 addition --- ci/e2e/run.py | 58 +++++++++++++++++++------------------- docs/end_to_end_testing.md | 3 +- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index e05a17c38..7ab3db5cc 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -51,35 +51,35 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - #TestCase0001BasicResync(), - #TestCase0002SyncListValidation(), - #TestCase0003DryRunValidation(), - #TestCase0004SingleDirectorySync(), - #TestCase0005ForceSyncOverride(), - #TestCase0006DownloadOnly(), - #TestCase0007DownloadOnlyCleanupLocalFiles(), - #TestCase0008UploadOnly(), - #TestCase0009UploadOnlyNoRemoteDelete(), - #TestCase0010UploadOnlyRemoveSourceFiles(), - #TestCase0011SkipFileValidation(), - #TestCase0012SkipDirValidation(), - #TestCase0013SkipDotfilesValidation(), - #TestCase0014SkipSizeValidation(), - #TestCase0015SkipSymlinksValidation(), - #TestCase0016CheckNosyncValidation(), - #TestCase0017CheckNomountValidation(), - #TestCase0018RecycleBinValidation(), - #TestCase0019LoggingAndRunningConfig(), - #TestCase0020MonitorModeValidation(), - #TestCase0021ResumableTransfersValidation(), - #TestCase0022LocalFirstValidation(), - #TestCase0023BypassDataPreservationValidation(), - #TestCase0024BigDeleteSafeguardValidation(), - #TestCase0025InvalidCharacterFilenameValidation(), - #TestCase0026ReservedDeviceNameValidation(), - #TestCase0027WhitespaceTrailingDotValidation(), - #TestCase0028ControlCharacterNonUtf8FilenameValidation(), - #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + TestCase0001BasicResync(), + TestCase0002SyncListValidation(), + TestCase0003DryRunValidation(), + TestCase0004SingleDirectorySync(), + TestCase0005ForceSyncOverride(), + TestCase0006DownloadOnly(), + TestCase0007DownloadOnlyCleanupLocalFiles(), + TestCase0008UploadOnly(), + TestCase0009UploadOnlyNoRemoteDelete(), + TestCase0010UploadOnlyRemoveSourceFiles(), + TestCase0011SkipFileValidation(), + TestCase0012SkipDirValidation(), + TestCase0013SkipDotfilesValidation(), + TestCase0014SkipSizeValidation(), + TestCase0015SkipSymlinksValidation(), + TestCase0016CheckNosyncValidation(), + TestCase0017CheckNomountValidation(), + TestCase0018RecycleBinValidation(), + TestCase0019LoggingAndRunningConfig(), + TestCase0020MonitorModeValidation(), + TestCase0021ResumableTransfersValidation(), + TestCase0022LocalFirstValidation(), + TestCase0023BypassDataPreservationValidation(), + TestCase0024BigDeleteSafeguardValidation(), + TestCase0025InvalidCharacterFilenameValidation(), + TestCase0026ReservedDeviceNameValidation(), + TestCase0027WhitespaceTrailingDotValidation(), + TestCase0028ControlCharacterNonUtf8FilenameValidation(), + TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), TestCase0030LocalRenamePropagationValidation(), TestCase0031LocalDirectoryRenamePropagationValidation(), TestCase0032RemoteRenameReconciliation(), diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 319822b62..bc7de7bef 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -52,4 +52,5 @@ SharePoint end-to-end testing uses the same complete automated test suite as Per | 0029 | Upload-only + Local First sync validation | - Personal
- Business
- SharePoint | This test validates that `--local-first --upload-only` uploads local content without rewriting local file timestamps from Microsoft API response data | | 0030 | Local rename propagation validation | - Personal
- Business
- SharePoint | This test validates that renaming a local file is correctly propagated to remote state | | 0031 | Local directory rename propagation validation | - Personal
- Business
- SharePoint | This test validates that renaming a local directory tree is correctly propagated to remote state | - +| 0032 | Remote file rename reconciliation | - Personal
- Business
- SharePoint | This test validates that a stale local client correctly reconciles a remote-side file rename without leaving stale local leftovers | +| 0033 | remote directory rename reconciliation | - Personal
- Business
- SharePoint | This test validates that a second client with existing local and database state correctly reconciles a remote directory rename propagated by another synchronising client | From 05a7af314b4908c1f0ac8dad522aa1528d7bcc50 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 5 Apr 2026 08:16:49 +1000 Subject: [PATCH 133/245] Update YAML files Remove deployment --- .github/workflows/e2e-business.yaml | 6 ++++-- .github/workflows/e2e-personal.yaml | 4 +++- .github/workflows/e2e-sharepoint.yaml | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-business.yaml b/.github/workflows/e2e-business.yaml index e56e57dba..13d11689f 100644 --- a/.github/workflows/e2e-business.yaml +++ b/.github/workflows/e2e-business.yaml @@ -15,8 +15,10 @@ jobs: e2e_business: runs-on: ubuntu-latest container: fedora:latest - environment: e2e-business - + environment: + name: e2e-business + deployment: false + steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index a7493c7ff..0b9fcc70a 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -15,7 +15,9 @@ jobs: e2e_personal: runs-on: ubuntu-latest container: fedora:latest - environment: onedrive-e2e + environment: + name: onedrive-e2e + deployment: false steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/e2e-sharepoint.yaml b/.github/workflows/e2e-sharepoint.yaml index fc1888997..4682678e9 100644 --- a/.github/workflows/e2e-sharepoint.yaml +++ b/.github/workflows/e2e-sharepoint.yaml @@ -15,7 +15,9 @@ jobs: e2e_sharepoint: runs-on: ubuntu-latest container: fedora:latest - environment: e2e-sharepoint + environment: + name: e2e-sharepoint + deployment: false steps: - uses: actions/checkout@v4 From 42984f7f6690d7880d3049f7c06bdad99708b2ca Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 5 Apr 2026 10:07:46 +1000 Subject: [PATCH 134/245] Update tc0021_resumable_transfers_validation.py Update tc0021 to handle updated exit code --- ci/e2e/testcases/tc0021_resumable_transfers_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index 00f377747..e407c70dc 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -258,7 +258,7 @@ def _phase1_interruption_acceptable(self, combined_phase1_output: str, phase1_re break interrupted_as_expected = ( - phase1_returncode in (-2, 130, -11, 139) + phase1_returncode in (-2, 2, 130, -11, 139) or crash_marker_seen in {"Segmentation fault", "core dumped", "SIGSEGV"} ) From 7ddf14d6e2b4e54cc1147ab97651e56c227df554 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sun, 5 Apr 2026 11:04:47 +1000 Subject: [PATCH 135/245] Update tc0020_monitor_mode_validation.py Remove fixed sleep time to remove brittleness in test case --- .../tc0020_monitor_mode_validation.py | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/ci/e2e/testcases/tc0020_monitor_mode_validation.py b/ci/e2e/testcases/tc0020_monitor_mode_validation.py index aafefcedd..d82036996 100644 --- a/ci/e2e/testcases/tc0020_monitor_mode_validation.py +++ b/ci/e2e/testcases/tc0020_monitor_mode_validation.py @@ -29,6 +29,27 @@ def _write_config(self, config_path: Path, app_log_dir: Path) -> None: 'monitor_fullscan_frequency = "1"\n', ) + def _wait_for_initial_sync_complete( + self, + stdout_file: Path, + timeout_seconds: int = 120, + poll_interval: float = 0.5, + ) -> bool: + deadline = time.time() + timeout_seconds + success_marker = "Sync with Microsoft OneDrive is complete" + + while time.time() < deadline: + if stdout_file.exists(): + try: + content = stdout_file.read_text(encoding="utf-8", errors="replace") + except OSError: + content = "" + if success_marker in content: + return True + time.sleep(poll_interval) + + return False + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0020" case_log_dir = context.logs_dir / "tc0020" @@ -83,9 +104,12 @@ def run(self, context: E2EContext) -> TestResult: stderr=stderr_fp, text=True, ) - time.sleep(8) - write_text_file(sync_root / root_name / "monitor-added.txt", "added while monitor mode was running\n") - time.sleep(12) + + initial_sync_complete = self._wait_for_initial_sync_complete(stdout_file) + if initial_sync_complete: + write_text_file(sync_root / root_name / "monitor-added.txt", "added while monitor mode was running\n") + time.sleep(12) + process.send_signal(signal.SIGINT) try: process.wait(timeout=30) @@ -122,6 +146,7 @@ def run(self, context: E2EContext) -> TestResult: f"root_name={root_name}", f"monitor_returncode={process.returncode}", f"verify_returncode={verify_result.returncode}", + f"initial_sync_complete={initial_sync_complete}", ] ) + "\n", ) @@ -133,12 +158,22 @@ def run(self, context: E2EContext) -> TestResult: "monitor_returncode": process.returncode, "verify_returncode": verify_result.returncode, "root_name": root_name, + "initial_sync_complete": initial_sync_complete, } + if not initial_sync_complete: + return TestResult.fail_result( + self.case_id, + self.name, + "Monitor mode did not complete the initial sync within the expected time", + artifacts, + details, + ) + if verify_result.returncode != 0: return TestResult.fail_result(self.case_id, self.name, f"Remote verification failed with status {verify_result.returncode}", artifacts, details) if f"{root_name}/monitor-added.txt" not in remote_manifest: return TestResult.fail_result(self.case_id, self.name, "Monitor mode did not upload the file created while the process was running", artifacts, details) - return TestResult.pass_result(self.case_id, self.name, artifacts, details) + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 0a640159008aba1180079cb2c05152f7062a9f3d Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 6 Apr 2026 07:29:36 +1000 Subject: [PATCH 136/245] Update e2e-personal.yaml * Update environment --- .github/workflows/e2e-personal.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 0b9fcc70a..38eb21206 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest container: fedora:latest environment: - name: onedrive-e2e + name: e2e-personal deployment: false steps: From 0645a37e3c0737685ee0440007ac1829650e1b9a Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 6 Apr 2026 07:35:10 +1000 Subject: [PATCH 137/245] Add E2E Workflow for 15 Character Personal Accounts Add E2E Workflow for 15 Character Personal Accounts. Microsoft Graph API specifically drops preceding zero's from the driveId value, thus this workflow uses an account that has this exact specific configuration, enabling end-to-end testing of the client against potential application bugs caused by the Microsoft Graph API issue. --- .../workflows/e2e-personal-15char-check.yaml | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 .github/workflows/e2e-personal-15char-check.yaml diff --git a/.github/workflows/e2e-personal-15char-check.yaml b/.github/workflows/e2e-personal-15char-check.yaml new file mode 100644 index 000000000..404d6354e --- /dev/null +++ b/.github/workflows/e2e-personal-15char-check.yaml @@ -0,0 +1,159 @@ +name: E2E Personal Account Testing - 15 Char Check + +on: + push: + branches-ignore: + - master + - main + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + e2e_personal: + runs-on: ubuntu-latest + container: fedora:latest + environment: + name: e2e-personal-15char-check + deployment: false + + steps: + - uses: actions/checkout@v4 + + - name: Install Dependencies + run: | + dnf -y update + dnf -y group install development-tools + dnf -y install python3 ldc libcurl-devel sqlite-devel dbus-devel jq + + - name: Build + local install prefix + run: | + ./configure --prefix="$PWD/.ci/prefix" + make -j"$(nproc)" + make install + "$PWD/.ci/prefix/bin/onedrive" --version + + - name: Prepare isolated HOME + run: | + set -euo pipefail + export HOME="$RUNNER_TEMP/home-personal" + echo "HOME=$HOME" >> "$GITHUB_ENV" + echo "XDG_CONFIG_HOME=$HOME/.config" >> "$GITHUB_ENV" + echo "XDG_CACHE_HOME=$HOME/.cache" >> "$GITHUB_ENV" + mkdir -p "$HOME" + + - name: Inject refresh token into onedrive config + env: + REFRESH_TOKEN_PERSONAL: ${{ secrets.REFRESH_TOKEN_PERSONAL }} + run: | + set -euo pipefail + mkdir -p "$XDG_CONFIG_HOME/onedrive" + umask 077 + printf "%s" "$REFRESH_TOKEN_PERSONAL" > "$XDG_CONFIG_HOME/onedrive/refresh_token" + chmod 600 "$XDG_CONFIG_HOME/onedrive/refresh_token" + + - name: Run E2E harness + env: + ONEDRIVE_BIN: ${{ github.workspace }}/.ci/prefix/bin/onedrive + E2E_TARGET: personal + RUN_ID: ${{ github.run_id }} + PYTHONUNBUFFERED: "1" + run: | + python3 -u ci/e2e/run.py + + - name: Upload E2E artefacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-personal + path: ci/e2e/out/** + + pr_comment: + name: Post PR summary comment + needs: [ e2e_personal ] + runs-on: ubuntu-latest + if: always() + + steps: + - uses: actions/checkout@v4 + + - name: Download artefact + uses: actions/download-artifact@v4 + with: + name: e2e-personal + path: artifacts/e2e-personal + + - name: Build markdown summary + id: summary + run: | + set -euo pipefail + + f="$(find artifacts/e2e-personal -name results.json -type f | head -n 1 || true)" + if [ -z "$f" ] || [ ! -f "$f" ]; then + echo "md=⚠️ E2E ran but results.json was not found." >> "$GITHUB_OUTPUT" + exit 0 + fi + + target=$(jq -r '.target // "personal"' "$f") + total=$(jq -r '.cases | length' "$f") + passed=$(jq -r '[.cases[] | select(.status=="pass")] | length' "$f") + failed=$(jq -r '[.cases[] | select(.status=="fail")] | length' "$f") + + failures=$(jq -r '.cases[] + | select(.status=="fail") + | "- Test Case \(.id // "????"): \(.name) — \(.reason // "no reason provided")"' "$f" || true) + + md="## ${target^} Account Testing\n" + md+="**${total}** Test Cases Run \n" + md+="**${passed}** Test Cases Passed \n" + md+="**${failed}** Test Cases Failed \n\n" + + if [ "$failed" -gt 0 ] && [ -n "$failures" ]; then + md+="### ${target^} Account Test Failures\n" + md+="$failures\n" + fi + + echo "md<> "$GITHUB_OUTPUT" + echo -e "$md" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Find PR associated with this commit + id: pr + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const sha = context.sha; + + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, repo, commit_sha: sha + }); + + if (!prs.data.length) { + core.setOutput("found", "false"); + return; + } + + core.setOutput("found", "true"); + core.setOutput("number", String(prs.data[0].number)); + + - name: Post PR comment + if: steps.pr.outputs.found == 'true' + uses: actions/github-script@v7 + env: + COMMENT_MD: ${{ steps.summary.outputs.md }} + with: + script: | + const { owner, repo } = context.repo; + const issue_number = Number("${{ steps.pr.outputs.number }}"); + + const md = process.env.COMMENT_MD || "⚠️ No summary text produced."; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: md + }); \ No newline at end of file From 46a8744cfc527b8c24cd656e047daeaaf3a5064f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 6 Apr 2026 08:36:57 +1000 Subject: [PATCH 138/245] Update e2e-personal-15char-check.yaml * Update Test Outcome Results Header --- .github/workflows/e2e-personal-15char-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-personal-15char-check.yaml b/.github/workflows/e2e-personal-15char-check.yaml index 404d6354e..5c0676aa2 100644 --- a/.github/workflows/e2e-personal-15char-check.yaml +++ b/.github/workflows/e2e-personal-15char-check.yaml @@ -105,7 +105,7 @@ jobs: | select(.status=="fail") | "- Test Case \(.id // "????"): \(.name) — \(.reason // "no reason provided")"' "$f" || true) - md="## ${target^} Account Testing\n" + md="## ${target^} Account Testing - 15 Character 'driveId'\n" md+="**${total}** Test Cases Run \n" md+="**${passed}** Test Cases Passed \n" md+="**${failed}** Test Cases Failed \n\n" From 6076c94097e00ac13c560ba57450b48772dce5d4 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 6 Apr 2026 09:54:20 +1000 Subject: [PATCH 139/245] Update PR * Update tc0033 to remove brittleness * Use different name for objects for 2nd personal account for 15char driveId validation --- .../workflows/e2e-personal-15char-check.yaml | 8 +- ..._remote_directory_rename_reconciliation.py | 234 +++++++++++------- 2 files changed, 152 insertions(+), 90 deletions(-) diff --git a/.github/workflows/e2e-personal-15char-check.yaml b/.github/workflows/e2e-personal-15char-check.yaml index 5c0676aa2..6bc4dc7bd 100644 --- a/.github/workflows/e2e-personal-15char-check.yaml +++ b/.github/workflows/e2e-personal-15char-check.yaml @@ -67,7 +67,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: e2e-personal + name: e2e-personal-15char-check path: ci/e2e/out/** pr_comment: @@ -82,15 +82,15 @@ jobs: - name: Download artefact uses: actions/download-artifact@v4 with: - name: e2e-personal - path: artifacts/e2e-personal + name: e2e-personal-15char-check + path: artifacts/e2e-personal-15char-check - name: Build markdown summary id: summary run: | set -euo pipefail - f="$(find artifacts/e2e-personal -name results.json -type f | head -n 1 || true)" + f="$(find artifacts/e2e-personal-15char-check -name results.json -type f | head -n 1 || true)" if [ -z "$f" ] || [ ! -f "$f" ]; then echo "md=⚠️ E2E ran but results.json was not found." >> "$GITHUB_OUTPUT" exit 0 diff --git a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py index ffc8d70e4..906c04569 100644 --- a/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py +++ b/ci/e2e/testcases/tc0033_remote_directory_rename_reconciliation.py @@ -58,6 +58,17 @@ def _list_dirs_under(self, root: Path) -> list[str]: return [] return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_dir()) + def _extract_deleted_remote_paths(self, stdout: str) -> list[str]: + prefix = "Deleting item from Microsoft OneDrive: " + deleted_paths: list[str] = [] + + for line in stdout.splitlines(): + line = line.strip() + if line.startswith(prefix): + deleted_paths.append(line[len(prefix):].strip()) + + return deleted_paths + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0033" case_log_dir = context.logs_dir / "tc0033" @@ -125,10 +136,12 @@ def run(self, context: E2EContext) -> TestResult: phase2_validator_initial_stderr = case_log_dir / "phase2_validator_initial_stderr.log" phase3_rename_stdout = case_log_dir / "phase3_directory_rename_stdout.log" phase3_rename_stderr = case_log_dir / "phase3_directory_rename_stderr.log" - phase4_validator_reconcile_stdout = case_log_dir / "phase4_validator_reconcile_stdout.log" - phase4_validator_reconcile_stderr = case_log_dir / "phase4_validator_reconcile_stderr.log" + phase3_converge_stdout = case_log_dir / "phase3_converge_stdout.log" + phase3_converge_stderr = case_log_dir / "phase3_converge_stderr.log" verify_stdout = case_log_dir / "verify_stdout.log" verify_stderr = case_log_dir / "verify_stderr.log" + phase4_validator_reconcile_stdout = case_log_dir / "phase4_validator_reconcile_stdout.log" + phase4_validator_reconcile_stderr = case_log_dir / "phase4_validator_reconcile_stderr.log" validator_manifest_file = state_dir / "validator_manifest.txt" verify_manifest_file = state_dir / "verify_manifest.txt" metadata_file = state_dir / "metadata.txt" @@ -140,10 +153,12 @@ def run(self, context: E2EContext) -> TestResult: str(phase2_validator_initial_stderr), str(phase3_rename_stdout), str(phase3_rename_stderr), - str(phase4_validator_reconcile_stdout), - str(phase4_validator_reconcile_stderr), + str(phase3_converge_stdout), + str(phase3_converge_stderr), str(verify_stdout), str(verify_stderr), + str(phase4_validator_reconcile_stdout), + str(phase4_validator_reconcile_stderr), str(validator_manifest_file), str(verify_manifest_file), str(metadata_file), @@ -298,9 +313,13 @@ def run(self, context: E2EContext) -> TestResult: write_text_file(phase3_rename_stdout, phase3_result.stdout) write_text_file(phase3_rename_stderr, phase3_result.stderr) details["phase3_returncode"] = phase3_result.returncode - details["phase3_deleted_old_directory_online"] = ( - f"Deleting item from Microsoft OneDrive: {root_name}/SourceDirectory" in phase3_result.stdout - ) + + phase3_deleted_paths = self._extract_deleted_remote_paths(phase3_result.stdout) + details["phase3_deleted_remote_paths"] = phase3_deleted_paths + details["phase3_deleted_old_root_exact"] = source_dir_relative in phase3_deleted_paths + details["phase3_deleted_old_nested_exact"] = f"{source_dir_relative}/Nested" in phase3_deleted_paths + details["phase3_deleted_old_file_1_exact"] = source_file_1_relative in phase3_deleted_paths + details["phase3_deleted_old_file_2_exact"] = source_file_2_relative in phase3_deleted_paths if phase3_result.returncode != 0: self._write_metadata(metadata_file, details) @@ -312,66 +331,42 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 4: Validator re-runs download-only against its existing local/database state. - phase4_command = [ + # Phase 3b: Run a second seeder sync pass to converge any residual remote state. + phase3_converge_command = [ context.onedrive_bin, "--display-running-config", "--sync", - "--download-only", "--verbose", - "--single-directory", - root_name, "--confdir", - str(conf_validator), + str(conf_seeder), ] context.log( - f"Executing Test Case {self.case_id} phase4 validator reconcile: " - f"{command_to_string(phase4_command)}" + f"Executing Test Case {self.case_id} phase3 converge sync: " + f"{command_to_string(phase3_converge_command)}" ) - phase4_result = run_command(phase4_command, cwd=context.repo_root) - write_text_file(phase4_validator_reconcile_stdout, phase4_result.stdout) - write_text_file(phase4_validator_reconcile_stderr, phase4_result.stderr) - details["phase4_returncode"] = phase4_result.returncode - - validator_manifest = build_manifest(validator_root) - write_manifest(validator_manifest_file, validator_manifest) - - details["validator_source_dir_exists_after_reconcile"] = validator_source_dir.exists() - details["validator_renamed_dir_exists_after_reconcile"] = validator_renamed_dir.exists() - details["validator_source_file_1_exists_after_reconcile"] = validator_source_file_1.exists() - details["validator_source_file_2_exists_after_reconcile"] = validator_source_file_2.exists() - details["validator_renamed_file_1_exists_after_reconcile"] = validator_renamed_file_1.exists() - details["validator_renamed_file_2_exists_after_reconcile"] = validator_renamed_file_2.exists() - - validator_old_tree_files = self._list_files_under(validator_source_dir) - validator_old_tree_dirs = self._list_dirs_under(validator_source_dir) - details["validator_old_tree_files_after_reconcile"] = validator_old_tree_files - details["validator_old_tree_dirs_after_reconcile"] = validator_old_tree_dirs - - validator_new_file_1_content = ( - validator_renamed_file_1.read_text(encoding="utf-8") - if validator_renamed_file_1.is_file() - else "" + phase3_converge_result = run_command(phase3_converge_command, cwd=context.repo_root) + write_text_file(phase3_converge_stdout, phase3_converge_result.stdout) + write_text_file(phase3_converge_stderr, phase3_converge_result.stderr) + details["phase3_converge_returncode"] = phase3_converge_result.returncode + + phase3_converge_deleted_paths = self._extract_deleted_remote_paths(phase3_converge_result.stdout) + details["phase3_converge_deleted_remote_paths"] = phase3_converge_deleted_paths + details["phase3_converge_deleted_old_root_exact"] = source_dir_relative in phase3_converge_deleted_paths + details["phase3_converge_deleted_old_nested_exact"] = ( + f"{source_dir_relative}/Nested" in phase3_converge_deleted_paths ) - validator_new_file_2_content = ( - validator_renamed_file_2.read_text(encoding="utf-8") - if validator_renamed_file_2.is_file() - else "" - ) - details["validator_renamed_file_1_content"] = validator_new_file_1_content - details["validator_renamed_file_2_content"] = validator_new_file_2_content - if phase4_result.returncode != 0: + if phase3_converge_result.returncode != 0: self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"validator reconcile phase failed with status {phase4_result.returncode}", + f"directory rename convergence phase failed with status {phase3_converge_result.returncode}", artifacts, details, ) - # Verify: fresh client downloads current remote truth independently. + # Verify remote truth independently before judging validator reconciliation. verify_command = [ context.onedrive_bin, "--display-running-config", @@ -385,7 +380,7 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_verify), ] - context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + context.log(f"Executing Test Case {self.case_id} verify remote truth: {command_to_string(verify_command)}") verify_result = run_command(verify_command, cwd=context.repo_root) write_text_file(verify_stdout, verify_result.stdout) write_text_file(verify_stderr, verify_result.stderr) @@ -419,9 +414,8 @@ def run(self, context: E2EContext) -> TestResult: details["verify_renamed_file_1_content"] = verify_new_file_1_content details["verify_renamed_file_2_content"] = verify_new_file_2_content - self._write_metadata(metadata_file, details) - if verify_result.returncode != 0: + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, @@ -430,148 +424,216 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Validator assertions: existing-state client must reconcile cleanly. - if validator_source_dir.exists() or validator_source_file_1.exists() or validator_source_file_2.exists(): + # Remote truth assertions: the old tree must be fully absent before validator is judged. + if verify_source_dir.exists() or verify_source_file_1.exists() or verify_source_file_2.exists(): + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"validator still contains original directory tree after reconciliation: {source_dir_relative}", + f"remote rename propagation incomplete: original directory tree still exists online: {source_dir_relative}", artifacts, details, ) - if validator_old_tree_files: + if verify_old_tree_files: + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"validator retained old files under original directory tree after reconciliation: {validator_old_tree_files}", + f"remote rename propagation incomplete: old files still exist online under original directory tree: {verify_old_tree_files}", artifacts, details, ) - if validator_old_tree_dirs: + if verify_old_tree_dirs: + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"validator retained old directories under original directory tree after reconciliation: {validator_old_tree_dirs}", + f"remote rename propagation incomplete: old directories still exist online under original directory tree: {verify_old_tree_dirs}", artifacts, details, ) - if not validator_renamed_dir.is_dir(): + if not verify_renamed_dir.is_dir(): + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"validator is missing renamed directory after reconciliation: {renamed_dir_relative}", + f"remote verification is missing renamed directory: {renamed_dir_relative}", artifacts, details, ) - if not validator_renamed_file_1.is_file(): + if not verify_renamed_file_1.is_file(): + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"validator is missing renamed top-level file after reconciliation: {renamed_file_1_relative}", + f"remote verification is missing renamed top-level file: {renamed_file_1_relative}", artifacts, details, ) - if not validator_renamed_file_2.is_file(): + if not verify_renamed_file_2.is_file(): + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - f"validator is missing renamed nested file after reconciliation: {renamed_file_2_relative}", + f"remote verification is missing renamed nested file: {renamed_file_2_relative}", artifacts, details, ) - if validator_new_file_1_content != file1_content: + if verify_new_file_1_content != file1_content: + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "validator renamed top-level file content did not match expected content", + "remote verification renamed top-level file content did not match expected content", artifacts, details, ) - if validator_new_file_2_content != file2_content: + if verify_new_file_2_content != file2_content: + self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, self.name, - "validator renamed nested file content did not match expected content", + "remote verification renamed nested file content did not match expected content", artifacts, details, ) - # Verify assertions: fresh remote truth must also be correct. - if verify_source_dir.exists() or verify_source_file_1.exists() or verify_source_file_2.exists(): + # Phase 4: Validator re-runs download-only against its existing local/database state, + # but only after remote truth has been proven clean. + phase4_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_validator), + ] + context.log( + f"Executing Test Case {self.case_id} phase4 validator reconcile: " + f"{command_to_string(phase4_command)}" + ) + phase4_result = run_command(phase4_command, cwd=context.repo_root) + write_text_file(phase4_validator_reconcile_stdout, phase4_result.stdout) + write_text_file(phase4_validator_reconcile_stderr, phase4_result.stderr) + details["phase4_returncode"] = phase4_result.returncode + + validator_manifest = build_manifest(validator_root) + write_manifest(validator_manifest_file, validator_manifest) + + details["validator_source_dir_exists_after_reconcile"] = validator_source_dir.exists() + details["validator_renamed_dir_exists_after_reconcile"] = validator_renamed_dir.exists() + details["validator_source_file_1_exists_after_reconcile"] = validator_source_file_1.exists() + details["validator_source_file_2_exists_after_reconcile"] = validator_source_file_2.exists() + details["validator_renamed_file_1_exists_after_reconcile"] = validator_renamed_file_1.exists() + details["validator_renamed_file_2_exists_after_reconcile"] = validator_renamed_file_2.exists() + + validator_old_tree_files = self._list_files_under(validator_source_dir) + validator_old_tree_dirs = self._list_dirs_under(validator_source_dir) + details["validator_old_tree_files_after_reconcile"] = validator_old_tree_files + details["validator_old_tree_dirs_after_reconcile"] = validator_old_tree_dirs + + validator_new_file_1_content = ( + validator_renamed_file_1.read_text(encoding="utf-8") + if validator_renamed_file_1.is_file() + else "" + ) + validator_new_file_2_content = ( + validator_renamed_file_2.read_text(encoding="utf-8") + if validator_renamed_file_2.is_file() + else "" + ) + details["validator_renamed_file_1_content"] = validator_new_file_1_content + details["validator_renamed_file_2_content"] = validator_new_file_2_content + + self._write_metadata(metadata_file, details) + + if phase4_result.returncode != 0: return TestResult.fail_result( self.case_id, self.name, - f"remote verification still contains original directory tree: {source_dir_relative}", + f"validator reconcile phase failed with status {phase4_result.returncode}", artifacts, details, ) - if verify_old_tree_files: + if validator_source_dir.exists() or validator_source_file_1.exists() or validator_source_file_2.exists(): return TestResult.fail_result( self.case_id, self.name, - f"remote verification retained old files under original directory tree: {verify_old_tree_files}", + f"validator still contains original directory tree after reconciliation: {source_dir_relative}", artifacts, details, ) - if verify_old_tree_dirs: + if validator_old_tree_files: return TestResult.fail_result( self.case_id, self.name, - f"remote verification retained old directories under original directory tree: {verify_old_tree_dirs}", + f"validator retained old files under original directory tree after reconciliation: {validator_old_tree_files}", artifacts, details, ) - if not verify_renamed_dir.is_dir(): + if validator_old_tree_dirs: return TestResult.fail_result( self.case_id, self.name, - f"remote verification is missing renamed directory: {renamed_dir_relative}", + f"validator retained old directories under original directory tree after reconciliation: {validator_old_tree_dirs}", artifacts, details, ) - if not verify_renamed_file_1.is_file(): + if not validator_renamed_dir.is_dir(): return TestResult.fail_result( self.case_id, self.name, - f"remote verification is missing renamed top-level file: {renamed_file_1_relative}", + f"validator is missing renamed directory after reconciliation: {renamed_dir_relative}", artifacts, details, ) - if not verify_renamed_file_2.is_file(): + if not validator_renamed_file_1.is_file(): return TestResult.fail_result( self.case_id, self.name, - f"remote verification is missing renamed nested file: {renamed_file_2_relative}", + f"validator is missing renamed top-level file after reconciliation: {renamed_file_1_relative}", artifacts, details, ) - if verify_new_file_1_content != file1_content: + if not validator_renamed_file_2.is_file(): return TestResult.fail_result( self.case_id, self.name, - "remote verification renamed top-level file content did not match expected content", + f"validator is missing renamed nested file after reconciliation: {renamed_file_2_relative}", artifacts, details, ) - if verify_new_file_2_content != file2_content: + if validator_new_file_1_content != file1_content: return TestResult.fail_result( self.case_id, self.name, - "remote verification renamed nested file content did not match expected content", + "validator renamed top-level file content did not match expected content", + artifacts, + details, + ) + + if validator_new_file_2_content != file2_content: + return TestResult.fail_result( + self.case_id, + self.name, + "validator renamed nested file content did not match expected content", artifacts, details, ) From b18259a0c17e94d1cdea4e10b26567c78f093a9f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 6 Apr 2026 11:30:42 +1000 Subject: [PATCH 140/245] Update tc0021_resumable_transfers_validation.py * Improve tc0021 to remove brittleness --- .../tc0021_resumable_transfers_validation.py | 126 +++++++++++++++++- 1 file changed, 120 insertions(+), 6 deletions(-) diff --git a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py index e407c70dc..46d16eda2 100644 --- a/ci/e2e/testcases/tc0021_resumable_transfers_validation.py +++ b/ci/e2e/testcases/tc0021_resumable_transfers_validation.py @@ -35,9 +35,12 @@ class TestCase0021ResumableTransfersValidation(E2ETestCase): TRANSFER_WAIT_TIMEOUT = 300 PROCESS_EXIT_TIMEOUT = 120 - # Apply a 10 MB/s rate limit for both upload and download scenarios - # so that the phase1 interrupt lands during an active resumable transfer. - RATE_LIMIT: str | None = "10485760" + # Use 1 MB/s to deliberately slow both upload and download so the 15% threshold + # is reached with ample time to deliver SIGINT before the transfer can complete. + RATE_LIMIT: str | None = "1048576" + + # Poll frequently to reduce brittleness caused by buffered log writes. + TRANSFER_POLL_INTERVAL_SECONDS = 0.25 def _write_config( self, @@ -183,7 +186,7 @@ def _interrupt_process_at_transfer_threshold( process.send_signal(signal.SIGINT) break - time.sleep(1) + time.sleep(self.TRANSFER_POLL_INTERVAL_SECONDS) try: process.wait(timeout=exit_timeout) @@ -264,6 +267,41 @@ def _phase1_interruption_acceptable(self, combined_phase1_output: str, phase1_re return interrupted_as_expected, crash_marker_seen + def _target_transfer_completed_in_phase1( + self, + combined_phase1_output: str, + target_filename: str, + transfer_kind: str, + ) -> bool: + for line in combined_phase1_output.splitlines(): + if target_filename not in line: + continue + + line_lower = line.lower() + + if transfer_kind == "upload": + if "upload" in line_lower and "done" in line_lower: + return True + elif transfer_kind == "download": + if "download" in line_lower and "done" in line_lower: + return True + + return False + + def _find_resumable_state_files(self, conf_dir: Path, patterns: list[str]) -> list[str]: + matches: list[str] = [] + for pattern in patterns: + for path in sorted(conf_dir.glob(pattern)): + if path.is_file(): + matches.append(str(path)) + return matches + + def _write_resumable_state_listing(self, output: Path, resumable_files: list[str]) -> None: + if resumable_files: + write_text_file(output, "\n".join(resumable_files) + "\n") + else: + write_text_file(output, "") + def _run_upload_resume_scenario( self, context: E2EContext, @@ -309,6 +347,7 @@ def _run_upload_resume_scenario( local_tree_after_phase1 = scenario_state_dir / "local_tree_after_phase1.txt" local_tree_after_phase2 = scenario_state_dir / "local_tree_after_phase2.txt" remote_manifest_file = scenario_state_dir / "remote_verify_manifest.txt" + resumable_state_file = scenario_state_dir / "phase1_resumable_state_files.txt" metadata_file = scenario_state_dir / "metadata.txt" self._snapshot_tree(sync_root, local_tree_before) @@ -348,6 +387,21 @@ def _run_upload_resume_scenario( phase1_app_log_text = self._read_text_if_exists(app_log_file) combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + phase1_app_log_text + phase1_completed_transfer = self._target_transfer_completed_in_phase1( + combined_phase1_output, + "session-large.bin", + "upload", + ) + + resumable_state_files = self._find_resumable_state_files( + conf_dir, + [ + "session_upload*", + "session_upload.*", + ], + ) + self._write_resumable_state_listing(resumable_state_file, resumable_state_files) + phase2_result = self._run_and_capture( context, f"{scenario_id} phase 2", @@ -404,6 +458,7 @@ def _run_upload_resume_scenario( str(local_tree_after_phase1), str(local_tree_after_phase2), str(remote_manifest_file), + str(resumable_state_file), str(metadata_file), ] self._append_if_exists(artifacts, app_log_dir) @@ -419,6 +474,8 @@ def _run_upload_resume_scenario( "interrupt_threshold_percent": self.INTERRUPT_THRESHOLD_PERCENT, "threshold_reached": threshold_reached, "observed_max_percent": observed_max_percent, + "phase1_transfer_completed": phase1_completed_transfer, + "phase1_resumable_state_files": resumable_state_files, "phase1_crash_marker_seen": crash_marker_seen, "phase1_interrupted_as_expected": interrupted_as_expected, "rate_limit": self.RATE_LIMIT or "disabled", @@ -439,6 +496,8 @@ def _run_upload_resume_scenario( f"interrupt_threshold_percent={self.INTERRUPT_THRESHOLD_PERCENT}", f"threshold_reached={threshold_reached}", f"observed_max_percent={observed_max_percent}", + f"phase1_transfer_completed={phase1_completed_transfer}", + f"phase1_resumable_state_files={len(resumable_state_files)}", f"phase1_crash_marker_seen={crash_marker_seen}", f"phase1_interrupted_as_expected={interrupted_as_expected}", f"rate_limit={self.RATE_LIMIT or 'disabled'}", @@ -458,6 +517,15 @@ def _run_upload_resume_scenario( details, ) + if phase1_completed_transfer: + return self._scenario_fail( + scenario_id, + description, + "Interrupted upload phase completed the target file transfer before SIGINT landed, so no resumable upload state was guaranteed", + artifacts, + details, + ) + if not interrupted_as_expected: return self._scenario_fail( scenario_id, @@ -467,6 +535,15 @@ def _run_upload_resume_scenario( details, ) + if not resumable_state_files: + return self._scenario_fail( + scenario_id, + description, + "Interrupted upload phase did not leave resumable upload session state on disk", + artifacts, + details, + ) + if phase2_result.returncode != 0: return self._scenario_fail( scenario_id, @@ -570,6 +647,7 @@ def _run_download_resume_scenario( local_tree_after_phase2 = scenario_state_dir / "local_tree_after_phase2.txt" local_tree_after_verify = scenario_state_dir / "local_tree_after_verify.txt" verify_manifest_file = scenario_state_dir / "verify_manifest.txt" + resumable_state_file = scenario_state_dir / "phase1_resumable_state_files.txt" metadata_file = scenario_state_dir / "metadata.txt" seed_command = [ @@ -607,8 +685,6 @@ def _run_download_resume_scenario( details, ) - # Prepare a clean local download state before phase1. - # This is the only point in TC0021 where resync/resync-auth should be used. reset_directory(download_root) items_db = conf_dir / "items.sqlite3" @@ -657,6 +733,21 @@ def _run_download_resume_scenario( phase1_app_log_text = self._read_text_if_exists(app_log_file) combined_phase1_output = phase1_stdout_text + "\n" + phase1_stderr_text + "\n" + phase1_app_log_text + phase1_completed_transfer = self._target_transfer_completed_in_phase1( + combined_phase1_output, + "session-large.bin", + "download", + ) + + resumable_state_files = self._find_resumable_state_files( + conf_dir, + [ + "resume_download*", + "resume_download.*", + ], + ) + self._write_resumable_state_listing(resumable_state_file, resumable_state_files) + download_command_phase2 = [ context.onedrive_bin, "--display-running-config", @@ -729,6 +820,7 @@ def _run_download_resume_scenario( str(local_tree_after_phase2), str(local_tree_after_verify), str(verify_manifest_file), + str(resumable_state_file), str(metadata_file), ] self._append_if_exists(artifacts, seed_app_log_dir) @@ -746,6 +838,8 @@ def _run_download_resume_scenario( "interrupt_threshold_percent": self.INTERRUPT_THRESHOLD_PERCENT, "threshold_reached": threshold_reached, "observed_max_percent": observed_max_percent, + "phase1_transfer_completed": phase1_completed_transfer, + "phase1_resumable_state_files": resumable_state_files, "downloaded_file_exists_after_phase2": downloaded_file.exists(), "phase1_crash_marker_seen": crash_marker_seen, "phase1_interrupted_as_expected": interrupted_as_expected, @@ -768,6 +862,8 @@ def _run_download_resume_scenario( f"interrupt_threshold_percent={self.INTERRUPT_THRESHOLD_PERCENT}", f"threshold_reached={threshold_reached}", f"observed_max_percent={observed_max_percent}", + f"phase1_transfer_completed={phase1_completed_transfer}", + f"phase1_resumable_state_files={len(resumable_state_files)}", f"downloaded_file_exists_after_phase2={downloaded_file.exists()}", f"phase1_crash_marker_seen={crash_marker_seen}", f"phase1_interrupted_as_expected={interrupted_as_expected}", @@ -788,6 +884,15 @@ def _run_download_resume_scenario( details, ) + if phase1_completed_transfer: + return self._scenario_fail( + scenario_id, + description, + "Interrupted download phase completed the target file transfer before SIGINT landed, so no resumable download state was guaranteed", + artifacts, + details, + ) + if not interrupted_as_expected: return self._scenario_fail( scenario_id, @@ -797,6 +902,15 @@ def _run_download_resume_scenario( details, ) + if not resumable_state_files: + return self._scenario_fail( + scenario_id, + description, + "Interrupted download phase did not leave resumable download state on disk", + artifacts, + details, + ) + if phase2_result.returncode != 0: return self._scenario_fail( scenario_id, From b9d39841e9dfd9eba7753f9f75e5b25e25a82142 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 6 Apr 2026 18:19:06 +1000 Subject: [PATCH 141/245] Add tc0034 Add tc0034 --- ci/e2e/run.py | 68 ++++++------- ...cal_move_between_directories_validation.py | 96 +++++++++++++++++++ 2 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 ci/e2e/testcases/tc0034_local_move_between_directories_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 7ab3db5cc..e131784d2 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -42,6 +42,7 @@ from testcases.tc0031_local_directory_rename_propagation_validation import TestCase0031LocalDirectoryRenamePropagationValidation from testcases.tc0032_remote_rename_reconciliation import TestCase0032RemoteRenameReconciliation from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033RemoteDirectoryRenameReconciliation +from testcases.tc0034_local_move_between_directories_validation import TestCase0034LocalMoveBetweenDirectories def build_test_suite() -> list: @@ -51,39 +52,40 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - TestCase0001BasicResync(), - TestCase0002SyncListValidation(), - TestCase0003DryRunValidation(), - TestCase0004SingleDirectorySync(), - TestCase0005ForceSyncOverride(), - TestCase0006DownloadOnly(), - TestCase0007DownloadOnlyCleanupLocalFiles(), - TestCase0008UploadOnly(), - TestCase0009UploadOnlyNoRemoteDelete(), - TestCase0010UploadOnlyRemoveSourceFiles(), - TestCase0011SkipFileValidation(), - TestCase0012SkipDirValidation(), - TestCase0013SkipDotfilesValidation(), - TestCase0014SkipSizeValidation(), - TestCase0015SkipSymlinksValidation(), - TestCase0016CheckNosyncValidation(), - TestCase0017CheckNomountValidation(), - TestCase0018RecycleBinValidation(), - TestCase0019LoggingAndRunningConfig(), - TestCase0020MonitorModeValidation(), - TestCase0021ResumableTransfersValidation(), - TestCase0022LocalFirstValidation(), - TestCase0023BypassDataPreservationValidation(), - TestCase0024BigDeleteSafeguardValidation(), - TestCase0025InvalidCharacterFilenameValidation(), - TestCase0026ReservedDeviceNameValidation(), - TestCase0027WhitespaceTrailingDotValidation(), - TestCase0028ControlCharacterNonUtf8FilenameValidation(), - TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), - TestCase0030LocalRenamePropagationValidation(), - TestCase0031LocalDirectoryRenamePropagationValidation(), - TestCase0032RemoteRenameReconciliation(), - TestCase0033RemoteDirectoryRenameReconciliation(), + #TestCase0001BasicResync(), + #TestCase0002SyncListValidation(), + #TestCase0003DryRunValidation(), + #TestCase0004SingleDirectorySync(), + #TestCase0005ForceSyncOverride(), + #TestCase0006DownloadOnly(), + #TestCase0007DownloadOnlyCleanupLocalFiles(), + #TestCase0008UploadOnly(), + #TestCase0009UploadOnlyNoRemoteDelete(), + #TestCase0010UploadOnlyRemoveSourceFiles(), + #TestCase0011SkipFileValidation(), + #TestCase0012SkipDirValidation(), + #TestCase0013SkipDotfilesValidation(), + #TestCase0014SkipSizeValidation(), + #TestCase0015SkipSymlinksValidation(), + #TestCase0016CheckNosyncValidation(), + #TestCase0017CheckNomountValidation(), + #TestCase0018RecycleBinValidation(), + #TestCase0019LoggingAndRunningConfig(), + #TestCase0020MonitorModeValidation(), + #TestCase0021ResumableTransfersValidation(), + #TestCase0022LocalFirstValidation(), + #TestCase0023BypassDataPreservationValidation(), + #TestCase0024BigDeleteSafeguardValidation(), + #TestCase0025InvalidCharacterFilenameValidation(), + #TestCase0026ReservedDeviceNameValidation(), + #TestCase0027WhitespaceTrailingDotValidation(), + #TestCase0028ControlCharacterNonUtf8FilenameValidation(), + #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + #TestCase0030LocalRenamePropagationValidation(), + #TestCase0031LocalDirectoryRenamePropagationValidation(), + #TestCase0032RemoteRenameReconciliation(), + #TestCase0033RemoteDirectoryRenameReconciliation(), + TestCase0034LocalMoveBetweenDirectories, ] diff --git a/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py b/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py new file mode 100644 index 000000000..a31a32835 --- /dev/null +++ b/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Test Case 0034: local move between directories validation + +Move a file from one local directory to another without renaming it and +validate the remote result. + +This validates path reclassification rather than rename semantics. +""" + +import os +import shutil +from pathlib import Path + +from .base import TestCaseBase + + +class TestCase0034LocalMoveBetweenDirectories(TestCaseBase): + def run(self): + self.log("Test Case 0034: local move between directories validation") + + sync_dir = Path(self.sync_dir) + + source_dir = sync_dir / "TestRoot" / "SourceDirectory" + dest_dir = sync_dir / "TestRoot" / "DestinationDirectory" + + source_dir.mkdir(parents=True, exist_ok=True) + dest_dir.mkdir(parents=True, exist_ok=True) + + source_file = source_dir / "move-me.txt" + dest_file = dest_dir / "move-me.txt" + + # ------------------------- + # Phase 1: Seed + # ------------------------- + self.log("Seeding initial file structure") + + source_file.write_text("original-content\n") + + # Anchor file to ensure destination exists everywhere + (dest_dir / "anchor.txt").write_text("anchor\n") + + rc = self.run_onedrive("--sync", "--verbose", "--display-running-config") + if rc != 0: + return self.fail("Seed phase failed") + + # ------------------------- + # Phase 2: Local Move + # ------------------------- + self.log("Performing local move between directories") + + shutil.move(str(source_file), str(dest_file)) + + if source_file.exists(): + return self.fail("Source file still exists after move") + + if not dest_file.exists(): + return self.fail("Destination file missing after move") + + # ------------------------- + # Phase 3: Upload Change + # ------------------------- + rc = self.run_onedrive("--sync", "--verbose", "--display-running-config") + if rc != 0: + return self.fail("Upload phase failed") + + # ------------------------- + # Phase 4: Validation (fresh client) + # ------------------------- + self.log("Validating remote state via fresh client") + + validator_dir = self.create_isolated_workdir("validator") + + rc = self.run_onedrive( + "--sync", + "--verbose", + "--display-running-config", + sync_dir=validator_dir, + ) + if rc != 0: + return self.fail("Validation sync failed") + + v_source = validator_dir / "TestRoot" / "SourceDirectory" / "move-me.txt" + v_dest = validator_dir / "TestRoot" / "DestinationDirectory" / "move-me.txt" + + if v_source.exists(): + return self.fail("File still present in source directory remotely") + + if not v_dest.exists(): + return self.fail("File not present in destination directory remotely") + + content = v_dest.read_text() + if content != "original-content\n": + return self.fail("File content mismatch after move") + + return self.pass_test() \ No newline at end of file From 03b01bc113fd0662dcabf8e0254a0c74ee9813c3 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Mon, 6 Apr 2026 18:26:11 +1000 Subject: [PATCH 142/245] Update tc0034_local_move_between_directories_validation.py * Update tc0034 --- ...cal_move_between_directories_validation.py | 328 ++++++++++++++---- 1 file changed, 263 insertions(+), 65 deletions(-) diff --git a/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py b/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py index a31a32835..e642f7750 100644 --- a/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py +++ b/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py @@ -1,96 +1,294 @@ -#!/usr/bin/env python3 -""" -Test Case 0034: local move between directories validation - -Move a file from one local directory to another without renaming it and -validate the remote result. - -This validates path reclassification rather than rename semantics. -""" +from __future__ import annotations import os -import shutil from pathlib import Path -from .base import TestCaseBase +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) + + +class TestCase0034LocalMoveBetweenDirectoriesValidation(E2ETestCase): + case_id = "0034" + name = "local move between directories validation" + description = ( + "Validate that moving a local file from one directory to another " + "is correctly propagated to remote state" + ) + + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( + "# tc0034 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) + + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0034" + case_log_dir = context.logs_dir / "tc0034" + state_dir = context.state_dir / "tc0034" + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() -class TestCase0034LocalMoveBetweenDirectories(TestCaseBase): - def run(self): - self.log("Test Case 0034: local move between directories validation") + local_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + conf_main = case_work_dir / "conf-main" + conf_verify = case_work_dir / "conf-verify" - sync_dir = Path(self.sync_dir) + reset_directory(local_root) + reset_directory(verify_root) - source_dir = sync_dir / "TestRoot" / "SourceDirectory" - dest_dir = sync_dir / "TestRoot" / "DestinationDirectory" + context.prepare_minimal_config_dir(conf_main, "") + context.prepare_minimal_config_dir(conf_verify, "") - source_dir.mkdir(parents=True, exist_ok=True) - dest_dir.mkdir(parents=True, exist_ok=True) + self._write_config(conf_main, local_root) + self._write_config(conf_verify, verify_root) - source_file = source_dir / "move-me.txt" - dest_file = dest_dir / "move-me.txt" + root_name = f"ZZ_E2E_TC0034_{context.run_id}_{os.getpid()}" + source_relative = f"{root_name}/SourceDirectory/move-me.txt" + destination_relative = f"{root_name}/DestinationDirectory/move-me.txt" + anchor_relative = f"{root_name}/DestinationDirectory/anchor.txt" - # ------------------------- - # Phase 1: Seed - # ------------------------- - self.log("Seeding initial file structure") + local_source_path = local_root / source_relative + local_destination_path = local_root / destination_relative + local_anchor_path = local_root / anchor_relative + + verify_source_path = verify_root / source_relative + verify_destination_path = verify_root / destination_relative + verify_anchor_path = verify_root / anchor_relative + + initial_content = ( + "TC0034 local move between directories validation\n" + "This content must survive the directory move unchanged.\n" + ) + anchor_content = ( + "TC0034 destination directory anchor\n" + "This ensures the destination directory exists before the move.\n" + ) - source_file.write_text("original-content\n") + phase1_stdout = case_log_dir / "phase1_seed_stdout.log" + phase1_stderr = case_log_dir / "phase1_seed_stderr.log" + phase2_stdout = case_log_dir / "phase2_move_stdout.log" + phase2_stderr = case_log_dir / "phase2_move_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" - # Anchor file to ensure destination exists everywhere - (dest_dir / "anchor.txt").write_text("anchor\n") + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(verify_stdout), + str(verify_stderr), + str(verify_manifest_file), + str(metadata_file), + ] - rc = self.run_onedrive("--sync", "--verbose", "--display-running-config") - if rc != 0: - return self.fail("Seed phase failed") + details: dict[str, object] = { + "root_name": root_name, + "source_relative": source_relative, + "destination_relative": destination_relative, + "anchor_relative": anchor_relative, + "main_conf_dir": str(conf_main), + "verify_conf_dir": str(conf_verify), + "local_root": str(local_root), + "verify_root": str(verify_root), + } - # ------------------------- - # Phase 2: Local Move - # ------------------------- - self.log("Performing local move between directories") + # Phase 1: seed original state with source file and destination anchor. + write_text_file(local_source_path, initial_content) + write_text_file(local_anchor_path, anchor_content) - shutil.move(str(source_file), str(dest_file)) + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + details["phase1_returncode"] = phase1_result.returncode - if source_file.exists(): - return self.fail("Source file still exists after move") + if phase1_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {phase1_result.returncode}", + artifacts, + details, + ) - if not dest_file.exists(): - return self.fail("Destination file missing after move") + # Phase 2: move the file locally between directories, without renaming it. + local_destination_path.parent.mkdir(parents=True, exist_ok=True) + local_source_path.rename(local_destination_path) - # ------------------------- - # Phase 3: Upload Change - # ------------------------- - rc = self.run_onedrive("--sync", "--verbose", "--display-running-config") - if rc != 0: - return self.fail("Upload phase failed") + details["local_source_exists_after_move"] = local_source_path.exists() + details["local_destination_exists_after_move"] = local_destination_path.is_file() + details["local_anchor_exists_after_move"] = local_anchor_path.is_file() - # ------------------------- - # Phase 4: Validation (fresh client) - # ------------------------- - self.log("Validating remote state via fresh client") + if local_source_path.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local source file still exists immediately after move", + artifacts, + details, + ) - validator_dir = self.create_isolated_workdir("validator") + if not local_destination_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local destination file does not exist immediately after move", + artifacts, + details, + ) - rc = self.run_onedrive( + phase2_command = [ + context.onedrive_bin, + "--display-running-config", "--sync", "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase2: {command_to_string(phase2_command)}") + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + details["phase2_returncode"] = phase2_result.returncode + + if phase2_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"move propagation phase failed with status {phase2_result.returncode}", + artifacts, + details, + ) + + # Phase 3: verify remote truth from a fresh client. + verify_command = [ + context.onedrive_bin, "--display-running-config", - sync_dir=validator_dir, + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + details["verify_source_exists"] = verify_source_path.exists() + details["verify_destination_exists"] = verify_destination_path.is_file() + details["verify_anchor_exists"] = verify_anchor_path.is_file() + + verify_destination_content = ( + verify_destination_path.read_text(encoding="utf-8") + if verify_destination_path.is_file() + else "" ) - if rc != 0: - return self.fail("Validation sync failed") + details["verify_destination_content"] = verify_destination_content + + self._write_metadata(metadata_file, details) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) - v_source = validator_dir / "TestRoot" / "SourceDirectory" / "move-me.txt" - v_dest = validator_dir / "TestRoot" / "DestinationDirectory" / "move-me.txt" + if verify_source_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification still contains source file path: {source_relative}", + artifacts, + details, + ) - if v_source.exists(): - return self.fail("File still present in source directory remotely") + if not verify_destination_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing moved file at destination path: {destination_relative}", + artifacts, + details, + ) - if not v_dest.exists(): - return self.fail("File not present in destination directory remotely") + if verify_destination_content != initial_content: + return TestResult.fail_result( + self.case_id, + self.name, + "moved file content did not match the original content after remote verification", + artifacts, + details, + ) - content = v_dest.read_text() - if content != "original-content\n": - return self.fail("File content mismatch after move") + if not verify_anchor_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing destination anchor file: {anchor_relative}", + artifacts, + details, + ) - return self.pass_test() \ No newline at end of file + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From fc346edcd9a28f2fa8bb1e1ffcd01514ede5830a Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 06:03:10 +1000 Subject: [PATCH 143/245] Update tc0034 Update tc0034 --- ci.zip | Bin 0 -> 90277 bytes ci/e2e/run.py | 5 +++-- ...local_move_between_directories_validation.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 ci.zip diff --git a/ci.zip b/ci.zip new file mode 100644 index 0000000000000000000000000000000000000000..b30c376602d76d4f4d96c37d92f94f114c7dcf3c GIT binary patch literal 90277 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 s4Fk;3Iu+Qu01DMWY(w?}Y# list: @@ -85,7 +86,7 @@ def build_test_suite() -> list: #TestCase0031LocalDirectoryRenamePropagationValidation(), #TestCase0032RemoteRenameReconciliation(), #TestCase0033RemoteDirectoryRenameReconciliation(), - TestCase0034LocalMoveBetweenDirectories, + TestCase0034LocalMoveBetweenDirectoriesValidation(), ] diff --git a/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py b/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py index e642f7750..5d52b5ddb 100644 --- a/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py +++ b/ci/e2e/testcases/tc0034_local_move_between_directories_validation.py @@ -126,7 +126,7 @@ def run(self, context: E2EContext) -> TestResult: "verify_root": str(verify_root), } - # Phase 1: seed original state with source file and destination anchor. + # Phase 1: seed original state with source file and destination anchor write_text_file(local_source_path, initial_content) write_text_file(local_anchor_path, anchor_content) @@ -156,7 +156,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 2: move the file locally between directories, without renaming it. + # Phase 2: move the file locally between directories without renaming it local_destination_path.parent.mkdir(parents=True, exist_ok=True) local_source_path.rename(local_destination_path) @@ -210,7 +210,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - # Phase 3: verify remote truth from a fresh client. + # Phase 3: verify remote truth from a fresh client verify_command = [ context.onedrive_bin, "--display-running-config", From 79a88c3369cf3e7f0be45234dc208068a6132477 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 07:31:13 +1000 Subject: [PATCH 144/245] Add tc0035 Add tc0035 --- ci.zip | Bin 90277 -> 0 bytes ci/e2e/run.py | 4 +- ...move_between_directories_reconciliation.py | 448 ++++++++++++++++++ 3 files changed, 450 insertions(+), 2 deletions(-) delete mode 100644 ci.zip create mode 100644 ci/e2e/testcases/tc0035_remote_move_between_directories_reconciliation.py diff --git a/ci.zip b/ci.zip deleted file mode 100644 index b30c376602d76d4f4d96c37d92f94f114c7dcf3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 90277 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 s4Fk;3Iu+Qu01DMWY(w?}Y# list: """ @@ -87,6 +86,7 @@ def build_test_suite() -> list: #TestCase0032RemoteRenameReconciliation(), #TestCase0033RemoteDirectoryRenameReconciliation(), TestCase0034LocalMoveBetweenDirectoriesValidation(), + TestCase0035RemoteMoveBetweenDirectoriesReconciliation(), ] diff --git a/ci/e2e/testcases/tc0035_remote_move_between_directories_reconciliation.py b/ci/e2e/testcases/tc0035_remote_move_between_directories_reconciliation.py new file mode 100644 index 000000000..e04c3324d --- /dev/null +++ b/ci/e2e/testcases/tc0035_remote_move_between_directories_reconciliation.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) + + +class TestCase0035RemoteMoveBetweenDirectoriesReconciliation(E2ETestCase): + case_id = "0035" + name = "remote move between directories reconciliation" + description = ( + "Validate that a stale local client correctly reconciles a remote-side " + "file move between directories without leaving stale local file leftovers" + ) + + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( + "# tc0035 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) + + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + def _list_files_under(self, root: Path) -> list[str]: + if not root.exists(): + return [] + return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_file()) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0035" + case_log_dir = context.logs_dir / "tc0035" + state_dir = context.state_dir / "tc0035" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + seed_root = case_work_dir / "seedroot" + stale_root = case_work_dir / "staleroot" + verify_root = case_work_dir / "verifyroot" + + conf_seed = case_work_dir / "conf-seed" + conf_stale = case_work_dir / "conf-stale" + conf_verify = case_work_dir / "conf-verify" + + reset_directory(seed_root) + reset_directory(verify_root) + + context.prepare_minimal_config_dir(conf_seed, "") + context.prepare_minimal_config_dir(conf_verify, "") + + self._write_config(conf_seed, seed_root) + self._write_config(conf_verify, verify_root) + + root_name = f"ZZ_E2E_TC0035_{context.run_id}_{os.getpid()}" + + source_relative = f"{root_name}/SourceDirectory/move-me.txt" + destination_relative = f"{root_name}/DestinationDirectory/move-me.txt" + anchor_relative = f"{root_name}/DestinationDirectory/anchor.txt" + + seed_source_path = seed_root / source_relative + seed_destination_path = seed_root / destination_relative + seed_anchor_path = seed_root / anchor_relative + + stale_source_path = stale_root / source_relative + stale_destination_path = stale_root / destination_relative + stale_anchor_path = stale_root / anchor_relative + + verify_source_path = verify_root / source_relative + verify_destination_path = verify_root / destination_relative + verify_anchor_path = verify_root / anchor_relative + + stale_source_dir = stale_root / f"{root_name}/SourceDirectory" + verify_source_dir = verify_root / f"{root_name}/SourceDirectory" + + initial_content = ( + "TC0035 remote move between directories reconciliation\n" + "This file is moved remotely and must reconcile locally.\n" + ) + anchor_content = ( + "TC0035 destination directory anchor\n" + "This ensures the destination directory exists before the move.\n" + ) + + seed_stdout = case_log_dir / "phase1_seed_stdout.log" + seed_stderr = case_log_dir / "phase1_seed_stderr.log" + remote_move_stdout = case_log_dir / "phase2_remote_move_stdout.log" + remote_move_stderr = case_log_dir / "phase2_remote_move_stderr.log" + stale_sync_stdout = case_log_dir / "phase3_stale_reconcile_stdout.log" + stale_sync_stderr = case_log_dir / "phase3_stale_reconcile_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + stale_manifest_file = state_dir / "stale_manifest.txt" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(seed_stdout), + str(seed_stderr), + str(remote_move_stdout), + str(remote_move_stderr), + str(stale_sync_stdout), + str(stale_sync_stderr), + str(verify_stdout), + str(verify_stderr), + str(stale_manifest_file), + str(verify_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "source_relative": source_relative, + "destination_relative": destination_relative, + "anchor_relative": anchor_relative, + "seed_root": str(seed_root), + "stale_root": str(stale_root), + "verify_root": str(verify_root), + "seed_conf_dir": str(conf_seed), + "stale_conf_dir": str(conf_stale), + "verify_conf_dir": str(conf_verify), + } + + # Phase 1: seed original remote state + write_text_file(seed_source_path, initial_content) + write_text_file(seed_anchor_path, anchor_content) + + seed_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} phase1 seed: {command_to_string(seed_command)}") + seed_result = run_command(seed_command, cwd=context.repo_root) + write_text_file(seed_stdout, seed_result.stdout) + write_text_file(seed_stderr, seed_result.stderr) + details["seed_returncode"] = seed_result.returncode + + if seed_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {seed_result.returncode}", + artifacts, + details, + ) + + # Snapshot synchronised local + config/db state to create a stale client. + # This stale client represents a second machine that has not yet seen the move. + if conf_stale.exists(): + shutil.rmtree(conf_stale) + if stale_root.exists(): + shutil.rmtree(stale_root) + + shutil.copytree(conf_seed, conf_stale) + shutil.copytree(seed_root, stale_root) + + # Rewrite stale runtime config so it points at stale_root while preserving DB state. + self._write_config(conf_stale, stale_root) + + details["stale_snapshot_source_exists_before_reconcile"] = stale_source_path.is_file() + details["stale_snapshot_destination_exists_before_reconcile"] = stale_destination_path.exists() + details["stale_snapshot_anchor_exists_before_reconcile"] = stale_anchor_path.is_file() + + if not stale_source_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "stale snapshot did not preserve original source file before reconciliation", + artifacts, + details, + ) + + # Phase 2: perform the move through the seed client. + # This is our remote-side move mechanism. + seed_destination_path.parent.mkdir(parents=True, exist_ok=True) + seed_source_path.rename(seed_destination_path) + + details["seed_source_exists_after_local_move"] = seed_source_path.exists() + details["seed_destination_exists_after_local_move"] = seed_destination_path.is_file() + details["seed_anchor_exists_after_local_move"] = seed_anchor_path.is_file() + + if seed_source_path.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seed local source path still exists immediately after move", + artifacts, + details, + ) + + if not seed_destination_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "seed local destination path does not exist immediately after move", + artifacts, + details, + ) + + remote_move_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_seed), + ] + context.log(f"Executing Test Case {self.case_id} phase2 remote move: {command_to_string(remote_move_command)}") + remote_move_result = run_command(remote_move_command, cwd=context.repo_root) + write_text_file(remote_move_stdout, remote_move_result.stdout) + write_text_file(remote_move_stderr, remote_move_result.stderr) + details["remote_move_returncode"] = remote_move_result.returncode + + if remote_move_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"remote move propagation phase failed with status {remote_move_result.returncode}", + artifacts, + details, + ) + + # Phase 3: stale client reconciles the remote move using existing DB/local state. + # No --resync here, because this is specifically a reconciliation test. + stale_sync_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_stale), + ] + context.log(f"Executing Test Case {self.case_id} phase3 stale reconcile: {command_to_string(stale_sync_command)}") + stale_sync_result = run_command(stale_sync_command, cwd=context.repo_root) + write_text_file(stale_sync_stdout, stale_sync_result.stdout) + write_text_file(stale_sync_stderr, stale_sync_result.stderr) + details["stale_reconcile_returncode"] = stale_sync_result.returncode + + stale_manifest = build_manifest(stale_root) + write_manifest(stale_manifest_file, stale_manifest) + + details["stale_source_exists_after_reconcile"] = stale_source_path.exists() + details["stale_destination_exists_after_reconcile"] = stale_destination_path.is_file() + details["stale_anchor_exists_after_reconcile"] = stale_anchor_path.is_file() + details["stale_source_dir_files_after_reconcile"] = self._list_files_under(stale_source_dir) + + stale_destination_content = ( + stale_destination_path.read_text(encoding="utf-8") + if stale_destination_path.is_file() + else "" + ) + details["stale_destination_content"] = stale_destination_content + + if stale_sync_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"stale reconciliation phase failed with status {stale_sync_result.returncode}", + artifacts, + details, + ) + + # Final clean remote verification from scratch. + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + details["verify_source_exists"] = verify_source_path.exists() + details["verify_destination_exists"] = verify_destination_path.is_file() + details["verify_anchor_exists"] = verify_anchor_path.is_file() + details["verify_source_dir_files"] = self._list_files_under(verify_source_dir) + + verify_destination_content = ( + verify_destination_path.read_text(encoding="utf-8") + if verify_destination_path.is_file() + else "" + ) + details["verify_destination_content"] = verify_destination_content + + self._write_metadata(metadata_file, details) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + + # Stale client assertions: existing-state client must reconcile cleanly. + if stale_source_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client still contains original source file after reconciliation: {source_relative}", + artifacts, + details, + ) + + if details["stale_source_dir_files_after_reconcile"]: + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client retained old files under source directory after reconciliation: {details['stale_source_dir_files_after_reconcile']}", + artifacts, + details, + ) + + if not stale_destination_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client is missing moved file after reconciliation: {destination_relative}", + artifacts, + details, + ) + + if stale_destination_content != initial_content: + return TestResult.fail_result( + self.case_id, + self.name, + "stale client moved file content did not match expected content after reconciliation", + artifacts, + details, + ) + + if not stale_anchor_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"stale client is missing destination anchor after reconciliation: {anchor_relative}", + artifacts, + details, + ) + + # Verify assertions: fresh remote truth must also be correct. + if verify_source_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification still contains original source file path: {source_relative}", + artifacts, + details, + ) + + if details["verify_source_dir_files"]: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification retained old files under source directory: {details['verify_source_dir_files']}", + artifacts, + details, + ) + + if not verify_destination_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing moved file at destination path: {destination_relative}", + artifacts, + details, + ) + + if verify_destination_content != initial_content: + return TestResult.fail_result( + self.case_id, + self.name, + "remote verification moved file content did not match expected content", + artifacts, + details, + ) + + if not verify_anchor_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing destination anchor file: {anchor_relative}", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 3024b143879c63c6169fec1a1d7a678ff486df73 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 07:47:12 +1000 Subject: [PATCH 145/245] Update PR * Update PR --- docs/end_to_end_testing.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index bc7de7bef..6aa9a62d8 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -54,3 +54,6 @@ SharePoint end-to-end testing uses the same complete automated test suite as Per | 0031 | Local directory rename propagation validation | - Personal
- Business
- SharePoint | This test validates that renaming a local directory tree is correctly propagated to remote state | | 0032 | Remote file rename reconciliation | - Personal
- Business
- SharePoint | This test validates that a stale local client correctly reconciles a remote-side file rename without leaving stale local leftovers | | 0033 | remote directory rename reconciliation | - Personal
- Business
- SharePoint | This test validates that a second client with existing local and database state correctly reconciles a remote directory rename propagated by another synchronising client | +| 0034 | Local move between directories validation | - Personal
- Business
- SharePoint | This test validates that moving a local file from one directory to another is correctly propagated to remote state | +| 0035 | Remote move between directories reconciliation | - Personal
- Business
- SharePoint | This test validates that a stale local client correctly reconciles a remote-side file move between directories without leaving stale local file leftovers | + From 60e082e4ceaa9963630b70e1db72086b2a56fc9e Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 09:03:51 +1000 Subject: [PATCH 146/245] Add tc0036 Add tc0036 --- ci/e2e/run.py | 2 + ...eplace_existing_file_content_validation.py | 100 ++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 ci/e2e/testcases/tc0036_overwrite_replace_existing_file_content_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 9a923ad9d..f10bb3f04 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -44,6 +44,7 @@ from testcases.tc0033_remote_directory_rename_reconciliation import TestCase0033RemoteDirectoryRenameReconciliation from testcases.tc0034_local_move_between_directories_validation import TestCase0034LocalMoveBetweenDirectoriesValidation from testcases.tc0035_remote_move_between_directories_reconciliation import TestCase0035RemoteMoveBetweenDirectoriesReconciliation +from testcases.tc0036_overwrite_replace_existing_file_content_validation import TestCase0036OverwriteReplaceExistingFileContentValidation def build_test_suite() -> list: """ @@ -87,6 +88,7 @@ def build_test_suite() -> list: #TestCase0033RemoteDirectoryRenameReconciliation(), TestCase0034LocalMoveBetweenDirectoriesValidation(), TestCase0035RemoteMoveBetweenDirectoriesReconciliation(), + TestCase0036OverwriteReplaceExistingFileContentValidation(), ] diff --git a/ci/e2e/testcases/tc0036_overwrite_replace_existing_file_content_validation.py b/ci/e2e/testcases/tc0036_overwrite_replace_existing_file_content_validation.py new file mode 100644 index 000000000..344605c7a --- /dev/null +++ b/ci/e2e/testcases/tc0036_overwrite_replace_existing_file_content_validation.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Test Case 0036: overwrite / replace existing file content validation + +Create a file, sync it, then replace its contents locally with the same name +and validate that the remote item content updates correctly without metadata confusion. +""" + +import os +from .base import TestCaseBase + + +class TestCase0036OverwriteReplaceExistingFileContentValidation(TestCaseBase): + + def run(self): + self.log("Starting Test Case 0036: overwrite / replace existing file content validation") + + test_root = self.get_testcase_root_dir() + + file_path = os.path.join(test_root, "replace-me.txt") + + # ------------------------------------------------------------------ + # Phase 1: Create initial file + # ------------------------------------------------------------------ + initial_content = "INITIAL_CONTENT_TC0036\n" + self.write_file(file_path, initial_content) + + self.log(f"Created initial file: {file_path}") + + # ------------------------------------------------------------------ + # Phase 2: Sync initial content upstream + # ------------------------------------------------------------------ + self.run_onedrive_sync() + + self.assert_remote_item_exists("replace-me.txt") + + # ------------------------------------------------------------------ + # Phase 3: Overwrite local file with new content + # ------------------------------------------------------------------ + replacement_content = "REPLACED_CONTENT_TC0036\n" + self.write_file(file_path, replacement_content) + + self.log("Overwrote local file with new content") + + # ------------------------------------------------------------------ + # Phase 4: Sync updated content upstream + # ------------------------------------------------------------------ + self.run_onedrive_sync() + + # ------------------------------------------------------------------ + # Phase 5: Validate via fresh download-only instance + # ------------------------------------------------------------------ + validator_dir = self.create_secondary_sync_dir() + + self.run_onedrive( + [ + "--download-only", + "--syncdir", validator_dir, + "--resync", + "--resync-auth", + "--verbose", + "--verbose", + "--display-running-config" + ] + ) + + validator_file = os.path.join(validator_dir, os.path.basename(test_root), "replace-me.txt") + + # ------------------------------------------------------------------ + # Assertions + # ------------------------------------------------------------------ + if not os.path.exists(validator_file): + raise Exception("Validated file does not exist after download") + + content = self.read_file(validator_file) + + if replacement_content not in content: + raise Exception( + "Replacement content not found in downloaded file - overwrite did not propagate" + ) + + if initial_content in content: + raise Exception( + "Initial content still present after overwrite - content replacement failed" + ) + + # Ensure only expected file exists + files = self.list_all_files(validator_dir) + + expected_relative = os.path.join(os.path.basename(test_root), "replace-me.txt") + + if expected_relative not in files: + raise Exception("Expected file missing from validator directory structure") + + if len(files) != 1: + raise Exception(f"Unexpected files present after overwrite test: {files}") + + self.log("Test Case 0036 completed successfully") \ No newline at end of file From 30cc9cb35347370758a8ee2f507b7a1a27d5bd56 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 09:11:49 +1000 Subject: [PATCH 147/245] Update tc0036 Update tc0036 --- ...eplace_existing_file_content_validation.py | 316 ++++++++++++++---- docs/end_to_end_testing.md | 7 +- readme.md | 7 +- 3 files changed, 254 insertions(+), 76 deletions(-) diff --git a/ci/e2e/testcases/tc0036_overwrite_replace_existing_file_content_validation.py b/ci/e2e/testcases/tc0036_overwrite_replace_existing_file_content_validation.py index 344605c7a..30e61ad0a 100644 --- a/ci/e2e/testcases/tc0036_overwrite_replace_existing_file_content_validation.py +++ b/ci/e2e/testcases/tc0036_overwrite_replace_existing_file_content_validation.py @@ -1,100 +1,276 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- +from __future__ import annotations -""" -Test Case 0036: overwrite / replace existing file content validation +import os +from pathlib import Path -Create a file, sync it, then replace its contents locally with the same name -and validate that the remote item content updates correctly without metadata confusion. -""" +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) -import os -from .base import TestCaseBase +class TestCase0036OverwriteReplaceExistingFileContentValidation(E2ETestCase): + case_id = "0036" + name = "overwrite / replace existing file content validation" + description = ( + "Validate that replacing the contents of an existing local file with the same " + "name correctly updates remote content without leaving stale content or " + "metadata confusion" + ) -class TestCase0036OverwriteReplaceExistingFileContentValidation(TestCaseBase): + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" - def run(self): - self.log("Starting Test Case 0036: overwrite / replace existing file content validation") + config_text = ( + "# tc0036 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) - test_root = self.get_testcase_root_dir() + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) - file_path = os.path.join(test_root, "replace-me.txt") + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) - # ------------------------------------------------------------------ - # Phase 1: Create initial file - # ------------------------------------------------------------------ - initial_content = "INITIAL_CONTENT_TC0036\n" - self.write_file(file_path, initial_content) + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0036" + case_log_dir = context.logs_dir / "tc0036" + state_dir = context.state_dir / "tc0036" - self.log(f"Created initial file: {file_path}") + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() - # ------------------------------------------------------------------ - # Phase 2: Sync initial content upstream - # ------------------------------------------------------------------ - self.run_onedrive_sync() + local_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + conf_main = case_work_dir / "conf-main" + conf_verify = case_work_dir / "conf-verify" - self.assert_remote_item_exists("replace-me.txt") + reset_directory(local_root) + reset_directory(verify_root) - # ------------------------------------------------------------------ - # Phase 3: Overwrite local file with new content - # ------------------------------------------------------------------ - replacement_content = "REPLACED_CONTENT_TC0036\n" - self.write_file(file_path, replacement_content) + context.prepare_minimal_config_dir(conf_main, "") + context.prepare_minimal_config_dir(conf_verify, "") - self.log("Overwrote local file with new content") + self._write_config(conf_main, local_root) + self._write_config(conf_verify, verify_root) - # ------------------------------------------------------------------ - # Phase 4: Sync updated content upstream - # ------------------------------------------------------------------ - self.run_onedrive_sync() + root_name = f"ZZ_E2E_TC0036_{context.run_id}_{os.getpid()}" + relative_path = f"{root_name}/replace-me.txt" - # ------------------------------------------------------------------ - # Phase 5: Validate via fresh download-only instance - # ------------------------------------------------------------------ - validator_dir = self.create_secondary_sync_dir() + local_file_path = local_root / relative_path + verify_file_path = verify_root / relative_path - self.run_onedrive( - [ - "--download-only", - "--syncdir", validator_dir, - "--resync", - "--resync-auth", - "--verbose", - "--verbose", - "--display-running-config" - ] + initial_content = ( + "TC0036 overwrite replace existing file content validation\n" + "INITIAL VERSION\n" + "This content must be fully replaced.\n" + ) + replacement_content = ( + "TC0036 overwrite replace existing file content validation\n" + "REPLACED VERSION\n" + "This content must be the only final content present.\n" ) - validator_file = os.path.join(validator_dir, os.path.basename(test_root), "replace-me.txt") + phase1_stdout = case_log_dir / "phase1_seed_stdout.log" + phase1_stderr = case_log_dir / "phase1_seed_stderr.log" + phase2_stdout = case_log_dir / "phase2_replace_stdout.log" + phase2_stderr = case_log_dir / "phase2_replace_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(verify_stdout), + str(verify_stderr), + str(verify_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "relative_path": relative_path, + "main_conf_dir": str(conf_main), + "verify_conf_dir": str(conf_verify), + "local_root": str(local_root), + "verify_root": str(verify_root), + } + + # Phase 1: seed initial file content + write_text_file(local_file_path, initial_content) + + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + details["phase1_returncode"] = phase1_result.returncode + + if phase1_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {phase1_result.returncode}", + artifacts, + details, + ) + + # Phase 2: replace the file contents locally with same filename + write_text_file(local_file_path, replacement_content) - # ------------------------------------------------------------------ - # Assertions - # ------------------------------------------------------------------ - if not os.path.exists(validator_file): - raise Exception("Validated file does not exist after download") + details["local_file_exists_after_replace"] = local_file_path.is_file() + details["local_file_size_after_replace"] = local_file_path.stat().st_size if local_file_path.is_file() else -1 - content = self.read_file(validator_file) + if not local_file_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local file does not exist immediately after content replacement", + artifacts, + details, + ) + + phase2_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase2: {command_to_string(phase2_command)}") + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + details["phase2_returncode"] = phase2_result.returncode - if replacement_content not in content: - raise Exception( - "Replacement content not found in downloaded file - overwrite did not propagate" + if phase2_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"replace propagation phase failed with status {phase2_result.returncode}", + artifacts, + details, ) - if initial_content in content: - raise Exception( - "Initial content still present after overwrite - content replacement failed" + # Phase 3: verify remote truth from a fresh client + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log(f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}") + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + details["verify_manifest"] = verify_manifest + details["verified_file_exists"] = verify_file_path.exists() + + verified_content = verify_file_path.read_text(encoding="utf-8") if verify_file_path.is_file() else "" + details["verified_content"] = verified_content + + expected_manifest = [ + root_name, + relative_path, + ] + details["expected_manifest"] = expected_manifest + + self._write_metadata(metadata_file, details) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, ) - # Ensure only expected file exists - files = self.list_all_files(validator_dir) + if not verify_file_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing expected file: {relative_path}", + artifacts, + details, + ) - expected_relative = os.path.join(os.path.basename(test_root), "replace-me.txt") + if verified_content != replacement_content: + return TestResult.fail_result( + self.case_id, + self.name, + "verified file content did not match the replacement content after remote verification", + artifacts, + details, + ) - if expected_relative not in files: - raise Exception("Expected file missing from validator directory structure") + if initial_content == verified_content: + return TestResult.fail_result( + self.case_id, + self.name, + "verified file content still matches the initial content after replacement", + artifacts, + details, + ) - if len(files) != 1: - raise Exception(f"Unexpected files present after overwrite test: {files}") + if verify_manifest != expected_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + "remote verification manifest did not match the expected single-file structure after content replacement", + artifacts, + details, + ) - self.log("Test Case 0036 completed successfully") \ No newline at end of file + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 6aa9a62d8..570342a1f 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -1,8 +1,9 @@ # End to End Testing of OneDrive Client for Linux -[![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) -[![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) -[![e2e Testing SharePoint](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![E2E Testing - Personal Account](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![E2E Testing - Personal Account: 15 Character driveId Check](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal-15char-check.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal-15char-check.yaml) +[![E2E Testing - Business Account](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![E2E Testing - SharePoint Configuration](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) This document describes the **end-to-end (E2E) automated testing framework** used to validate the behaviour of the OneDrive Client for Linux. diff --git a/readme.md b/readme.md index 9477197fd..70b71dcc1 100644 --- a/readme.md +++ b/readme.md @@ -7,9 +7,10 @@ [![Build Docker Images](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/docker.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/driveone/onedrive)](https://hub.docker.com/r/driveone/onedrive) -[![e2e Testing Personal](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) -[![e2e Testing Business](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) -[![e2e Testing SharePoint](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![E2E Testing - Personal Account](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![E2E Testing - Personal Account: 15 Character driveId Check](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal-15char-check.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal-15char-check.yaml) +[![E2E Testing - Business Account](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![E2E Testing - SharePoint Configuration](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) A fully featured, free, and actively maintained Microsoft OneDrive client that seamlessly supports OneDrive Personal, OneDrive for Business, Microsoft 365 (formerly Office 365), and SharePoint document libraries. From 158a74db28ac8559779e978ea862b6d22ca89ff9 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 09:27:55 +1000 Subject: [PATCH 148/245] Change titles of E2E workflows Change titles of E2E workflows --- .github/workflows/e2e-business.yaml | 2 +- .github/workflows/e2e-personal-15char-check.yaml | 2 +- .github/workflows/e2e-personal.yaml | 2 +- .github/workflows/e2e-sharepoint.yaml | 2 +- readme.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e-business.yaml b/.github/workflows/e2e-business.yaml index 13d11689f..e4969b0f1 100644 --- a/.github/workflows/e2e-business.yaml +++ b/.github/workflows/e2e-business.yaml @@ -1,4 +1,4 @@ -name: E2E Business Account Testing +name: E2E Testing - Business Account on: push: diff --git a/.github/workflows/e2e-personal-15char-check.yaml b/.github/workflows/e2e-personal-15char-check.yaml index 6bc4dc7bd..fce8bd387 100644 --- a/.github/workflows/e2e-personal-15char-check.yaml +++ b/.github/workflows/e2e-personal-15char-check.yaml @@ -1,4 +1,4 @@ -name: E2E Personal Account Testing - 15 Char Check +name: E2E Testing - Personal Account: 15 Character driveId Check on: push: diff --git a/.github/workflows/e2e-personal.yaml b/.github/workflows/e2e-personal.yaml index 38eb21206..9c866c7e2 100644 --- a/.github/workflows/e2e-personal.yaml +++ b/.github/workflows/e2e-personal.yaml @@ -1,4 +1,4 @@ -name: E2E Personal Account Testing +name: E2E Testing - Personal Account on: push: diff --git a/.github/workflows/e2e-sharepoint.yaml b/.github/workflows/e2e-sharepoint.yaml index 4682678e9..e44b65b19 100644 --- a/.github/workflows/e2e-sharepoint.yaml +++ b/.github/workflows/e2e-sharepoint.yaml @@ -1,4 +1,4 @@ -name: E2E SharePoint Account Testing +name: E2E Testing - SharePoint documentLibrary Configuration on: push: diff --git a/readme.md b/readme.md index 70b71dcc1..20ce9bb3a 100644 --- a/readme.md +++ b/readme.md @@ -10,7 +10,7 @@ [![E2E Testing - Personal Account](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) [![E2E Testing - Personal Account: 15 Character driveId Check](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal-15char-check.yaml/badge.svg)](https://github.com/abraunegg/onedrive/actions/workflows/e2e-personal-15char-check.yaml) [![E2E Testing - Business Account](https://github.com/abraunegg/onedrive/actions/workflows/e2e-business.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) -[![E2E Testing - SharePoint Configuration](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) +[![E2E Testing - SharePoint documentLibrary Configuration](https://github.com/abraunegg/onedrive/actions/workflows/e2e-sharepoint.yaml/badge.svg)](https://github.com/abraunegg/onedrive/blob/master/docs/end_to_end_testing.md) A fully featured, free, and actively maintained Microsoft OneDrive client that seamlessly supports OneDrive Personal, OneDrive for Business, Microsoft 365 (formerly Office 365), and SharePoint document libraries. From e56060f708eca8dd273671eb5a78307e1dfeccdb Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 09:32:03 +1000 Subject: [PATCH 149/245] Update e2e-personal-15char-check.yaml * Update yaml name --- .github/workflows/e2e-personal-15char-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-personal-15char-check.yaml b/.github/workflows/e2e-personal-15char-check.yaml index fce8bd387..7ef83eaa5 100644 --- a/.github/workflows/e2e-personal-15char-check.yaml +++ b/.github/workflows/e2e-personal-15char-check.yaml @@ -1,4 +1,4 @@ -name: E2E Testing - Personal Account: 15 Character driveId Check +name: E2E Testing - Personal Account with 15 Character driveId Check on: push: From 4f328c7a262fd175dec022d1489b35fbc525a096 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 09:53:29 +1000 Subject: [PATCH 150/245] Add tc0037 Add tc0037 --- ci/e2e/run.py | 2 + ...tc0037_mtime_only_local_change_handling.py | 370 ++++++++++++++++++ docs/end_to_end_testing.md | 7 + 3 files changed, 379 insertions(+) create mode 100644 ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index f10bb3f04..0494ec466 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -45,6 +45,7 @@ from testcases.tc0034_local_move_between_directories_validation import TestCase0034LocalMoveBetweenDirectoriesValidation from testcases.tc0035_remote_move_between_directories_reconciliation import TestCase0035RemoteMoveBetweenDirectoriesReconciliation from testcases.tc0036_overwrite_replace_existing_file_content_validation import TestCase0036OverwriteReplaceExistingFileContentValidation +from testcases.tc0037_mtime_only_local_change_handling import TestCase0037MtimeOnlyLocalChangeHandling def build_test_suite() -> list: """ @@ -89,6 +90,7 @@ def build_test_suite() -> list: TestCase0034LocalMoveBetweenDirectoriesValidation(), TestCase0035RemoteMoveBetweenDirectoriesReconciliation(), TestCase0036OverwriteReplaceExistingFileContentValidation(), + TestCase0037MtimeOnlyLocalChangeHandling(), ] diff --git a/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py b/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py new file mode 100644 index 000000000..74f13fbe3 --- /dev/null +++ b/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from pathlib import Path +from typing import Dict, List, Tuple + +from framework import E2EContext, E2ETestCase, TestResult + + +class TestCase0037MtimeOnlyLocalChangeHandling(E2ETestCase): + """ + tc0037 — mtime-only local change handling + + Create a file, upload it, then modify only the local mtime without changing + content. Validate that: + * sync completes successfully + * no duplicate / backup / conflict artefacts are created + * remote content remains unchanged + * remote mtime does not move forward purely because the local mtime changed + * the second sync does not attempt a content upload for the touched file + """ + + TEST_ID = "0037" + TEST_NAME = "mtime-only local change handling" + + def run(self, context: E2EContext) -> TestResult: + artifacts: List[str] = [] + + try: + testcase_root = Path(context.test_root) / "tc0037" + seeder_root = testcase_root / "seeder" + verifier_before_root = testcase_root / "verifier_before" + verifier_after_root = testcase_root / "verifier_after" + + self._reset_dir(testcase_root) + self._reset_dir(seeder_root) + self._reset_dir(verifier_before_root) + self._reset_dir(verifier_after_root) + + seeder_sync_dir = seeder_root / "sync_dir" + verifier_before_sync_dir = verifier_before_root / "sync_dir" + verifier_after_sync_dir = verifier_after_root / "sync_dir" + + seeder_sync_dir.mkdir(parents=True, exist_ok=True) + verifier_before_sync_dir.mkdir(parents=True, exist_ok=True) + verifier_after_sync_dir.mkdir(parents=True, exist_ok=True) + + target_relpath = Path("mtime-only.txt") + seeder_target = seeder_sync_dir / target_relpath + verifier_before_target = verifier_before_sync_dir / target_relpath + verifier_after_target = verifier_after_sync_dir / target_relpath + + initial_content = ( + "tc0037 baseline file content\n" + "This file is intentionally unchanged after initial upload.\n" + "Only the local mtime will be modified.\n" + ) + + seeder_target.write_text(initial_content, encoding="utf-8") + + seeder_config_dir = seeder_root / "config" + verifier_before_config_dir = verifier_before_root / "config" + verifier_after_config_dir = verifier_after_root / "config" + + self._write_config( + context=context, + config_dir=seeder_config_dir, + sync_dir=seeder_sync_dir, + extra_config_lines=[], + ) + self._write_config( + context=context, + config_dir=verifier_before_config_dir, + sync_dir=verifier_before_sync_dir, + extra_config_lines=[], + ) + self._write_config( + context=context, + config_dir=verifier_after_config_dir, + sync_dir=verifier_after_sync_dir, + extra_config_lines=[], + ) + + # + # Phase 1: upload baseline file + # + rc, stdout, stderr = self._run_onedrive( + context=context, + config_dir=seeder_config_dir, + extra_args=["--sync", "--verbose"], + ) + artifacts.extend( + self._write_phase_artifacts( + testcase_root, + "phase1_seed_upload", + rc, + stdout, + stderr, + ) + ) + if rc != 0: + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — initial upload failed with exit code {rc}", + artifacts=artifacts, + ) + + # + # Phase 2: fresh verifier downloads remote baseline state + # + rc, stdout, stderr = self._run_onedrive( + context=context, + config_dir=verifier_before_config_dir, + extra_args=["--sync", "--download-only", "--resync", "--resync-auth", "--verbose"], + ) + artifacts.extend( + self._write_phase_artifacts( + testcase_root, + "phase2_verify_remote_baseline", + rc, + stdout, + stderr, + ) + ) + if rc != 0: + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — baseline verifier download failed with exit code {rc}", + artifacts=artifacts, + ) + + if not verifier_before_target.exists(): + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — baseline verifier did not download {target_relpath}", + artifacts=artifacts, + ) + + baseline_remote_content = verifier_before_target.read_text(encoding="utf-8") + if baseline_remote_content != initial_content: + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — baseline verifier content mismatch for {target_relpath}", + artifacts=artifacts, + ) + + baseline_remote_mtime = int(verifier_before_target.stat().st_mtime) + + # + # Phase 3: touch local file only - no content change + # + touched_local_mtime = baseline_remote_mtime + 300 + os.utime(seeder_target, (touched_local_mtime, touched_local_mtime)) + + local_content_after_touch = seeder_target.read_text(encoding="utf-8") + if local_content_after_touch != initial_content: + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — local file content changed unexpectedly after mtime touch", + artifacts=artifacts, + ) + + # + # Phase 4: normal sync after mtime-only change + # + rc, stdout, stderr = self._run_onedrive( + context=context, + config_dir=seeder_config_dir, + extra_args=["--sync", "--verbose"], + ) + artifacts.extend( + self._write_phase_artifacts( + testcase_root, + "phase4_sync_after_mtime_touch", + rc, + stdout, + stderr, + ) + ) + if rc != 0: + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — sync after mtime-only touch failed with exit code {rc}", + artifacts=artifacts, + ) + + upload_indicators = [ + f"Uploading new file {target_relpath.name}", + f"Uploading differences of {target_relpath.name}", + f"Uploading file {target_relpath.name}", + ] + combined_log = f"{stdout}\n{stderr}" + matched_upload_indicators = [ + indicator for indicator in upload_indicators if indicator in combined_log + ] + if matched_upload_indicators: + return TestResult.fail_result( + self.TEST_ID, + ( + f"{self.TEST_NAME} — mtime-only change triggered upload behaviour " + f"for {target_relpath}: {', '.join(matched_upload_indicators)}" + ), + artifacts=artifacts, + ) + + unexpected_local_entries = self._find_unexpected_entries( + seeder_sync_dir, + allowed_relative_paths={str(target_relpath)}, + ) + if unexpected_local_entries: + return TestResult.fail_result( + self.TEST_ID, + ( + f"{self.TEST_NAME} — unexpected local artefacts created after mtime-only sync: " + f"{', '.join(unexpected_local_entries)}" + ), + artifacts=artifacts, + ) + + # + # Phase 5: fresh verifier downloads final remote state + # + rc, stdout, stderr = self._run_onedrive( + context=context, + config_dir=verifier_after_config_dir, + extra_args=["--sync", "--download-only", "--resync", "--resync-auth", "--verbose"], + ) + artifacts.extend( + self._write_phase_artifacts( + testcase_root, + "phase5_verify_remote_final_state", + rc, + stdout, + stderr, + ) + ) + if rc != 0: + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — final verifier download failed with exit code {rc}", + artifacts=artifacts, + ) + + if not verifier_after_target.exists(): + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — final verifier did not download {target_relpath}", + artifacts=artifacts, + ) + + final_remote_content = verifier_after_target.read_text(encoding="utf-8") + if final_remote_content != initial_content: + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — remote content changed after mtime-only local touch", + artifacts=artifacts, + ) + + final_remote_mtime = int(verifier_after_target.stat().st_mtime) + + if final_remote_mtime >= touched_local_mtime: + return TestResult.fail_result( + self.TEST_ID, + ( + f"{self.TEST_NAME} — remote mtime moved forward to {final_remote_mtime} " + f"after local mtime-only touch at {touched_local_mtime}" + ), + artifacts=artifacts, + ) + + if abs(final_remote_mtime - baseline_remote_mtime) > 2: + return TestResult.fail_result( + self.TEST_ID, + ( + f"{self.TEST_NAME} — remote mtime changed unexpectedly: " + f"baseline={baseline_remote_mtime}, final={final_remote_mtime}" + ), + artifacts=artifacts, + ) + + metadata = { + "testcase": self.TEST_ID, + "target_relpath": str(target_relpath), + "baseline_remote_mtime": baseline_remote_mtime, + "touched_local_mtime": touched_local_mtime, + "final_remote_mtime": final_remote_mtime, + "baseline_remote_content_length": len(baseline_remote_content), + "final_remote_content_length": len(final_remote_content), + } + metadata_path = testcase_root / "tc0037_metadata.json" + metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8") + artifacts.append(str(metadata_path)) + + return TestResult.pass_result( + self.TEST_ID, + ( + f"{self.TEST_NAME} — mtime-only local change was ignored as expected; " + f"content remained unchanged and remote mtime was not updated" + ), + artifacts=artifacts, + ) + + except Exception as exc: + return TestResult.fail_result( + self.TEST_ID, + f"{self.TEST_NAME} — unhandled exception: {exc}", + artifacts=artifacts, + ) + + def _run_onedrive( + self, + context: E2EContext, + config_dir: Path, + extra_args: List[str], + ) -> Tuple[int, str, str]: + command = [str(context.onedrive_path), "--confdir", str(config_dir)] + command.extend(extra_args) + + completed = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + return completed.returncode, completed.stdout, completed.stderr + + def _write_phase_artifacts( + self, + testcase_root: Path, + phase_name: str, + returncode: int, + stdout: str, + stderr: str, + ) -> List[str]: + phase_dir = testcase_root / "artifacts" / phase_name + phase_dir.mkdir(parents=True, exist_ok=True) + + stdout_path = phase_dir / "stdout.log" + stderr_path = phase_dir / "stderr.log" + rc_path = phase_dir / "returncode.txt" + + stdout_path.write_text(stdout, encoding="utf-8") + stderr_path.write_text(stderr, encoding="utf-8") + rc_path.write_text(str(returncode), encoding="utf-8") + + return [str(stdout_path), str(stderr_path), str(rc_path)] + + def _find_unexpected_entries(self, root: Path, allowed_relative_paths: set[str]) -> List[str]: + unexpected: List[str] = [] + + for path in sorted(root.rglob("*")): + if path.is_dir(): + continue + rel = str(path.relative_to(root)) + if rel not in allowed_relative_paths: + unexpected.append(rel) + + return unexpected + + def _reset_dir(self, path: Path) -> None: + if path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True, exist_ok=True) \ No newline at end of file diff --git a/docs/end_to_end_testing.md b/docs/end_to_end_testing.md index 570342a1f..f7532672a 100644 --- a/docs/end_to_end_testing.md +++ b/docs/end_to_end_testing.md @@ -58,3 +58,10 @@ SharePoint end-to-end testing uses the same complete automated test suite as Per | 0034 | Local move between directories validation | - Personal
- Business
- SharePoint | This test validates that moving a local file from one directory to another is correctly propagated to remote state | | 0035 | Remote move between directories reconciliation | - Personal
- Business
- SharePoint | This test validates that a stale local client correctly reconciles a remote-side file move between directories without leaving stale local file leftovers | +### Contributing Additional Test Cases + +While this end-to-end test suite provides broad and comprehensive coverage of the OneDrive Client for Linux, it is not exhaustive. Real-world usage continues to surface new edge cases, behaviours, and scenarios that may not yet be represented here. + +If you identify a gap in coverage, encounter a scenario that is not currently validated, or believe an existing test case could be improved, contributions are strongly encouraged. Please feel free to raise a discussion outlining the scenario, or ideally develop and submit a new test case via a pull request following the existing test framework structure. + +Community contributions play a critical role in strengthening the reliability and robustness of the client, and all well-defined additions are welcomed. From 6e0ef7cfeff299bf9ba04b1b9a2834df92be728f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 10:54:46 +1000 Subject: [PATCH 151/245] Update tc0037 Update tc0037 --- ...tc0037_mtime_only_local_change_handling.py | 709 +++++++++--------- 1 file changed, 371 insertions(+), 338 deletions(-) diff --git a/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py b/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py index 74f13fbe3..1aebd6f09 100644 --- a/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py +++ b/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py @@ -1,370 +1,403 @@ from __future__ import annotations -import json import os -import shutil -import subprocess import time from pathlib import Path -from typing import Dict, List, Tuple -from framework import E2EContext, E2ETestCase, TestResult +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) class TestCase0037MtimeOnlyLocalChangeHandling(E2ETestCase): - """ - tc0037 — mtime-only local change handling - - Create a file, upload it, then modify only the local mtime without changing - content. Validate that: - * sync completes successfully - * no duplicate / backup / conflict artefacts are created - * remote content remains unchanged - * remote mtime does not move forward purely because the local mtime changed - * the second sync does not attempt a content upload for the touched file - """ + case_id = "0037" + name = "mtime-only local change handling" + description = ( + "Validate that changing only the local modification timestamp of an existing " + "file does not cause unintended content upload or remote state change" + ) + + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( + "# tc0037 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) - TEST_ID = "0037" - TEST_NAME = "mtime-only local change handling" + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) def run(self, context: E2EContext) -> TestResult: - artifacts: List[str] = [] - - try: - testcase_root = Path(context.test_root) / "tc0037" - seeder_root = testcase_root / "seeder" - verifier_before_root = testcase_root / "verifier_before" - verifier_after_root = testcase_root / "verifier_after" - - self._reset_dir(testcase_root) - self._reset_dir(seeder_root) - self._reset_dir(verifier_before_root) - self._reset_dir(verifier_after_root) - - seeder_sync_dir = seeder_root / "sync_dir" - verifier_before_sync_dir = verifier_before_root / "sync_dir" - verifier_after_sync_dir = verifier_after_root / "sync_dir" - - seeder_sync_dir.mkdir(parents=True, exist_ok=True) - verifier_before_sync_dir.mkdir(parents=True, exist_ok=True) - verifier_after_sync_dir.mkdir(parents=True, exist_ok=True) - - target_relpath = Path("mtime-only.txt") - seeder_target = seeder_sync_dir / target_relpath - verifier_before_target = verifier_before_sync_dir / target_relpath - verifier_after_target = verifier_after_sync_dir / target_relpath - - initial_content = ( - "tc0037 baseline file content\n" - "This file is intentionally unchanged after initial upload.\n" - "Only the local mtime will be modified.\n" + case_work_dir = context.work_root / "tc0037" + case_log_dir = context.logs_dir / "tc0037" + state_dir = context.state_dir / "tc0037" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + local_root = case_work_dir / "syncroot" + verify_initial_root = case_work_dir / "verify-initial-root" + verify_final_root = case_work_dir / "verify-final-root" + + conf_main = case_work_dir / "conf-main" + conf_verify_initial = case_work_dir / "conf-verify-initial" + conf_verify_final = case_work_dir / "conf-verify-final" + + reset_directory(local_root) + reset_directory(verify_initial_root) + reset_directory(verify_final_root) + + context.prepare_minimal_config_dir(conf_main, "") + context.prepare_minimal_config_dir(conf_verify_initial, "") + context.prepare_minimal_config_dir(conf_verify_final, "") + + self._write_config(conf_main, local_root) + self._write_config(conf_verify_initial, verify_initial_root) + self._write_config(conf_verify_final, verify_final_root) + + root_name = f"ZZ_E2E_TC0037_{context.run_id}_{os.getpid()}" + relative_path = f"{root_name}/mtime-only.txt" + + local_file_path = local_root / relative_path + verify_initial_file_path = verify_initial_root / relative_path + verify_final_file_path = verify_final_root / relative_path + + initial_content = ( + "TC0037 mtime-only local change handling\n" + "This file content must remain unchanged.\n" + "Only the local modification timestamp is altered.\n" + ) + + phase1_stdout = case_log_dir / "phase1_seed_stdout.log" + phase1_stderr = case_log_dir / "phase1_seed_stderr.log" + verify_initial_stdout = case_log_dir / "phase2_verify_initial_stdout.log" + verify_initial_stderr = case_log_dir / "phase2_verify_initial_stderr.log" + phase3_stdout = case_log_dir / "phase3_touch_sync_stdout.log" + phase3_stderr = case_log_dir / "phase3_touch_sync_stderr.log" + verify_final_stdout = case_log_dir / "phase4_verify_final_stdout.log" + verify_final_stderr = case_log_dir / "phase4_verify_final_stderr.log" + verify_initial_manifest_file = state_dir / "verify_initial_manifest.txt" + verify_final_manifest_file = state_dir / "verify_final_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(verify_initial_stdout), + str(verify_initial_stderr), + str(phase3_stdout), + str(phase3_stderr), + str(verify_final_stdout), + str(verify_final_stderr), + str(verify_initial_manifest_file), + str(verify_final_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "relative_path": relative_path, + "main_conf_dir": str(conf_main), + "verify_initial_conf_dir": str(conf_verify_initial), + "verify_final_conf_dir": str(conf_verify_final), + "local_root": str(local_root), + "verify_initial_root": str(verify_initial_root), + "verify_final_root": str(verify_final_root), + } + + # Phase 1: seed initial file content + write_text_file(local_file_path, initial_content) + + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + details["phase1_returncode"] = phase1_result.returncode + + if phase1_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {phase1_result.returncode}", + artifacts, + details, ) - seeder_target.write_text(initial_content, encoding="utf-8") + # Phase 2: establish remote baseline from a fresh verification client + verify_initial_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify_initial), + ] + context.log(f"Executing Test Case {self.case_id} phase2 verify initial: {command_to_string(verify_initial_command)}") + verify_initial_result = run_command(verify_initial_command, cwd=context.repo_root) + write_text_file(verify_initial_stdout, verify_initial_result.stdout) + write_text_file(verify_initial_stderr, verify_initial_result.stderr) + details["verify_initial_returncode"] = verify_initial_result.returncode + + verify_initial_manifest = build_manifest(verify_initial_root) + write_manifest(verify_initial_manifest_file, verify_initial_manifest) + details["verify_initial_manifest"] = verify_initial_manifest + details["verify_initial_file_exists"] = verify_initial_file_path.is_file() + + baseline_verified_content = ( + verify_initial_file_path.read_text(encoding="utf-8") + if verify_initial_file_path.is_file() + else "" + ) + details["baseline_verified_content"] = baseline_verified_content - seeder_config_dir = seeder_root / "config" - verifier_before_config_dir = verifier_before_root / "config" - verifier_after_config_dir = verifier_after_root / "config" + baseline_verified_mtime_ns = ( + verify_initial_file_path.stat().st_mtime_ns + if verify_initial_file_path.is_file() + else -1 + ) + details["baseline_verified_mtime_ns"] = baseline_verified_mtime_ns - self._write_config( - context=context, - config_dir=seeder_config_dir, - sync_dir=seeder_sync_dir, - extra_config_lines=[], - ) - self._write_config( - context=context, - config_dir=verifier_before_config_dir, - sync_dir=verifier_before_sync_dir, - extra_config_lines=[], - ) - self._write_config( - context=context, - config_dir=verifier_after_config_dir, - sync_dir=verifier_after_sync_dir, - extra_config_lines=[], + if verify_initial_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"initial remote verification failed with status {verify_initial_result.returncode}", + artifacts, + details, ) - # - # Phase 1: upload baseline file - # - rc, stdout, stderr = self._run_onedrive( - context=context, - config_dir=seeder_config_dir, - extra_args=["--sync", "--verbose"], + if not verify_initial_file_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"initial remote verification is missing expected file: {relative_path}", + artifacts, + details, ) - artifacts.extend( - self._write_phase_artifacts( - testcase_root, - "phase1_seed_upload", - rc, - stdout, - stderr, - ) + + if baseline_verified_content != initial_content: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "initial remote verification content did not match seeded content", + artifacts, + details, ) - if rc != 0: - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — initial upload failed with exit code {rc}", - artifacts=artifacts, - ) - - # - # Phase 2: fresh verifier downloads remote baseline state - # - rc, stdout, stderr = self._run_onedrive( - context=context, - config_dir=verifier_before_config_dir, - extra_args=["--sync", "--download-only", "--resync", "--resync-auth", "--verbose"], + + # Phase 3: change only the local mtime and sync again + local_mtime_before_touch_ns = local_file_path.stat().st_mtime_ns + details["local_mtime_before_touch_ns"] = local_mtime_before_touch_ns + + time.sleep(2) + os.utime(local_file_path, None) + + local_mtime_after_touch_ns = local_file_path.stat().st_mtime_ns + details["local_mtime_after_touch_ns"] = local_mtime_after_touch_ns + details["local_touch_advanced_mtime"] = local_mtime_after_touch_ns > local_mtime_before_touch_ns + + local_content_after_touch = local_file_path.read_text(encoding="utf-8") + details["local_content_after_touch"] = local_content_after_touch + + if local_content_after_touch != initial_content: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local file content changed unexpectedly after mtime-only touch", + artifacts, + details, ) - artifacts.extend( - self._write_phase_artifacts( - testcase_root, - "phase2_verify_remote_baseline", - rc, - stdout, - stderr, - ) + + if local_mtime_after_touch_ns <= local_mtime_before_touch_ns: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local file modification timestamp did not advance after touch operation", + artifacts, + details, ) - if rc != 0: - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — baseline verifier download failed with exit code {rc}", - artifacts=artifacts, - ) - - if not verifier_before_target.exists(): - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — baseline verifier did not download {target_relpath}", - artifacts=artifacts, - ) - - baseline_remote_content = verifier_before_target.read_text(encoding="utf-8") - if baseline_remote_content != initial_content: - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — baseline verifier content mismatch for {target_relpath}", - artifacts=artifacts, - ) - - baseline_remote_mtime = int(verifier_before_target.stat().st_mtime) - - # - # Phase 3: touch local file only - no content change - # - touched_local_mtime = baseline_remote_mtime + 300 - os.utime(seeder_target, (touched_local_mtime, touched_local_mtime)) - - local_content_after_touch = seeder_target.read_text(encoding="utf-8") - if local_content_after_touch != initial_content: - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — local file content changed unexpectedly after mtime touch", - artifacts=artifacts, - ) - - # - # Phase 4: normal sync after mtime-only change - # - rc, stdout, stderr = self._run_onedrive( - context=context, - config_dir=seeder_config_dir, - extra_args=["--sync", "--verbose"], + + phase3_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log(f"Executing Test Case {self.case_id} phase3: {command_to_string(phase3_command)}") + phase3_result = run_command(phase3_command, cwd=context.repo_root) + write_text_file(phase3_stdout, phase3_result.stdout) + write_text_file(phase3_stderr, phase3_result.stderr) + details["phase3_returncode"] = phase3_result.returncode + + phase3_combined_output = phase3_result.stdout + "\n" + phase3_result.stderr + upload_markers = [ + f"Uploading new file {relative_path}", + f"Uploading file {relative_path}", + f"Uploading differences of {relative_path}", + "Uploading new file", + "Uploading differences of", + ] + matched_upload_markers = [marker for marker in upload_markers if marker in phase3_combined_output] + details["matched_upload_markers"] = matched_upload_markers + + if phase3_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"mtime-only sync phase failed with status {phase3_result.returncode}", + artifacts, + details, ) - artifacts.extend( - self._write_phase_artifacts( - testcase_root, - "phase4_sync_after_mtime_touch", - rc, - stdout, - stderr, - ) + + # Phase 4: verify remote truth again from a fresh client + verify_final_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify_final), + ] + context.log(f"Executing Test Case {self.case_id} phase4 verify final: {command_to_string(verify_final_command)}") + verify_final_result = run_command(verify_final_command, cwd=context.repo_root) + write_text_file(verify_final_stdout, verify_final_result.stdout) + write_text_file(verify_final_stderr, verify_final_result.stderr) + details["verify_final_returncode"] = verify_final_result.returncode + + verify_final_manifest = build_manifest(verify_final_root) + write_manifest(verify_final_manifest_file, verify_final_manifest) + details["verify_final_manifest"] = verify_final_manifest + details["verify_final_file_exists"] = verify_final_file_path.is_file() + + final_verified_content = ( + verify_final_file_path.read_text(encoding="utf-8") + if verify_final_file_path.is_file() + else "" + ) + details["final_verified_content"] = final_verified_content + + final_verified_mtime_ns = ( + verify_final_file_path.stat().st_mtime_ns + if verify_final_file_path.is_file() + else -1 + ) + details["final_verified_mtime_ns"] = final_verified_mtime_ns + + expected_manifest = [ + root_name, + relative_path, + ] + details["expected_manifest"] = expected_manifest + + self._write_metadata(metadata_file, details) + + if verify_final_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"final remote verification failed with status {verify_final_result.returncode}", + artifacts, + details, ) - if rc != 0: - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — sync after mtime-only touch failed with exit code {rc}", - artifacts=artifacts, - ) - - upload_indicators = [ - f"Uploading new file {target_relpath.name}", - f"Uploading differences of {target_relpath.name}", - f"Uploading file {target_relpath.name}", - ] - combined_log = f"{stdout}\n{stderr}" - matched_upload_indicators = [ - indicator for indicator in upload_indicators if indicator in combined_log - ] - if matched_upload_indicators: - return TestResult.fail_result( - self.TEST_ID, - ( - f"{self.TEST_NAME} — mtime-only change triggered upload behaviour " - f"for {target_relpath}: {', '.join(matched_upload_indicators)}" - ), - artifacts=artifacts, - ) - - unexpected_local_entries = self._find_unexpected_entries( - seeder_sync_dir, - allowed_relative_paths={str(target_relpath)}, + + if matched_upload_markers: + return TestResult.fail_result( + self.case_id, + self.name, + f"mtime-only local change triggered upload behaviour: {matched_upload_markers}", + artifacts, + details, ) - if unexpected_local_entries: - return TestResult.fail_result( - self.TEST_ID, - ( - f"{self.TEST_NAME} — unexpected local artefacts created after mtime-only sync: " - f"{', '.join(unexpected_local_entries)}" - ), - artifacts=artifacts, - ) - - # - # Phase 5: fresh verifier downloads final remote state - # - rc, stdout, stderr = self._run_onedrive( - context=context, - config_dir=verifier_after_config_dir, - extra_args=["--sync", "--download-only", "--resync", "--resync-auth", "--verbose"], + + if not verify_final_file_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"final remote verification is missing expected file: {relative_path}", + artifacts, + details, ) - artifacts.extend( - self._write_phase_artifacts( - testcase_root, - "phase5_verify_remote_final_state", - rc, - stdout, - stderr, - ) + + if final_verified_content != initial_content: + return TestResult.fail_result( + self.case_id, + self.name, + "final verified file content did not match the original content after mtime-only local change", + artifacts, + details, ) - if rc != 0: - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — final verifier download failed with exit code {rc}", - artifacts=artifacts, - ) - - if not verifier_after_target.exists(): - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — final verifier did not download {target_relpath}", - artifacts=artifacts, - ) - - final_remote_content = verifier_after_target.read_text(encoding="utf-8") - if final_remote_content != initial_content: - return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — remote content changed after mtime-only local touch", - artifacts=artifacts, - ) - - final_remote_mtime = int(verifier_after_target.stat().st_mtime) - - if final_remote_mtime >= touched_local_mtime: - return TestResult.fail_result( - self.TEST_ID, - ( - f"{self.TEST_NAME} — remote mtime moved forward to {final_remote_mtime} " - f"after local mtime-only touch at {touched_local_mtime}" - ), - artifacts=artifacts, - ) - - if abs(final_remote_mtime - baseline_remote_mtime) > 2: - return TestResult.fail_result( - self.TEST_ID, - ( - f"{self.TEST_NAME} — remote mtime changed unexpectedly: " - f"baseline={baseline_remote_mtime}, final={final_remote_mtime}" - ), - artifacts=artifacts, - ) - - metadata = { - "testcase": self.TEST_ID, - "target_relpath": str(target_relpath), - "baseline_remote_mtime": baseline_remote_mtime, - "touched_local_mtime": touched_local_mtime, - "final_remote_mtime": final_remote_mtime, - "baseline_remote_content_length": len(baseline_remote_content), - "final_remote_content_length": len(final_remote_content), - } - metadata_path = testcase_root / "tc0037_metadata.json" - metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8") - artifacts.append(str(metadata_path)) - - return TestResult.pass_result( - self.TEST_ID, - ( - f"{self.TEST_NAME} — mtime-only local change was ignored as expected; " - f"content remained unchanged and remote mtime was not updated" - ), - artifacts=artifacts, + + if verify_final_manifest != expected_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + "final remote verification manifest did not match the expected single-file structure after mtime-only local change", + artifacts, + details, ) - except Exception as exc: + if baseline_verified_mtime_ns != final_verified_mtime_ns: return TestResult.fail_result( - self.TEST_ID, - f"{self.TEST_NAME} — unhandled exception: {exc}", - artifacts=artifacts, + self.case_id, + self.name, + "remote file modification timestamp changed after an mtime-only local touch", + artifacts, + details, ) - def _run_onedrive( - self, - context: E2EContext, - config_dir: Path, - extra_args: List[str], - ) -> Tuple[int, str, str]: - command = [str(context.onedrive_path), "--confdir", str(config_dir)] - command.extend(extra_args) - - completed = subprocess.run( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - check=False, - ) - return completed.returncode, completed.stdout, completed.stderr - - def _write_phase_artifacts( - self, - testcase_root: Path, - phase_name: str, - returncode: int, - stdout: str, - stderr: str, - ) -> List[str]: - phase_dir = testcase_root / "artifacts" / phase_name - phase_dir.mkdir(parents=True, exist_ok=True) - - stdout_path = phase_dir / "stdout.log" - stderr_path = phase_dir / "stderr.log" - rc_path = phase_dir / "returncode.txt" - - stdout_path.write_text(stdout, encoding="utf-8") - stderr_path.write_text(stderr, encoding="utf-8") - rc_path.write_text(str(returncode), encoding="utf-8") - - return [str(stdout_path), str(stderr_path), str(rc_path)] - - def _find_unexpected_entries(self, root: Path, allowed_relative_paths: set[str]) -> List[str]: - unexpected: List[str] = [] - - for path in sorted(root.rglob("*")): - if path.is_dir(): - continue - rel = str(path.relative_to(root)) - if rel not in allowed_relative_paths: - unexpected.append(rel) - - return unexpected - - def _reset_dir(self, path: Path) -> None: - if path.exists(): - shutil.rmtree(path) - path.mkdir(parents=True, exist_ok=True) \ No newline at end of file + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 65628659401fadbe737944ae73acf72c374cfded Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 11:03:33 +1000 Subject: [PATCH 152/245] Update tc0037_mtime_only_local_change_handling.py enable debug loggin --- ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py b/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py index 1aebd6f09..8fe575e58 100644 --- a/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py +++ b/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py @@ -138,6 +138,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--confdir", @@ -166,6 +167,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -267,6 +269,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--confdir", @@ -306,6 +309,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", From d22e9aec79bf142f0c0c98aa57f05eab907a402f Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 11:21:37 +1000 Subject: [PATCH 153/245] Update tc0037 Update tc0037 --- ...tc0037_mtime_only_local_change_handling.py | 570 +++++++++++------- 1 file changed, 363 insertions(+), 207 deletions(-) diff --git a/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py b/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py index 8fe575e58..6970a1cb3 100644 --- a/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py +++ b/ci/e2e/testcases/tc0037_mtime_only_local_change_handling.py @@ -22,20 +22,26 @@ class TestCase0037MtimeOnlyLocalChangeHandling(E2ETestCase): case_id = "0037" name = "mtime-only local change handling" description = ( - "Validate that changing only the local modification timestamp of an existing " - "file does not cause unintended content upload or remote state change" + "Validate mtime-only local file changes across direct upload, automatic " + "session upload for files larger than 4 MiB, and forced session upload " + "behaviour without changing file content" ) - def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + def _write_config(self, config_dir: Path, sync_dir: Path, extra_config_lines: list[str] | None = None) -> None: config_path = config_dir / "config" backup_path = config_dir / ".config.backup" hash_path = config_dir / ".config.hash" - config_text = ( - "# tc0037 config\n" - f'sync_dir = "{sync_dir}"\n' - 'bypass_data_preservation = "true"\n' - ) + config_lines = [ + "# tc0037 config", + f'sync_dir = "{sync_dir}"', + 'bypass_data_preservation = "true"', + ] + + if extra_config_lines: + config_lines.extend(extra_config_lines) + + config_text = "\n".join(config_lines) + "\n" write_onedrive_config(config_path, config_text) write_onedrive_config(backup_path, config_text) @@ -50,23 +56,68 @@ def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> No "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", ) - def run(self, context: E2EContext) -> TestResult: - case_work_dir = context.work_root / "tc0037" - case_log_dir = context.logs_dir / "tc0037" - state_dir = context.state_dir / "tc0037" - - reset_directory(case_work_dir) - reset_directory(case_log_dir) - reset_directory(state_dir) - context.ensure_refresh_token_available() - - local_root = case_work_dir / "syncroot" - verify_initial_root = case_work_dir / "verify-initial-root" - verify_final_root = case_work_dir / "verify-final-root" - - conf_main = case_work_dir / "conf-main" - conf_verify_initial = case_work_dir / "conf-verify-initial" - conf_verify_final = case_work_dir / "conf-verify-final" + def _write_file_with_exact_size(self, path: Path, size_bytes: int, header_text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + header_bytes = header_text.encode("utf-8") + if len(header_bytes) > size_bytes: + raise ValueError(f"header_text is larger than requested file size for {path}") + + filler_size = size_bytes - len(header_bytes) + filler_chunk = b"0123456789ABCDEF" * 4096 + + with path.open("wb") as handle: + handle.write(header_bytes) + while filler_size > 0: + chunk = filler_chunk[: min(len(filler_chunk), filler_size)] + handle.write(chunk) + filler_size -= len(chunk) + + def _run_logged_command( + self, + context: E2EContext, + command: list[str], + stdout_path: Path, + stderr_path: Path, + ): + context.log(f"Executing Test Case {self.case_id}: {command_to_string(command)}") + result = run_command(command, cwd=context.repo_root) + write_text_file(stdout_path, result.stdout) + write_text_file(stderr_path, result.stderr) + return result + + def _scenario_uses_session_upload(self, file_size_bytes: int, force_session_upload: bool) -> bool: + if force_session_upload: + return True + return file_size_bytes > (4 * 1024 * 1024) + + def _run_scenario( + self, + context: E2EContext, + case_work_dir: Path, + case_log_dir: Path, + state_dir: Path, + scenario_id: str, + scenario_name: str, + file_size_bytes: int, + force_session_upload: bool, + artifacts: list[str], + ) -> tuple[bool, str, dict[str, object]]: + scenario_work_dir = case_work_dir / scenario_id + scenario_log_dir = case_log_dir / scenario_id + scenario_state_dir = state_dir / scenario_id + + reset_directory(scenario_work_dir) + reset_directory(scenario_log_dir) + reset_directory(scenario_state_dir) + + local_root = scenario_work_dir / "syncroot" + verify_initial_root = scenario_work_dir / "verify-initial-root" + verify_final_root = scenario_work_dir / "verify-final-root" + + conf_main = scenario_work_dir / "conf-main" + conf_verify_initial = scenario_work_dir / "conf-verify-initial" + conf_verify_final = scenario_work_dir / "conf-verify-final" reset_directory(local_root) reset_directory(verify_initial_root) @@ -76,98 +127,116 @@ def run(self, context: E2EContext) -> TestResult: context.prepare_minimal_config_dir(conf_verify_initial, "") context.prepare_minimal_config_dir(conf_verify_final, "") - self._write_config(conf_main, local_root) + extra_config_lines: list[str] = [] + if force_session_upload: + extra_config_lines.append('force_session_upload = "true"') + + self._write_config(conf_main, local_root, extra_config_lines) self._write_config(conf_verify_initial, verify_initial_root) self._write_config(conf_verify_final, verify_final_root) - root_name = f"ZZ_E2E_TC0037_{context.run_id}_{os.getpid()}" + root_name = f"ZZ_E2E_TC0037_{scenario_id}_{context.run_id}_{os.getpid()}" relative_path = f"{root_name}/mtime-only.txt" local_file_path = local_root / relative_path verify_initial_file_path = verify_initial_root / relative_path verify_final_file_path = verify_final_root / relative_path - initial_content = ( - "TC0037 mtime-only local change handling\n" - "This file content must remain unchanged.\n" - "Only the local modification timestamp is altered.\n" - ) - - phase1_stdout = case_log_dir / "phase1_seed_stdout.log" - phase1_stderr = case_log_dir / "phase1_seed_stderr.log" - verify_initial_stdout = case_log_dir / "phase2_verify_initial_stdout.log" - verify_initial_stderr = case_log_dir / "phase2_verify_initial_stderr.log" - phase3_stdout = case_log_dir / "phase3_touch_sync_stdout.log" - phase3_stderr = case_log_dir / "phase3_touch_sync_stderr.log" - verify_final_stdout = case_log_dir / "phase4_verify_final_stdout.log" - verify_final_stderr = case_log_dir / "phase4_verify_final_stderr.log" - verify_initial_manifest_file = state_dir / "verify_initial_manifest.txt" - verify_final_manifest_file = state_dir / "verify_final_manifest.txt" - metadata_file = state_dir / "metadata.txt" - - artifacts = [ - str(phase1_stdout), - str(phase1_stderr), - str(verify_initial_stdout), - str(verify_initial_stderr), - str(phase3_stdout), - str(phase3_stderr), - str(verify_final_stdout), - str(verify_final_stderr), - str(verify_initial_manifest_file), - str(verify_final_manifest_file), - str(metadata_file), + expected_manifest = [ + root_name, + relative_path, ] + uses_session_upload = self._scenario_uses_session_upload(file_size_bytes, force_session_upload) + + phase1_stdout = scenario_log_dir / "phase1_seed_stdout.log" + phase1_stderr = scenario_log_dir / "phase1_seed_stderr.log" + phase2_stdout = scenario_log_dir / "phase2_verify_initial_stdout.log" + phase2_stderr = scenario_log_dir / "phase2_verify_initial_stderr.log" + phase3_stdout = scenario_log_dir / "phase3_touch_sync_stdout.log" + phase3_stderr = scenario_log_dir / "phase3_touch_sync_stderr.log" + phase4_stdout = scenario_log_dir / "phase4_verify_final_stdout.log" + phase4_stderr = scenario_log_dir / "phase4_verify_final_stderr.log" + + verify_initial_manifest_file = scenario_state_dir / "verify_initial_manifest.txt" + verify_final_manifest_file = scenario_state_dir / "verify_final_manifest.txt" + metadata_file = scenario_state_dir / "metadata.txt" + + artifacts.extend( + [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(phase3_stdout), + str(phase3_stderr), + str(phase4_stdout), + str(phase4_stderr), + str(verify_initial_manifest_file), + str(verify_final_manifest_file), + str(metadata_file), + ] + ) + details: dict[str, object] = { + "scenario_id": scenario_id, + "scenario_name": scenario_name, "root_name": root_name, "relative_path": relative_path, + "file_size_bytes": file_size_bytes, + "force_session_upload": force_session_upload, + "uses_session_upload": uses_session_upload, "main_conf_dir": str(conf_main), "verify_initial_conf_dir": str(conf_verify_initial), "verify_final_conf_dir": str(conf_verify_final), "local_root": str(local_root), "verify_initial_root": str(verify_initial_root), "verify_final_root": str(verify_final_root), + "expected_manifest": expected_manifest, } - # Phase 1: seed initial file content - write_text_file(local_file_path, initial_content) + initial_header = ( + f"TC0037 {scenario_id} {scenario_name}\n" + "This file content must remain unchanged.\n" + "Only the local modification timestamp is altered.\n" + ) + self._write_file_with_exact_size(local_file_path, file_size_bytes, initial_header) + + initial_local_hash = compute_quickxor_hash_file(local_file_path) + initial_local_size = local_file_path.stat().st_size + details["initial_local_hash"] = initial_local_hash + details["initial_local_size"] = initial_local_size + + # Phase 1: seed phase1_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", str(conf_main), ] - context.log(f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}") - phase1_result = run_command(phase1_command, cwd=context.repo_root) - write_text_file(phase1_stdout, phase1_result.stdout) - write_text_file(phase1_stderr, phase1_result.stderr) + phase1_result = self._run_logged_command(context, phase1_command, phase1_stdout, phase1_stderr) details["phase1_returncode"] = phase1_result.returncode if phase1_result.returncode != 0: self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"seed phase failed with status {phase1_result.returncode}", - artifacts, + return ( + False, + f"{scenario_id} seed phase failed with status {phase1_result.returncode}", details, ) - # Phase 2: establish remote baseline from a fresh verification client - verify_initial_command = [ + # Phase 2: initial fresh remote verification + phase2_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -175,92 +244,91 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_verify_initial), ] - context.log(f"Executing Test Case {self.case_id} phase2 verify initial: {command_to_string(verify_initial_command)}") - verify_initial_result = run_command(verify_initial_command, cwd=context.repo_root) - write_text_file(verify_initial_stdout, verify_initial_result.stdout) - write_text_file(verify_initial_stderr, verify_initial_result.stderr) - details["verify_initial_returncode"] = verify_initial_result.returncode + phase2_result = self._run_logged_command(context, phase2_command, phase2_stdout, phase2_stderr) + details["phase2_returncode"] = phase2_result.returncode verify_initial_manifest = build_manifest(verify_initial_root) write_manifest(verify_initial_manifest_file, verify_initial_manifest) details["verify_initial_manifest"] = verify_initial_manifest details["verify_initial_file_exists"] = verify_initial_file_path.is_file() - baseline_verified_content = ( - verify_initial_file_path.read_text(encoding="utf-8") - if verify_initial_file_path.is_file() - else "" - ) - details["baseline_verified_content"] = baseline_verified_content + if phase2_result.returncode != 0: + self._write_metadata(metadata_file, details) + return ( + False, + f"{scenario_id} initial remote verification failed with status {phase2_result.returncode}", + details, + ) - baseline_verified_mtime_ns = ( - verify_initial_file_path.stat().st_mtime_ns - if verify_initial_file_path.is_file() - else -1 - ) - details["baseline_verified_mtime_ns"] = baseline_verified_mtime_ns + if not verify_initial_file_path.is_file(): + self._write_metadata(metadata_file, details) + return ( + False, + f"{scenario_id} initial remote verification is missing expected file: {relative_path}", + details, + ) + + baseline_verified_hash = compute_quickxor_hash_file(verify_initial_file_path) + baseline_verified_size = verify_initial_file_path.stat().st_size + baseline_verified_mtime = int(verify_initial_file_path.stat().st_mtime) + + details["baseline_verified_hash"] = baseline_verified_hash + details["baseline_verified_size"] = baseline_verified_size + details["baseline_verified_mtime"] = baseline_verified_mtime - if verify_initial_result.returncode != 0: + if verify_initial_manifest != expected_manifest: self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"initial remote verification failed with status {verify_initial_result.returncode}", - artifacts, + return ( + False, + f"{scenario_id} initial remote verification manifest did not match expected structure", details, ) - if not verify_initial_file_path.is_file(): + if baseline_verified_hash != initial_local_hash: self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"initial remote verification is missing expected file: {relative_path}", - artifacts, + return ( + False, + f"{scenario_id} initial remote verification hash did not match seeded local file", details, ) - if baseline_verified_content != initial_content: + if baseline_verified_size != initial_local_size: self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "initial remote verification content did not match seeded content", - artifacts, + return ( + False, + f"{scenario_id} initial remote verification size did not match seeded local file", details, ) - # Phase 3: change only the local mtime and sync again - local_mtime_before_touch_ns = local_file_path.stat().st_mtime_ns - details["local_mtime_before_touch_ns"] = local_mtime_before_touch_ns + # Phase 3: touch local file only by explicitly setting a later mtime + local_hash_before_touch = compute_quickxor_hash_file(local_file_path) + local_mtime_before_touch = int(local_file_path.stat().st_mtime) - time.sleep(2) - os.utime(local_file_path, None) + touched_epoch = max(int(time.time()), local_mtime_before_touch, baseline_verified_mtime) + 120 + os.utime(local_file_path, (touched_epoch, touched_epoch)) - local_mtime_after_touch_ns = local_file_path.stat().st_mtime_ns - details["local_mtime_after_touch_ns"] = local_mtime_after_touch_ns - details["local_touch_advanced_mtime"] = local_mtime_after_touch_ns > local_mtime_before_touch_ns + local_hash_after_touch = compute_quickxor_hash_file(local_file_path) + local_mtime_after_touch = int(local_file_path.stat().st_mtime) - local_content_after_touch = local_file_path.read_text(encoding="utf-8") - details["local_content_after_touch"] = local_content_after_touch + details["local_hash_before_touch"] = local_hash_before_touch + details["local_hash_after_touch"] = local_hash_after_touch + details["local_mtime_before_touch"] = local_mtime_before_touch + details["local_mtime_after_touch"] = local_mtime_after_touch + details["touched_epoch"] = touched_epoch - if local_content_after_touch != initial_content: + if local_hash_after_touch != local_hash_before_touch: self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "local file content changed unexpectedly after mtime-only touch", - artifacts, + return ( + False, + f"{scenario_id} local file hash changed after mtime-only touch", details, ) - if local_mtime_after_touch_ns <= local_mtime_before_touch_ns: + if local_mtime_after_touch <= local_mtime_before_touch: self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - "local file modification timestamp did not advance after touch operation", - artifacts, + return ( + False, + f"{scenario_id} local file mtime did not advance after touch", details, ) @@ -269,47 +337,56 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", str(conf_main), ] - context.log(f"Executing Test Case {self.case_id} phase3: {command_to_string(phase3_command)}") - phase3_result = run_command(phase3_command, cwd=context.repo_root) - write_text_file(phase3_stdout, phase3_result.stdout) - write_text_file(phase3_stderr, phase3_result.stderr) + phase3_result = self._run_logged_command(context, phase3_command, phase3_stdout, phase3_stderr) details["phase3_returncode"] = phase3_result.returncode phase3_combined_output = phase3_result.stdout + "\n" + phase3_result.stderr - upload_markers = [ - f"Uploading new file {relative_path}", - f"Uploading file {relative_path}", - f"Uploading differences of {relative_path}", - "Uploading new file", - "Uploading differences of", - ] - matched_upload_markers = [marker for marker in upload_markers if marker in phase3_combined_output] - details["matched_upload_markers"] = matched_upload_markers + content_unchanged_marker = ( + "The last modified timestamp has changed however the file content has not changed" + ) + same_hash_marker = "The local item has the same hash value as the item online" + correcting_timestamp_marker = "correcting online timestamp" + + details["phase3_detected_content_unchanged_marker"] = content_unchanged_marker in phase3_combined_output + details["phase3_detected_same_hash_marker"] = same_hash_marker in phase3_combined_output + details["phase3_detected_correcting_timestamp_marker"] = correcting_timestamp_marker in phase3_combined_output if phase3_result.returncode != 0: self._write_metadata(metadata_file, details) - return TestResult.fail_result( - self.case_id, - self.name, - f"mtime-only sync phase failed with status {phase3_result.returncode}", - artifacts, + return ( + False, + f"{scenario_id} mtime-only sync phase failed with status {phase3_result.returncode}", details, ) - # Phase 4: verify remote truth again from a fresh client - verify_final_command = [ + if content_unchanged_marker not in phase3_combined_output: + self._write_metadata(metadata_file, details) + return ( + False, + f"{scenario_id} did not log the expected content-unchanged timestamp handling marker", + details, + ) + + if same_hash_marker not in phase3_combined_output: + self._write_metadata(metadata_file, details) + return ( + False, + f"{scenario_id} did not log the expected same-hash timestamp handling marker", + details, + ) + + # Phase 4: final fresh remote verification + phase4_command = [ context.onedrive_bin, "--display-running-config", "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -317,89 +394,168 @@ def run(self, context: E2EContext) -> TestResult: "--confdir", str(conf_verify_final), ] - context.log(f"Executing Test Case {self.case_id} phase4 verify final: {command_to_string(verify_final_command)}") - verify_final_result = run_command(verify_final_command, cwd=context.repo_root) - write_text_file(verify_final_stdout, verify_final_result.stdout) - write_text_file(verify_final_stderr, verify_final_result.stderr) - details["verify_final_returncode"] = verify_final_result.returncode + phase4_result = self._run_logged_command(context, phase4_command, phase4_stdout, phase4_stderr) + details["phase4_returncode"] = phase4_result.returncode verify_final_manifest = build_manifest(verify_final_root) write_manifest(verify_final_manifest_file, verify_final_manifest) details["verify_final_manifest"] = verify_final_manifest details["verify_final_file_exists"] = verify_final_file_path.is_file() - final_verified_content = ( - verify_final_file_path.read_text(encoding="utf-8") - if verify_final_file_path.is_file() - else "" - ) - details["final_verified_content"] = final_verified_content + if phase4_result.returncode != 0: + self._write_metadata(metadata_file, details) + return ( + False, + f"{scenario_id} final remote verification failed with status {phase4_result.returncode}", + details, + ) - final_verified_mtime_ns = ( - verify_final_file_path.stat().st_mtime_ns - if verify_final_file_path.is_file() - else -1 - ) - details["final_verified_mtime_ns"] = final_verified_mtime_ns + if not verify_final_file_path.is_file(): + self._write_metadata(metadata_file, details) + return ( + False, + f"{scenario_id} final remote verification is missing expected file: {relative_path}", + details, + ) - expected_manifest = [ - root_name, - relative_path, - ] - details["expected_manifest"] = expected_manifest + final_verified_hash = compute_quickxor_hash_file(verify_final_file_path) + final_verified_size = verify_final_file_path.stat().st_size + final_verified_mtime = int(verify_final_file_path.stat().st_mtime) + + details["final_verified_hash"] = final_verified_hash + details["final_verified_size"] = final_verified_size + details["final_verified_mtime"] = final_verified_mtime self._write_metadata(metadata_file, details) - if verify_final_result.returncode != 0: - return TestResult.fail_result( - self.case_id, - self.name, - f"final remote verification failed with status {verify_final_result.returncode}", - artifacts, + if verify_final_manifest != expected_manifest: + return ( + False, + f"{scenario_id} final remote verification manifest did not match expected structure", details, ) - if matched_upload_markers: - return TestResult.fail_result( - self.case_id, - self.name, - f"mtime-only local change triggered upload behaviour: {matched_upload_markers}", - artifacts, + if final_verified_hash != initial_local_hash: + return ( + False, + f"{scenario_id} final verified file hash did not match original file content", details, ) - if not verify_final_file_path.is_file(): - return TestResult.fail_result( - self.case_id, - self.name, - f"final remote verification is missing expected file: {relative_path}", - artifacts, + if final_verified_size != initial_local_size: + return ( + False, + f"{scenario_id} final verified file size did not match original file size", details, ) - if final_verified_content != initial_content: - return TestResult.fail_result( - self.case_id, - self.name, - "final verified file content did not match the original content after mtime-only local change", - artifacts, - details, + # Scenario-specific timestamp assertions + if uses_session_upload: + if abs(final_verified_mtime - touched_epoch) > 2: + return ( + False, + f"{scenario_id} final remote mtime {final_verified_mtime} did not match touched local timestamp {touched_epoch} within tolerance", + details, + ) + else: + if final_verified_mtime <= baseline_verified_mtime: + return ( + False, + f"{scenario_id} final remote mtime {final_verified_mtime} did not advance beyond baseline {baseline_verified_mtime}", + details, + ) + + if correcting_timestamp_marker not in phase3_combined_output: + return ( + False, + f"{scenario_id} did not log the expected online timestamp correction marker for direct upload handling", + details, + ) + + return (True, f"{scenario_id} passed", details) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0037" + case_log_dir = context.logs_dir / "tc0037" + state_dir = context.state_dir / "tc0037" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + context.ensure_refresh_token_available() + + artifacts: list[str] = [] + details: dict[str, object] = {} + + scenarios = [ + { + "scenario_id": "MT-0001", + "scenario_name": "small file with default upload behaviour", + "file_size_bytes": 1 * 1024 * 1024, + "force_session_upload": False, + }, + { + "scenario_id": "MT-0002", + "scenario_name": "large file greater than 4 MiB with automatic session upload behaviour", + "file_size_bytes": 5 * 1024 * 1024, + "force_session_upload": False, + }, + { + "scenario_id": "MT-0003", + "scenario_name": "small file with force_session_upload enabled", + "file_size_bytes": 1 * 1024 * 1024, + "force_session_upload": True, + }, + { + "scenario_id": "MT-0004", + "scenario_name": "large file greater than 4 MiB with force_session_upload enabled", + "file_size_bytes": 5 * 1024 * 1024, + "force_session_upload": True, + }, + ] + + failed_scenarios: list[str] = [] + + for scenario in scenarios: + passed, message, scenario_details = self._run_scenario( + context=context, + case_work_dir=case_work_dir, + case_log_dir=case_log_dir, + state_dir=state_dir, + scenario_id=scenario["scenario_id"], + scenario_name=scenario["scenario_name"], + file_size_bytes=scenario["file_size_bytes"], + force_session_upload=scenario["force_session_upload"], + artifacts=artifacts, ) - if verify_final_manifest != expected_manifest: - return TestResult.fail_result( - self.case_id, - self.name, - "final remote verification manifest did not match the expected single-file structure after mtime-only local change", - artifacts, - details, + details[scenario["scenario_id"]] = scenario_details + details[f"{scenario['scenario_id']}_passed"] = passed + details[f"{scenario['scenario_id']}_message"] = message + + if not passed: + failed_scenarios.append(scenario["scenario_id"]) + + summary_file = state_dir / "scenario-summary.txt" + write_text_file( + summary_file, + "\n".join( + f"{scenario_id}: passed={details.get(f'{scenario_id}_passed')} message={details.get(f'{scenario_id}_message')!r}" + for scenario_id in [scenario["scenario_id"] for scenario in scenarios] ) + + "\n", + ) + artifacts.append(str(summary_file)) + + metadata_file = state_dir / "metadata.txt" + self._write_metadata(metadata_file, details) + artifacts.append(str(metadata_file)) - if baseline_verified_mtime_ns != final_verified_mtime_ns: + if failed_scenarios: return TestResult.fail_result( self.case_id, self.name, - "remote file modification timestamp changed after an mtime-only local touch", + f"{len(failed_scenarios)} of {len(scenarios)} mtime-only scenarios failed: {', '.join(failed_scenarios)}", artifacts, details, ) From 7fa42e632d9bee648876725d2cba6eb31db4ae86 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 11:38:14 +1000 Subject: [PATCH 154/245] Add tc0038 Add tc0038 --- ci/e2e/run.py | 2 + ..._and_recreate_with_same_name_validation.py | 371 ++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 ci/e2e/testcases/tc0038_delete_and_recreate_with_same_name_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 0494ec466..6b3f3ce9d 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -46,6 +46,7 @@ from testcases.tc0035_remote_move_between_directories_reconciliation import TestCase0035RemoteMoveBetweenDirectoriesReconciliation from testcases.tc0036_overwrite_replace_existing_file_content_validation import TestCase0036OverwriteReplaceExistingFileContentValidation from testcases.tc0037_mtime_only_local_change_handling import TestCase0037MtimeOnlyLocalChangeHandling +from testcases.tc0038_delete_and_recreate_with_same_name_validation import TestCase0038DeleteAndRecreateWithSameNameValidation def build_test_suite() -> list: """ @@ -91,6 +92,7 @@ def build_test_suite() -> list: TestCase0035RemoteMoveBetweenDirectoriesReconciliation(), TestCase0036OverwriteReplaceExistingFileContentValidation(), TestCase0037MtimeOnlyLocalChangeHandling(), + TestCase0038DeleteAndRecreateWithSameNameValidation(), ] diff --git a/ci/e2e/testcases/tc0038_delete_and_recreate_with_same_name_validation.py b/ci/e2e/testcases/tc0038_delete_and_recreate_with_same_name_validation.py new file mode 100644 index 000000000..f18d89cb2 --- /dev/null +++ b/ci/e2e/testcases/tc0038_delete_and_recreate_with_same_name_validation.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) + + +class TestCase0038DeleteAndRecreateWithSameNameValidation(E2ETestCase): + case_id = "0038" + name = "delete and recreate with same name validation" + description = ( + "Validate that deleting a file, syncing that deletion, then recreating " + "a different file with the same name correctly results in the final " + "remote and local state without stale item-id or state database issues" + ) + + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( + "# tc0038 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) + + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0038" + case_log_dir = context.logs_dir / "tc0038" + state_dir = context.state_dir / "tc0038" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + + context.ensure_refresh_token_available() + + local_root = case_work_dir / "syncroot" + verify_root = case_work_dir / "verifyroot" + conf_main = case_work_dir / "conf-main" + conf_verify = case_work_dir / "conf-verify" + + reset_directory(local_root) + reset_directory(verify_root) + + context.prepare_minimal_config_dir(conf_main, "") + context.prepare_minimal_config_dir(conf_verify, "") + + self._write_config(conf_main, local_root) + self._write_config(conf_verify, verify_root) + + root_name = f"ZZ_E2E_TC0038_{context.run_id}_{os.getpid()}" + target_relative = f"{root_name}/same-name-target.txt" + anchor_relative = f"{root_name}/anchor.txt" + + local_target_path = local_root / target_relative + local_anchor_path = local_root / anchor_relative + + verify_target_path = verify_root / target_relative + verify_anchor_path = verify_root / anchor_relative + + initial_content = ( + "TC0038 delete and recreate with same name validation\n" + "INITIAL VERSION\n" + "This file must be deleted and removed from remote state.\n" + ) + recreated_content = ( + "TC0038 delete and recreate with same name validation\n" + "RECREATED VERSION\n" + "This is a different file with the same name and must be the final state.\n" + ) + anchor_content = ( + "TC0038 anchor file\n" + "This file keeps the directory present throughout the delete/recreate cycle.\n" + ) + + phase1_stdout = case_log_dir / "phase1_seed_stdout.log" + phase1_stderr = case_log_dir / "phase1_seed_stderr.log" + phase2_stdout = case_log_dir / "phase2_delete_stdout.log" + phase2_stderr = case_log_dir / "phase2_delete_stderr.log" + phase3_stdout = case_log_dir / "phase3_recreate_stdout.log" + phase3_stderr = case_log_dir / "phase3_recreate_stderr.log" + verify_stdout = case_log_dir / "verify_stdout.log" + verify_stderr = case_log_dir / "verify_stderr.log" + verify_manifest_file = state_dir / "verify_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(phase3_stdout), + str(phase3_stderr), + str(verify_stdout), + str(verify_stderr), + str(verify_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "target_relative": target_relative, + "anchor_relative": anchor_relative, + "main_conf_dir": str(conf_main), + "verify_conf_dir": str(conf_verify), + "local_root": str(local_root), + "verify_root": str(verify_root), + } + + # Phase 1: seed initial remote state with target file + anchor file + write_text_file(local_target_path, initial_content) + write_text_file(local_anchor_path, anchor_content) + + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log( + f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}" + ) + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + details["phase1_returncode"] = phase1_result.returncode + + if phase1_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {phase1_result.returncode}", + artifacts, + details, + ) + + # Phase 2: delete the target file and sync the deletion + if local_target_path.exists(): + local_target_path.unlink() + + details["local_target_exists_after_delete"] = local_target_path.exists() + details["local_anchor_exists_after_delete"] = local_anchor_path.is_file() + + if local_target_path.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local target file still exists immediately after delete", + artifacts, + details, + ) + + if not local_anchor_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local anchor file is missing immediately after delete phase preparation", + artifacts, + details, + ) + + phase2_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log( + f"Executing Test Case {self.case_id} phase2: {command_to_string(phase2_command)}" + ) + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + details["phase2_returncode"] = phase2_result.returncode + + if phase2_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"delete propagation phase failed with status {phase2_result.returncode}", + artifacts, + details, + ) + + # Phase 3: recreate a different file with the same name and sync again + write_text_file(local_target_path, recreated_content) + + details["local_target_exists_after_recreate"] = local_target_path.is_file() + details["local_target_size_after_recreate"] = ( + local_target_path.stat().st_size if local_target_path.is_file() else -1 + ) + details["local_anchor_exists_after_recreate"] = local_anchor_path.is_file() + + if not local_target_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local target file does not exist immediately after recreate", + artifacts, + details, + ) + + phase3_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log( + f"Executing Test Case {self.case_id} phase3: {command_to_string(phase3_command)}" + ) + phase3_result = run_command(phase3_command, cwd=context.repo_root) + write_text_file(phase3_stdout, phase3_result.stdout) + write_text_file(phase3_stderr, phase3_result.stderr) + details["phase3_returncode"] = phase3_result.returncode + + if phase3_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"recreate propagation phase failed with status {phase3_result.returncode}", + artifacts, + details, + ) + + # Phase 4: verify remote truth from a fresh client + verify_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify), + ] + context.log( + f"Executing Test Case {self.case_id} verify: {command_to_string(verify_command)}" + ) + verify_result = run_command(verify_command, cwd=context.repo_root) + write_text_file(verify_stdout, verify_result.stdout) + write_text_file(verify_stderr, verify_result.stderr) + details["verify_returncode"] = verify_result.returncode + + verify_manifest = build_manifest(verify_root) + write_manifest(verify_manifest_file, verify_manifest) + + details["verify_manifest"] = verify_manifest + details["verified_target_exists"] = verify_target_path.is_file() + details["verified_anchor_exists"] = verify_anchor_path.is_file() + + verified_target_content = ( + verify_target_path.read_text(encoding="utf-8") + if verify_target_path.is_file() + else "" + ) + details["verified_target_content"] = verified_target_content + + expected_manifest = [ + root_name, + anchor_relative, + target_relative, + ] + details["expected_manifest"] = expected_manifest + + self._write_metadata(metadata_file, details) + + if verify_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification failed with status {verify_result.returncode}", + artifacts, + details, + ) + + if not verify_anchor_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing anchor file: {anchor_relative}", + artifacts, + details, + ) + + if not verify_target_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"remote verification is missing recreated file: {target_relative}", + artifacts, + details, + ) + + if verified_target_content != recreated_content: + return TestResult.fail_result( + self.case_id, + self.name, + "verified file content did not match the recreated content after delete/recreate cycle", + artifacts, + details, + ) + + if verified_target_content == initial_content: + return TestResult.fail_result( + self.case_id, + self.name, + "verified file content still matches the initial content after delete/recreate cycle", + artifacts, + details, + ) + + if verify_manifest != expected_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + "remote verification manifest did not match the expected final structure after delete/recreate cycle", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From 9e073be6131be5755843e4cbfb90329d97669220 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 11:56:57 +1000 Subject: [PATCH 155/245] Add tc0039 Add tc0039 --- ci/e2e/run.py | 2 + ...039_empty_directory_handling_validation.py | 443 ++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 ci/e2e/testcases/tc0039_empty_directory_handling_validation.py diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 6b3f3ce9d..21643b70f 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -47,6 +47,7 @@ from testcases.tc0036_overwrite_replace_existing_file_content_validation import TestCase0036OverwriteReplaceExistingFileContentValidation from testcases.tc0037_mtime_only_local_change_handling import TestCase0037MtimeOnlyLocalChangeHandling from testcases.tc0038_delete_and_recreate_with_same_name_validation import TestCase0038DeleteAndRecreateWithSameNameValidation +from testcases.tc0039_empty_directory_handling import TestCase0039EmptyDirectoryHandling def build_test_suite() -> list: """ @@ -93,6 +94,7 @@ def build_test_suite() -> list: TestCase0036OverwriteReplaceExistingFileContentValidation(), TestCase0037MtimeOnlyLocalChangeHandling(), TestCase0038DeleteAndRecreateWithSameNameValidation(), + TestCase0039EmptyDirectoryHandling(), ] diff --git a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py new file mode 100644 index 000000000..7baba3269 --- /dev/null +++ b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from framework.base import E2ETestCase +from framework.context import E2EContext +from framework.manifest import build_manifest, write_manifest +from framework.result import TestResult +from framework.utils import ( + command_to_string, + compute_quickxor_hash_file, + reset_directory, + run_command, + write_onedrive_config, + write_text_file, +) + + +class TestCase0039EmptyDirectoryHandling(E2ETestCase): + case_id = "0039" + name = "empty directory handling" + description = ( + "Validate creation, sync, verification, and cleanup behaviour for " + "empty directories so that directory-only state is handled correctly " + "without leaving stale folders behind" + ) + + def _write_config(self, config_dir: Path, sync_dir: Path) -> None: + config_path = config_dir / "config" + backup_path = config_dir / ".config.backup" + hash_path = config_dir / ".config.hash" + + config_text = ( + "# tc0039 config\n" + f'sync_dir = "{sync_dir}"\n' + 'bypass_data_preservation = "true"\n' + ) + + write_onedrive_config(config_path, config_text) + write_onedrive_config(backup_path, config_text) + hash_path.write_text(compute_quickxor_hash_file(config_path), encoding="utf-8") + + os.chmod(config_path, 0o600) + os.chmod(backup_path, 0o600) + os.chmod(hash_path, 0o600) + + def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> None: + write_text_file( + metadata_file, + "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", + ) + + def run(self, context: E2EContext) -> TestResult: + case_work_dir = context.work_root / "tc0039" + case_log_dir = context.logs_dir / "tc0039" + state_dir = context.state_dir / "tc0039" + + reset_directory(case_work_dir) + reset_directory(case_log_dir) + reset_directory(state_dir) + + context.ensure_refresh_token_available() + + local_root = case_work_dir / "syncroot" + verify_create_root = case_work_dir / "verify-create-root" + verify_cleanup_root = case_work_dir / "verify-cleanup-root" + + conf_main = case_work_dir / "conf-main" + conf_verify_create = case_work_dir / "conf-verify-create" + conf_verify_cleanup = case_work_dir / "conf-verify-cleanup" + + reset_directory(local_root) + reset_directory(verify_create_root) + reset_directory(verify_cleanup_root) + + context.prepare_minimal_config_dir(conf_main, "") + context.prepare_minimal_config_dir(conf_verify_create, "") + context.prepare_minimal_config_dir(conf_verify_cleanup, "") + + self._write_config(conf_main, local_root) + self._write_config(conf_verify_create, verify_create_root) + self._write_config(conf_verify_cleanup, verify_cleanup_root) + + root_name = f"ZZ_E2E_TC0039_{context.run_id}_{os.getpid()}" + + anchor_relative = f"{root_name}/anchor.txt" + empty_dir_relative = f"{root_name}/EmptyDirectory" + nested_parent_relative = f"{root_name}/NestedParent" + nested_empty_relative = f"{root_name}/NestedParent/ChildEmptyDirectory" + + local_anchor_path = local_root / anchor_relative + local_empty_dir_path = local_root / empty_dir_relative + local_nested_parent_path = local_root / nested_parent_relative + local_nested_empty_path = local_root / nested_empty_relative + + verify_create_anchor_path = verify_create_root / anchor_relative + verify_create_empty_dir_path = verify_create_root / empty_dir_relative + verify_create_nested_parent_path = verify_create_root / nested_parent_relative + verify_create_nested_empty_path = verify_create_root / nested_empty_relative + + verify_cleanup_anchor_path = verify_cleanup_root / anchor_relative + verify_cleanup_empty_dir_path = verify_cleanup_root / empty_dir_relative + verify_cleanup_nested_parent_path = verify_cleanup_root / nested_parent_relative + verify_cleanup_nested_empty_path = verify_cleanup_root / nested_empty_relative + + anchor_content = ( + "TC0039 anchor file\n" + "This file keeps the testcase root present while validating empty directory handling.\n" + ) + + phase1_stdout = case_log_dir / "phase1_seed_stdout.log" + phase1_stderr = case_log_dir / "phase1_seed_stderr.log" + phase2_stdout = case_log_dir / "phase2_verify_creation_stdout.log" + phase2_stderr = case_log_dir / "phase2_verify_creation_stderr.log" + phase3_stdout = case_log_dir / "phase3_cleanup_stdout.log" + phase3_stderr = case_log_dir / "phase3_cleanup_stderr.log" + phase4_stdout = case_log_dir / "phase4_verify_cleanup_stdout.log" + phase4_stderr = case_log_dir / "phase4_verify_cleanup_stderr.log" + + verify_create_manifest_file = state_dir / "verify_create_manifest.txt" + verify_cleanup_manifest_file = state_dir / "verify_cleanup_manifest.txt" + metadata_file = state_dir / "metadata.txt" + + artifacts = [ + str(phase1_stdout), + str(phase1_stderr), + str(phase2_stdout), + str(phase2_stderr), + str(phase3_stdout), + str(phase3_stderr), + str(phase4_stdout), + str(phase4_stderr), + str(verify_create_manifest_file), + str(verify_cleanup_manifest_file), + str(metadata_file), + ] + + details: dict[str, object] = { + "root_name": root_name, + "anchor_relative": anchor_relative, + "empty_dir_relative": empty_dir_relative, + "nested_parent_relative": nested_parent_relative, + "nested_empty_relative": nested_empty_relative, + "main_conf_dir": str(conf_main), + "verify_create_conf_dir": str(conf_verify_create), + "verify_cleanup_conf_dir": str(conf_verify_cleanup), + "local_root": str(local_root), + "verify_create_root": str(verify_create_root), + "verify_cleanup_root": str(verify_cleanup_root), + } + + # Phase 1: create anchor file and empty directories, then sync + write_text_file(local_anchor_path, anchor_content) + local_empty_dir_path.mkdir(parents=True, exist_ok=True) + local_nested_empty_path.mkdir(parents=True, exist_ok=True) + + details["local_anchor_exists_before_seed"] = local_anchor_path.is_file() + details["local_empty_dir_exists_before_seed"] = local_empty_dir_path.is_dir() + details["local_nested_parent_exists_before_seed"] = local_nested_parent_path.is_dir() + details["local_nested_empty_exists_before_seed"] = local_nested_empty_path.is_dir() + + expected_creation_manifest = [ + root_name, + anchor_relative, + empty_dir_relative, + nested_parent_relative, + nested_empty_relative, + ] + details["expected_creation_manifest"] = expected_creation_manifest + + phase1_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log( + f"Executing Test Case {self.case_id} phase1: {command_to_string(phase1_command)}" + ) + phase1_result = run_command(phase1_command, cwd=context.repo_root) + write_text_file(phase1_stdout, phase1_result.stdout) + write_text_file(phase1_stderr, phase1_result.stderr) + details["phase1_returncode"] = phase1_result.returncode + + if phase1_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"seed phase failed with status {phase1_result.returncode}", + artifacts, + details, + ) + + # Phase 2: verify remote creation with a fresh client + phase2_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify_create), + ] + context.log( + f"Executing Test Case {self.case_id} phase2: {command_to_string(phase2_command)}" + ) + phase2_result = run_command(phase2_command, cwd=context.repo_root) + write_text_file(phase2_stdout, phase2_result.stdout) + write_text_file(phase2_stderr, phase2_result.stderr) + details["phase2_returncode"] = phase2_result.returncode + + verify_create_manifest = build_manifest(verify_create_root) + write_manifest(verify_create_manifest_file, verify_create_manifest) + details["verify_create_manifest"] = verify_create_manifest + details["verify_create_anchor_exists"] = verify_create_anchor_path.is_file() + details["verify_create_empty_dir_exists"] = verify_create_empty_dir_path.is_dir() + details["verify_create_nested_parent_exists"] = verify_create_nested_parent_path.is_dir() + details["verify_create_nested_empty_exists"] = verify_create_nested_empty_path.is_dir() + + if phase2_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"creation verification failed with status {phase2_result.returncode}", + artifacts, + details, + ) + + if not verify_create_anchor_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"creation verification is missing anchor file: {anchor_relative}", + artifacts, + details, + ) + + if not verify_create_empty_dir_path.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"creation verification is missing empty directory: {empty_dir_relative}", + artifacts, + details, + ) + + if not verify_create_nested_parent_path.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"creation verification is missing nested parent directory: {nested_parent_relative}", + artifacts, + details, + ) + + if not verify_create_nested_empty_path.is_dir(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"creation verification is missing nested empty directory: {nested_empty_relative}", + artifacts, + details, + ) + + if verify_create_manifest != expected_creation_manifest: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "creation verification manifest did not match expected structure", + artifacts, + details, + ) + + # Phase 3: remove the empty directories locally and sync cleanup + if local_nested_empty_path.exists(): + local_nested_empty_path.rmdir() + if local_empty_dir_path.exists(): + local_empty_dir_path.rmdir() + if local_nested_parent_path.exists(): + local_nested_parent_path.rmdir() + + details["local_empty_dir_exists_after_cleanup_prep"] = local_empty_dir_path.exists() + details["local_nested_parent_exists_after_cleanup_prep"] = local_nested_parent_path.exists() + details["local_nested_empty_exists_after_cleanup_prep"] = local_nested_empty_path.exists() + details["local_anchor_exists_after_cleanup_prep"] = local_anchor_path.is_file() + + if local_empty_dir_path.exists() or local_nested_parent_path.exists() or local_nested_empty_path.exists(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local empty directory cleanup preparation failed before sync", + artifacts, + details, + ) + + if not local_anchor_path.is_file(): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local anchor file is missing before cleanup sync", + artifacts, + details, + ) + + phase3_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--verbose", + "--single-directory", + root_name, + "--confdir", + str(conf_main), + ] + context.log( + f"Executing Test Case {self.case_id} phase3: {command_to_string(phase3_command)}" + ) + phase3_result = run_command(phase3_command, cwd=context.repo_root) + write_text_file(phase3_stdout, phase3_result.stdout) + write_text_file(phase3_stderr, phase3_result.stderr) + details["phase3_returncode"] = phase3_result.returncode + + if phase3_result.returncode != 0: + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + f"cleanup propagation phase failed with status {phase3_result.returncode}", + artifacts, + details, + ) + + # Phase 4: verify remote cleanup with a fresh client + expected_cleanup_manifest = [ + root_name, + anchor_relative, + ] + details["expected_cleanup_manifest"] = expected_cleanup_manifest + + phase4_command = [ + context.onedrive_bin, + "--display-running-config", + "--sync", + "--download-only", + "--verbose", + "--resync", + "--resync-auth", + "--single-directory", + root_name, + "--confdir", + str(conf_verify_cleanup), + ] + context.log( + f"Executing Test Case {self.case_id} phase4: {command_to_string(phase4_command)}" + ) + phase4_result = run_command(phase4_command, cwd=context.repo_root) + write_text_file(phase4_stdout, phase4_result.stdout) + write_text_file(phase4_stderr, phase4_result.stderr) + details["phase4_returncode"] = phase4_result.returncode + + verify_cleanup_manifest = build_manifest(verify_cleanup_root) + write_manifest(verify_cleanup_manifest_file, verify_cleanup_manifest) + details["verify_cleanup_manifest"] = verify_cleanup_manifest + details["verify_cleanup_anchor_exists"] = verify_cleanup_anchor_path.is_file() + details["verify_cleanup_empty_dir_exists"] = verify_cleanup_empty_dir_path.exists() + details["verify_cleanup_nested_parent_exists"] = verify_cleanup_nested_parent_path.exists() + details["verify_cleanup_nested_empty_exists"] = verify_cleanup_nested_empty_path.exists() + + self._write_metadata(metadata_file, details) + + if phase4_result.returncode != 0: + return TestResult.fail_result( + self.case_id, + self.name, + f"cleanup verification failed with status {phase4_result.returncode}", + artifacts, + details, + ) + + if not verify_cleanup_anchor_path.is_file(): + return TestResult.fail_result( + self.case_id, + self.name, + f"cleanup verification is missing anchor file: {anchor_relative}", + artifacts, + details, + ) + + if verify_cleanup_empty_dir_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"cleanup verification still contains removed empty directory: {empty_dir_relative}", + artifacts, + details, + ) + + if verify_cleanup_nested_parent_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"cleanup verification still contains removed nested parent directory: {nested_parent_relative}", + artifacts, + details, + ) + + if verify_cleanup_nested_empty_path.exists(): + return TestResult.fail_result( + self.case_id, + self.name, + f"cleanup verification still contains removed nested empty directory: {nested_empty_relative}", + artifacts, + details, + ) + + if verify_cleanup_manifest != expected_cleanup_manifest: + return TestResult.fail_result( + self.case_id, + self.name, + "cleanup verification manifest did not match expected final structure", + artifacts, + details, + ) + + return TestResult.pass_result(self.case_id, self.name, artifacts, details) \ No newline at end of file From ede559d76365528ad93e5aa2f2fa012642d081ef Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 12:06:24 +1000 Subject: [PATCH 156/245] Update tc0039 Update tc0039 --- ci/e2e/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index 21643b70f..d97920ad8 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -47,7 +47,7 @@ from testcases.tc0036_overwrite_replace_existing_file_content_validation import TestCase0036OverwriteReplaceExistingFileContentValidation from testcases.tc0037_mtime_only_local_change_handling import TestCase0037MtimeOnlyLocalChangeHandling from testcases.tc0038_delete_and_recreate_with_same_name_validation import TestCase0038DeleteAndRecreateWithSameNameValidation -from testcases.tc0039_empty_directory_handling import TestCase0039EmptyDirectoryHandling +from testcases.tc0039_empty_directory_handling_validation import TestCase0039EmptyDirectoryHandling def build_test_suite() -> list: """ From a03ca6017a8a7fcb22973696deff0c17db26c938 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 12:13:53 +1000 Subject: [PATCH 157/245] Update tc0039_empty_directory_handling_validation.py enable debug logging to determine failure --- .../testcases/tc0039_empty_directory_handling_validation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py index 7baba3269..f2e11471f 100644 --- a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py +++ b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py @@ -174,6 +174,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--confdir", @@ -204,6 +205,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -325,6 +327,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--confdir", @@ -361,6 +364,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", From facd57cf83b87526410766b6f81063e962975bad Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 13:32:41 +1000 Subject: [PATCH 158/245] Fix tc0039 Fix tc0039 --- ...0039_empty_directory_handling_validation.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py index f2e11471f..4eeb8fe8d 100644 --- a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py +++ b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py @@ -22,8 +22,8 @@ class TestCase0039EmptyDirectoryHandling(E2ETestCase): name = "empty directory handling" description = ( "Validate creation, sync, verification, and cleanup behaviour for " - "empty directories so that directory-only state is handled correctly " - "without leaving stale folders behind" + "empty directories, including nested empty directories, to ensure " + "directory-only state is created and removed correctly" ) def _write_config(self, config_dir: Path, sync_dir: Path) -> None: @@ -174,7 +174,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", @@ -205,7 +204,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -279,7 +277,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if verify_create_manifest != expected_creation_manifest: + if sorted(verify_create_manifest) != sorted(expected_creation_manifest): self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, @@ -302,7 +300,11 @@ def run(self, context: E2EContext) -> TestResult: details["local_nested_empty_exists_after_cleanup_prep"] = local_nested_empty_path.exists() details["local_anchor_exists_after_cleanup_prep"] = local_anchor_path.is_file() - if local_empty_dir_path.exists() or local_nested_parent_path.exists() or local_nested_empty_path.exists(): + if ( + local_empty_dir_path.exists() + or local_nested_parent_path.exists() + or local_nested_empty_path.exists() + ): self._write_metadata(metadata_file, details) return TestResult.fail_result( self.case_id, @@ -327,7 +329,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", @@ -364,7 +365,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -435,7 +435,7 @@ def run(self, context: E2EContext) -> TestResult: details, ) - if verify_cleanup_manifest != expected_cleanup_manifest: + if sorted(verify_cleanup_manifest) != sorted(expected_cleanup_manifest): return TestResult.fail_result( self.case_id, self.name, From aff3b7654efe298b1f9689163d5844b0eded9186 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 13:46:48 +1000 Subject: [PATCH 159/245] Update tc0039 Add debug logging --- .../testcases/tc0039_empty_directory_handling_validation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py index 4eeb8fe8d..77cc70783 100644 --- a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py +++ b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py @@ -174,6 +174,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--confdir", @@ -204,6 +205,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -329,6 +331,7 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", + "--verbose", "--single-directory", root_name, "--confdir", @@ -365,6 +368,7 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", + "--verbose", "--resync", "--resync-auth", "--single-directory", From dfd1711c5d9555a26f3727c53bbab9b01b0e1565 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 7 Apr 2026 14:50:43 +1000 Subject: [PATCH 160/245] Update tc0039 Update tc0039 --- ...039_empty_directory_handling_validation.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py index 77cc70783..538e72bc3 100644 --- a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py +++ b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py @@ -51,6 +51,40 @@ def _write_metadata(self, metadata_file: Path, details: dict[str, object]) -> No "\n".join(f"{key}={value!r}" for key, value in sorted(details.items())) + "\n", ) + def _write_tree_snapshot(self, root: Path, output_file: Path) -> None: + lines: list[str] = [] + + if not root.exists(): + lines.append(f"MISSING {root}") + write_text_file(output_file, "\n".join(lines) + "\n") + return + + lines.append(f"ROOT {root}") + + for current_root, dirnames, filenames in os.walk(root): + dirnames.sort() + filenames.sort() + + current_path = Path(current_root) + rel_root = current_path.relative_to(root) + + if rel_root == Path("."): + lines.append(".") + else: + lines.append(str(rel_root) + "/") + + for dirname in dirnames: + child = current_path / dirname + child_rel = child.relative_to(root) + lines.append(f"DIR {child_rel}/") + + for filename in filenames: + child = current_path / filename + child_rel = child.relative_to(root) + lines.append(f"FILE {child_rel}") + + write_text_file(output_file, "\n".join(lines) + "\n") + def run(self, context: E2EContext) -> TestResult: case_work_dir = context.work_root / "tc0039" case_log_dir = context.logs_dir / "tc0039" @@ -120,6 +154,8 @@ def run(self, context: E2EContext) -> TestResult: verify_create_manifest_file = state_dir / "verify_create_manifest.txt" verify_cleanup_manifest_file = state_dir / "verify_cleanup_manifest.txt" + local_tree_before_phase3_file = state_dir / "local_tree_before_phase3.txt" + local_manifest_before_phase3_file = state_dir / "local_manifest_before_phase3.txt" metadata_file = state_dir / "metadata.txt" artifacts = [ @@ -133,6 +169,8 @@ def run(self, context: E2EContext) -> TestResult: str(phase4_stderr), str(verify_create_manifest_file), str(verify_cleanup_manifest_file), + str(local_tree_before_phase3_file), + str(local_manifest_before_phase3_file), str(metadata_file), ] @@ -302,6 +340,18 @@ def run(self, context: E2EContext) -> TestResult: details["local_nested_empty_exists_after_cleanup_prep"] = local_nested_empty_path.exists() details["local_anchor_exists_after_cleanup_prep"] = local_anchor_path.is_file() + expected_local_before_phase3_manifest = [ + root_name, + anchor_relative, + ] + details["expected_local_before_phase3_manifest"] = expected_local_before_phase3_manifest + + local_manifest_before_phase3 = build_manifest(local_root) + write_manifest(local_manifest_before_phase3_file, local_manifest_before_phase3) + self._write_tree_snapshot(local_root, local_tree_before_phase3_file) + + details["local_manifest_before_phase3"] = local_manifest_before_phase3 + if ( local_empty_dir_path.exists() or local_nested_parent_path.exists() @@ -326,6 +376,16 @@ def run(self, context: E2EContext) -> TestResult: details, ) + if sorted(local_manifest_before_phase3) != sorted(expected_local_before_phase3_manifest): + self._write_metadata(metadata_file, details) + return TestResult.fail_result( + self.case_id, + self.name, + "local filesystem manifest before cleanup sync did not match expected structure", + artifacts, + details, + ) + phase3_command = [ context.onedrive_bin, "--display-running-config", From 5fa92551780b57705eedb2ca2ba1b4c505e5ee29 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Wed, 8 Apr 2026 06:22:39 +1000 Subject: [PATCH 161/245] Update PR * Perform full CI run * Remove debug logging from tc0039 --- ci/e2e/run.py | 66 +++++++++---------- ...039_empty_directory_handling_validation.py | 4 -- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/ci/e2e/run.py b/ci/e2e/run.py index d97920ad8..2812ec835 100644 --- a/ci/e2e/run.py +++ b/ci/e2e/run.py @@ -56,39 +56,39 @@ def build_test_suite() -> list: Add future test cases here in the required execution order. """ return [ - #TestCase0001BasicResync(), - #TestCase0002SyncListValidation(), - #TestCase0003DryRunValidation(), - #TestCase0004SingleDirectorySync(), - #TestCase0005ForceSyncOverride(), - #TestCase0006DownloadOnly(), - #TestCase0007DownloadOnlyCleanupLocalFiles(), - #TestCase0008UploadOnly(), - #TestCase0009UploadOnlyNoRemoteDelete(), - #TestCase0010UploadOnlyRemoveSourceFiles(), - #TestCase0011SkipFileValidation(), - #TestCase0012SkipDirValidation(), - #TestCase0013SkipDotfilesValidation(), - #TestCase0014SkipSizeValidation(), - #TestCase0015SkipSymlinksValidation(), - #TestCase0016CheckNosyncValidation(), - #TestCase0017CheckNomountValidation(), - #TestCase0018RecycleBinValidation(), - #TestCase0019LoggingAndRunningConfig(), - #TestCase0020MonitorModeValidation(), - #TestCase0021ResumableTransfersValidation(), - #TestCase0022LocalFirstValidation(), - #TestCase0023BypassDataPreservationValidation(), - #TestCase0024BigDeleteSafeguardValidation(), - #TestCase0025InvalidCharacterFilenameValidation(), - #TestCase0026ReservedDeviceNameValidation(), - #TestCase0027WhitespaceTrailingDotValidation(), - #TestCase0028ControlCharacterNonUtf8FilenameValidation(), - #TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), - #TestCase0030LocalRenamePropagationValidation(), - #TestCase0031LocalDirectoryRenamePropagationValidation(), - #TestCase0032RemoteRenameReconciliation(), - #TestCase0033RemoteDirectoryRenameReconciliation(), + TestCase0001BasicResync(), + TestCase0002SyncListValidation(), + TestCase0003DryRunValidation(), + TestCase0004SingleDirectorySync(), + TestCase0005ForceSyncOverride(), + TestCase0006DownloadOnly(), + TestCase0007DownloadOnlyCleanupLocalFiles(), + TestCase0008UploadOnly(), + TestCase0009UploadOnlyNoRemoteDelete(), + TestCase0010UploadOnlyRemoveSourceFiles(), + TestCase0011SkipFileValidation(), + TestCase0012SkipDirValidation(), + TestCase0013SkipDotfilesValidation(), + TestCase0014SkipSizeValidation(), + TestCase0015SkipSymlinksValidation(), + TestCase0016CheckNosyncValidation(), + TestCase0017CheckNomountValidation(), + TestCase0018RecycleBinValidation(), + TestCase0019LoggingAndRunningConfig(), + TestCase0020MonitorModeValidation(), + TestCase0021ResumableTransfersValidation(), + TestCase0022LocalFirstValidation(), + TestCase0023BypassDataPreservationValidation(), + TestCase0024BigDeleteSafeguardValidation(), + TestCase0025InvalidCharacterFilenameValidation(), + TestCase0026ReservedDeviceNameValidation(), + TestCase0027WhitespaceTrailingDotValidation(), + TestCase0028ControlCharacterNonUtf8FilenameValidation(), + TestCase0029LocalFirstUploadOnlyTimestampPreservationValidation(), + TestCase0030LocalRenamePropagationValidation(), + TestCase0031LocalDirectoryRenamePropagationValidation(), + TestCase0032RemoteRenameReconciliation(), + TestCase0033RemoteDirectoryRenameReconciliation(), TestCase0034LocalMoveBetweenDirectoriesValidation(), TestCase0035RemoteMoveBetweenDirectoriesReconciliation(), TestCase0036OverwriteReplaceExistingFileContentValidation(), diff --git a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py index 538e72bc3..9752a31ce 100644 --- a/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py +++ b/ci/e2e/testcases/tc0039_empty_directory_handling_validation.py @@ -212,7 +212,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", @@ -243,7 +242,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", @@ -391,7 +389,6 @@ def run(self, context: E2EContext) -> TestResult: "--display-running-config", "--sync", "--verbose", - "--verbose", "--single-directory", root_name, "--confdir", @@ -428,7 +425,6 @@ def run(self, context: E2EContext) -> TestResult: "--sync", "--download-only", "--verbose", - "--verbose", "--resync", "--resync-auth", "--single-directory", From e73b145304224342296f741d9730431b0bd72add Mon Sep 17 00:00:00 2001 From: abraunegg Date: Thu, 9 Apr 2026 05:46:51 +1000 Subject: [PATCH 162/245] Add CI re-run support with debugging Add CI re-run support with debugging enabled automatically for scenarios that fail --- ci/e2e/README-rerun.md | 55 +++++ ci/e2e/framework/context.py | 121 ++++++++++- ci/e2e/rerun_failures.py | 96 +++++++++ ci/e2e/run.py | 190 ++++++++++++------ .../testcases/tc0002_sync_list_validation.py | 7 +- .../tc0021_resumable_transfers_validation.py | 45 +++-- ...tc0037_mtime_only_local_change_handling.py | 7 + 7 files changed, 433 insertions(+), 88 deletions(-) create mode 100644 ci/e2e/README-rerun.md create mode 100644 ci/e2e/rerun_failures.py diff --git a/ci/e2e/README-rerun.md b/ci/e2e/README-rerun.md new file mode 100644 index 000000000..1331b0c5f --- /dev/null +++ b/ci/e2e/README-rerun.md @@ -0,0 +1,55 @@ +# E2E automatic failure rerun support + +This folder now supports a two-pass CI model: + +1. Run the primary E2E suite normally using `ci/e2e/run.py` +2. Parse `results.json` and automatically rerun only failed cases/scenarios with debug enabled using `ci/e2e/rerun_failures.py` + +## Primary run + +```bash +python3 -u ci/e2e/run.py +``` + +## Automatic debug rerun + +```bash +python3 -u ci/e2e/rerun_failures.py --results ci/e2e/out/results.json --output-subdir debug-rerun --run-label debug-rerun +``` + +This will: +- read the failed cases from the primary `results.json` +- narrow the rerun to only failed case IDs +- narrow scenario-based cases to only failed scenario IDs when available +- rerun with debug verbosity enabled +- write rerun outputs into `ci/e2e/out/debug-rerun/` + +## Direct filtered reruns + +### Rerun one case + +```bash +python3 -u ci/e2e/run.py --case-id 0024 --debug --output-subdir tc0024-debug +``` + +### Rerun multiple cases + +```bash +python3 -u ci/e2e/run.py --case-id 0002,0021,0024 --debug --output-subdir targeted-debug +``` + +### Rerun only selected scenarios + +```bash +python3 -u ci/e2e/run.py --case-id 0002,0021 --scenario 0002:SL-0004,SL-0018 --scenario 0021:RT-0001 --debug --output-subdir targeted-scenarios-debug +``` + +## Environment-driven controls + +These are also supported if you prefer using workflow env vars: +- `E2E_DEBUG=1` +- `E2E_OUTPUT_SUBDIR=` +- `E2E_RUN_LABEL=