aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-25 15:00:44 -0500
committerCraig Jennings <c@cjennings.net>2026-07-25 15:00:44 -0500
commitd52da959bc416e09bc0f3e54994563bd80e19c64 (patch)
treeadaf2ce086e87c548c695e5b4a82eb63f7c926c8
parentd25711a61172ffb7b622a6a834ed17fdf52a54de (diff)
downloadarchsetup-d52da959bc416e09bc0f3e54994563bd80e19c64.tar.gz
archsetup-d52da959bc416e09bc0f3e54994563bd80e19c64.zip
refactor(test): share runner and scenario contracts
-rw-r--r--scripts/testing/lib/maint-scenario.sh62
-rw-r--r--scripts/testing/lib/run-common.sh61
-rw-r--r--scripts/testing/run-maint-nspawn.sh60
-rw-r--r--scripts/testing/run-maint-scenarios.sh65
-rwxr-xr-xscripts/testing/run-test-baremetal.sh65
-rwxr-xr-xscripts/testing/run-test.sh82
-rw-r--r--tests/maint-scenarios/test_scenario_plan.py61
-rw-r--r--tests/vm-framework/test_run_common.py97
8 files changed, 329 insertions, 224 deletions
diff --git a/scripts/testing/lib/maint-scenario.sh b/scripts/testing/lib/maint-scenario.sh
new file mode 100644
index 0000000..e7fcdda
--- /dev/null
+++ b/scripts/testing/lib/maint-scenario.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+# Shared contract and execution helpers for maint scenario transports.
+
+# Extract one declared variable from a scenario file in a clean subshell.
+_scenario_var() { # <file> <varname>
+ bash -c 'set -eu; source "$1" || exit 9; eval "printf %s \"\${$2-}\""' \
+ _probe "$1" "$2" 2>/dev/null
+}
+
+_validate_scenario() { # <file> -> fatal on contract violation
+ local f="$1" base probe_err
+ base=$(basename "$f")
+ probe_err=$(bash -c 'set -eu; source "$1"
+ : "${SCENARIO_DESC:?missing SCENARIO_DESC}"
+ : "${SCENARIO_GROUP:?missing SCENARIO_GROUP}"
+ : "${SCENARIO_PROFILES:?missing SCENARIO_PROFILES}"
+ for p in $SCENARIO_PROFILES; do
+ case "$p" in btrfs|zfs|any) ;; *)
+ echo "bad profile token: $p" >&2; exit 1 ;;
+ esac
+ done
+ declare -f scenario_break scenario_fix scenario_assert >/dev/null \
+ || { echo "missing scenario_break/fix/assert" >&2; exit 1; }' \
+ _probe "$f" 2>&1 >/dev/null) \
+ || fatal "scenario contract violation in $base: $probe_err"
+}
+
+run_scenario() { # <index>
+ local i="$1" name="${S_NAMES[$1]}" f="${S_FILES[$1]}"
+ section "Scenario: $name — ${S_DESCS[$1]}"
+ # shellcheck disable=SC1090
+ source "$f"
+ local ok=true
+ step "break"
+ if ! scenario_break; then
+ error "$name: break step failed"
+ ok=false
+ fi
+ if [ "$ok" = "true" ]; then
+ step "fix"
+ if ! scenario_fix; then
+ error "$name: fix step failed"
+ ok=false
+ fi
+ fi
+ if [ "$ok" = "true" ]; then
+ step "assert"
+ if ! scenario_assert; then
+ error "$name: post-state assertion failed"
+ ok=false
+ fi
+ fi
+ unset -f scenario_break scenario_fix scenario_assert
+ unset SCENARIO_DESC SCENARIO_GROUP SCENARIO_PROFILES
+ if [ "$ok" = "true" ]; then
+ success "PASS: $name"
+ PASS+=("$name")
+ else
+ error "FAIL: $name"
+ FAIL+=("$name")
+ fi
+}
diff --git a/scripts/testing/lib/run-common.sh b/scripts/testing/lib/run-common.sh
new file mode 100644
index 0000000..b5696db
--- /dev/null
+++ b/scripts/testing/lib/run-common.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Transport-independent polling and reporting for archsetup test runners.
+
+archsetup_process_running() { # <transport-function>
+ local transport="$1"
+ "$transport" "pgrep -f '[b]ash archsetup' > /dev/null" 2>/dev/null
+}
+
+wait_for_archsetup() { # <transport> <max-polls> <seconds> <progress-every> [callback]
+ local transport="$1" max_polls="$2" poll_seconds="$3"
+ local progress_every="$4" progress_callback="${5:-}"
+ ARCHSETUP_POLL_COUNT=0
+
+ while [ "$ARCHSETUP_POLL_COUNT" -lt "$max_polls" ]; do
+ archsetup_process_running "$transport" || return 0
+ sleep "$poll_seconds"
+ ARCHSETUP_POLL_COUNT=$((ARCHSETUP_POLL_COUNT + 1))
+ if [ "$progress_every" -gt 0 ] \
+ && [ $((ARCHSETUP_POLL_COUNT % progress_every)) -eq 0 ] \
+ && [ -n "$progress_callback" ]; then
+ "$progress_callback" "$ARCHSETUP_POLL_COUNT"
+ fi
+ done
+ return 124
+}
+
+write_archsetup_test_report() {
+ local file="$1" title="$2" test_id="$3" method="$4" context="$5"
+ local completed="$6" passed="$7" count_passed="$8" count_failed="$9"
+ shift 9
+ local count_warnings="$1" artifacts="$2" validation="FAILED"
+ [ "$passed" = "true" ] && validation="PASSED"
+
+ {
+ echo "========================================"
+ echo "$title"
+ echo "========================================"
+ echo
+ echo "Test ID: $test_id"
+ echo "Date: $(date +'%Y-%m-%d %H:%M:%S')"
+ echo "Test Method: $method"
+ if [ -n "$context" ]; then
+ echo
+ printf '%s\n' "$context"
+ fi
+ echo
+ echo "Results:"
+ echo " ArchSetup Completed: $completed (completion marker, not the installer's exit code)"
+ echo " Validation: $validation"
+ echo
+ echo "Validation Summary:"
+ echo " Passed: $count_passed"
+ echo " Failed: $count_failed"
+ echo " Warnings: $count_warnings"
+ if [ -n "$artifacts" ]; then
+ echo
+ printf '%s\n' "$artifacts"
+ fi
+ echo
+ } > "$file"
+}
diff --git a/scripts/testing/run-maint-nspawn.sh b/scripts/testing/run-maint-nspawn.sh
index 04d4a32..dcc7c16 100644
--- a/scripts/testing/run-maint-nspawn.sh
+++ b/scripts/testing/run-maint-nspawn.sh
@@ -32,6 +32,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$SCRIPT_DIR/lib/logging.sh"
+source "$SCRIPT_DIR/lib/maint-scenario.sh"
SCENARIO_DIR="${MAINT_SCENARIO_DIR:-$SCRIPT_DIR/maint-scenarios}"
MAINT_SRC="${MAINT_SRC:-$HOME/.dotfiles/maint/src/maint}"
@@ -63,29 +64,6 @@ done
S_FILES=() S_NAMES=() S_DESCS=()
-_scenario_var() { # <file> <varname>
- bash -c 'set -eu; source "$1" || exit 9; eval "printf %s \"\${$2-}\""' \
- _probe "$1" "$2" 2>/dev/null
-}
-
-_validate_scenario() { # <file> -> fatal on contract violation
- local f="$1" base probe_err
- base=$(basename "$f")
- probe_err=$(bash -c 'set -eu; source "$1"
- : "${SCENARIO_DESC:?missing SCENARIO_DESC}"
- : "${SCENARIO_GROUP:?missing SCENARIO_GROUP}"
- : "${SCENARIO_PROFILES:?missing SCENARIO_PROFILES}"
- for p in $SCENARIO_PROFILES; do
- case "$p" in btrfs|zfs|any) ;; *)
- echo "bad profile token: $p" >&2; exit 1 ;;
- esac
- done
- declare -f scenario_break scenario_fix scenario_assert >/dev/null \
- || { echo "missing scenario_break/fix/assert" >&2; exit 1; }' \
- _probe "$f" 2>&1 >/dev/null) \
- || fatal "scenario contract violation in $base: $probe_err"
-}
-
build_plan() {
[ -d "$SCENARIO_DIR" ] || fatal "scenario dir not found: $SCENARIO_DIR"
local f base
@@ -212,42 +190,6 @@ success "Container ready"
PASS=() FAIL=()
-run_scenario() { # <index>
- local i="$1" name="${S_NAMES[$1]}" f="${S_FILES[$1]}"
- section "Scenario: $name — ${S_DESCS[$1]}"
- # shellcheck disable=SC1090
- source "$f"
- local ok=true
- step "break"
- if ! scenario_break; then
- error "$name: break step failed"
- ok=false
- fi
- if [ "$ok" = "true" ]; then
- step "fix"
- if ! scenario_fix; then
- error "$name: fix step failed"
- ok=false
- fi
- fi
- if [ "$ok" = "true" ]; then
- step "assert"
- if ! scenario_assert; then
- error "$name: post-state assertion failed"
- ok=false
- fi
- fi
- unset -f scenario_break scenario_fix scenario_assert
- unset SCENARIO_DESC SCENARIO_GROUP SCENARIO_PROFILES
- if [ "$ok" = "true" ]; then
- success "PASS: $name"
- PASS+=("$name")
- else
- error "FAIL: $name"
- FAIL+=("$name")
- fi
-}
-
for i in "${!S_FILES[@]}"; do
run_scenario "$i"
done
diff --git a/scripts/testing/run-maint-scenarios.sh b/scripts/testing/run-maint-scenarios.sh
index 448f6d9..17481e9 100644
--- a/scripts/testing/run-maint-scenarios.sh
+++ b/scripts/testing/run-maint-scenarios.sh
@@ -35,6 +35,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/vm-utils.sh"
+source "$SCRIPT_DIR/lib/maint-scenario.sh"
SCENARIO_DIR="${MAINT_SCENARIO_DIR:-$SCRIPT_DIR/maint-scenarios}"
MAINT_SRC="${MAINT_SRC:-$HOME/.dotfiles/maint/src/maint}"
@@ -72,32 +73,6 @@ S_FILES=() S_NAMES=() S_DESCS=() S_GROUPS=()
# Groups in first-appearance order.
GROUP_ORDER=()
-# Extract one declared var from a scenario file by sourcing it in a clean
-# subshell. Scenario files must only define vars + functions; any top-level
-# command fails here (no helpers exist), which is the contract.
-_scenario_var() { # <file> <varname>
- bash -c 'set -eu; source "$1" || exit 9; eval "printf %s \"\${$2-}\""' \
- _probe "$1" "$2" 2>/dev/null
-}
-
-_validate_scenario() { # <file> -> fatal on contract violation
- local f="$1" base probe_err
- base=$(basename "$f")
- probe_err=$(bash -c 'set -eu; source "$1"
- : "${SCENARIO_DESC:?missing SCENARIO_DESC}"
- : "${SCENARIO_GROUP:?missing SCENARIO_GROUP}"
- : "${SCENARIO_PROFILES:?missing SCENARIO_PROFILES}"
- for p in $SCENARIO_PROFILES; do
- case "$p" in btrfs|zfs|any) ;; *)
- echo "bad profile token: $p" >&2; exit 1 ;;
- esac
- done
- declare -f scenario_break scenario_fix scenario_assert >/dev/null \
- || { echo "missing scenario_break/fix/assert" >&2; exit 1; }' \
- _probe "$f" 2>&1 >/dev/null) \
- || fatal "scenario contract violation in $base: $probe_err"
-}
-
_profile_matches() { # <profiles> -> 0 if scenario runs under $FS_PROFILE
local p
for p in $1; do
@@ -283,44 +258,6 @@ import json; print(len(json.load(open('$RESULTS_DIR/bootstrap-status.json'))['me
PASS=() FAIL=()
-run_scenario() { # <index>
- local i="$1" name="${S_NAMES[$1]}" f="${S_FILES[$1]}"
- section "Scenario: $name — ${S_DESCS[$1]}"
- # Sourced in the runner's shell so break/fix/assert see the helpers;
- # unset afterwards so the next scenario can't inherit stale functions.
- # shellcheck disable=SC1090
- source "$f"
- local ok=true
- step "break"
- if ! scenario_break; then
- error "$name: break step failed"
- ok=false
- fi
- if [ "$ok" = "true" ]; then
- step "fix"
- if ! scenario_fix; then
- error "$name: fix step failed"
- ok=false
- fi
- fi
- if [ "$ok" = "true" ]; then
- step "assert"
- if ! scenario_assert; then
- error "$name: post-state assertion failed"
- ok=false
- fi
- fi
- unset -f scenario_break scenario_fix scenario_assert
- unset SCENARIO_DESC SCENARIO_GROUP SCENARIO_PROFILES
- if [ "$ok" = "true" ]; then
- success "PASS: $name"
- PASS+=("$name")
- else
- error "FAIL: $name"
- FAIL+=("$name")
- fi
-}
-
section "Preparing VM"
stop_qemu 2>/dev/null || true
step "Restoring clean-install snapshot"
diff --git a/scripts/testing/run-test-baremetal.sh b/scripts/testing/run-test-baremetal.sh
index d837389..4692fda 100755
--- a/scripts/testing/run-test-baremetal.sh
+++ b/scripts/testing/run-test-baremetal.sh
@@ -23,6 +23,7 @@ source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/validation.sh" # live helpers: ssh_cmd, capture_*_state, analyze_log_diff, generate_issue_report
source "$SCRIPT_DIR/lib/vm-utils.sh" # inject_root_key + SSH_OPTS/SSH_KEY_OPT for key auth
source "$SCRIPT_DIR/lib/testinfra.sh" # run_testinfra_validation (authoritative validator)
+source "$SCRIPT_DIR/lib/run-common.sh"
# Parse arguments
ROLLBACK_FIRST=false
@@ -203,30 +204,25 @@ if ! $VALIDATE_ONLY; then
# Start archsetup in background
ssh_cmd "cd /tmp/archsetup-test && nohup bash archsetup > $REMOTE_LOG 2>&1 &"
- success "ArchSetup started in background"
+ sleep 3
+ if archsetup_process_running ssh_cmd; then
+ success "ArchSetup started in background"
+ else
+ fatal "ArchSetup process not found after launch"
+ fi
# Poll for completion
step "Monitoring archsetup progress"
- POLL_COUNT=0
MAX_POLLS=180 # 90 minutes max
- while [ $POLL_COUNT -lt $MAX_POLLS ]; do
- if ssh_cmd "ps aux | grep '[b]ash archsetup' > /dev/null" 2>/dev/null; then
- sleep 30
- POLL_COUNT=$((POLL_COUNT + 1))
- if [ $((POLL_COUNT % 10)) -eq 0 ]; then
- ELAPSED_MINS=$((POLL_COUNT / 2))
- info "Still running... ($ELAPSED_MINS minutes elapsed)"
- # Show last line of progress
- LAST_LINE=$(ssh_cmd "tail -1 $REMOTE_LOG 2>/dev/null" || echo "")
- [ -n "$LAST_LINE" ] && info " $LAST_LINE"
- fi
- else
- break
- fi
- done
+ baremetal_progress() {
+ local last_line
+ info "Still running... ($(("$1" / 2)) minutes elapsed)"
+ last_line=$(ssh_cmd "tail -1 $REMOTE_LOG 2>/dev/null" || echo "")
+ [ -n "$last_line" ] && info " $last_line"
+ }
- if [ $POLL_COUNT -ge $MAX_POLLS ]; then
+ if ! wait_for_archsetup ssh_cmd "$MAX_POLLS" 30 10 baremetal_progress; then
error "ArchSetup timed out after 90 minutes"
ARCHSETUP_COMPLETED=timeout
else
@@ -294,31 +290,14 @@ fi
section "Generating Test Report"
REPORT_FILE="$TEST_RESULTS_DIR/test-report.txt"
-cat > "$REPORT_FILE" << EOFREPORT
-========================================
-Bare Metal ArchSetup Test Report
-========================================
-
-Test ID: $TIMESTAMP
-Date: $(date +'%Y-%m-%d %H:%M:%S')
-Target: $TARGET_HOST
-Test Method: Bare Metal ZFS
-
-Results:
- ArchSetup Completed: $ARCHSETUP_COMPLETED (completion marker, not the installer's exit code)
- Validation: $(if $TEST_PASSED; then echo "PASSED"; else echo "FAILED"; fi)
-
-Validation Summary:
- Passed: $VALIDATION_PASSED
- Failed: $VALIDATION_FAILED
- Warnings: $VALIDATION_WARNINGS
-
-Artifacts:
- Log file: $LOGFILE
- Report: $REPORT_FILE
- Results: $TEST_RESULTS_DIR/
-
-EOFREPORT
+printf -v RUN_REPORT_ARTIFACTS \
+ 'Artifacts:\n Log file: %s\n Report: %s\n Results: %s/' \
+ "$LOGFILE" "$REPORT_FILE" "$TEST_RESULTS_DIR"
+write_archsetup_test_report \
+ "$REPORT_FILE" "Bare Metal ArchSetup Test Report" "$TIMESTAMP" \
+ "Bare Metal ZFS" "Target: $TARGET_HOST" "$ARCHSETUP_COMPLETED" \
+ "$TEST_PASSED" "$VALIDATION_PASSED" "$VALIDATION_FAILED" \
+ "$VALIDATION_WARNINGS" "$RUN_REPORT_ARTIFACTS"
info "Test report saved: $REPORT_FILE"
diff --git a/scripts/testing/run-test.sh b/scripts/testing/run-test.sh
index a5c3691..0f57678 100755
--- a/scripts/testing/run-test.sh
+++ b/scripts/testing/run-test.sh
@@ -25,6 +25,7 @@ source "$SCRIPT_DIR/lib/vm-utils.sh"
source "$SCRIPT_DIR/lib/network-diagnostics.sh"
source "$SCRIPT_DIR/lib/validation.sh"
source "$SCRIPT_DIR/lib/testinfra.sh"
+source "$SCRIPT_DIR/lib/run-common.sh"
# Parse arguments
KEEP_VM=false
@@ -248,7 +249,11 @@ sshpass -p "$ROOT_PASSWORD" ssh -T -n $SSH_OPTS \
# Verify the process started
sleep 3
-if vm_exec "$ROOT_PASSWORD" "pgrep -f 'bash archsetup'" >> "$LOGFILE" 2>/dev/null; then
+vm_transport() {
+ vm_exec "$ROOT_PASSWORD" "$@"
+}
+
+if archsetup_process_running vm_transport; then
success "ArchSetup started in background on VM"
else
fatal "ArchSetup process not found after launch"
@@ -256,29 +261,14 @@ fi
# Poll for completion
step "Monitoring archsetup progress (polling every 30 seconds)..."
-POLL_COUNT=0
MAX_POLLS=300 # 150 minutes max (300 * 30 seconds); a full install with heavy
# AUR builds (e.g. vagrant) can exceed 90 min on a slow mirror
-while [ $POLL_COUNT -lt $MAX_POLLS ]; do
- # Check if archsetup process is still running
- if vm_exec "$ROOT_PASSWORD" "ps aux | grep '[b]ash archsetup' > /dev/null" 2>/dev/null; then
- # Still running, wait and continue
- sleep 30
- POLL_COUNT=$((POLL_COUNT + 1))
-
- # Show progress every 5 minutes
- if [ $((POLL_COUNT % 10)) -eq 0 ]; then
- ELAPSED_MINS=$((POLL_COUNT / 2))
- info "Still running... ($ELAPSED_MINS minutes elapsed)"
- fi
- else
- # Process finished
- break
- fi
-done
+vm_progress() {
+ info "Still running... ($(("$1" / 2)) minutes elapsed)"
+}
-if [ $POLL_COUNT -ge $MAX_POLLS ]; then
+if ! wait_for_archsetup vm_transport "$MAX_POLLS" 30 10 vm_progress; then
error "ArchSetup timed out after 150 minutes"
ARCHSETUP_COMPLETED=timeout
else
@@ -357,44 +347,20 @@ fi
section "Generating Test Report"
REPORT_FILE="$TEST_RESULTS_DIR/test-report.txt"
-cat > "$REPORT_FILE" << EOFREPORT
-========================================
-ArchSetup Test Report
-========================================
-
-Test ID: $TIMESTAMP
-Date: $(date +'%Y-%m-%d %H:%M:%S')
-Test Method: QEMU snapshot-based
-
-VM Configuration:
- Disk: $DISK_PATH
- Snapshot: $SNAPSHOT_NAME
- SSH: localhost:$SSH_PORT
-
-Results:
- ArchSetup Completed: $ARCHSETUP_COMPLETED (completion marker, not the installer's exit code)
- Validation: $(if $TEST_PASSED; then echo "PASSED"; else echo "FAILED"; fi)
-
-Validation Summary:
- Passed: $VALIDATION_PASSED
- Failed: $VALIDATION_FAILED
- Warnings: $VALIDATION_WARNINGS
-
-Issue Attribution:
- ArchSetup Issues: ${#ARCHSETUP_ISSUES[@]}
- Base Install Issues: ${#BASE_INSTALL_ISSUES[@]}
- Unknown Issues: ${#UNKNOWN_ISSUES[@]}
-
-Artifacts:
- Log file: $LOGFILE
- Report: $REPORT_FILE
- Results: $TEST_RESULTS_DIR/
- Issue Report: $TEST_RESULTS_DIR/issue-report.txt
- Pre-install logs: $TEST_RESULTS_DIR/pre-install/
- Post-install logs: $TEST_RESULTS_DIR/post-install/
- Analysis: $TEST_RESULTS_DIR/analysis/
-
-EOFREPORT
+printf -v RUN_REPORT_CONTEXT \
+ 'VM Configuration:\n Disk: %s\n Snapshot: %s\n SSH: localhost:%s' \
+ "$DISK_PATH" "$SNAPSHOT_NAME" "$SSH_PORT"
+printf -v RUN_REPORT_ARTIFACTS \
+ 'Issue Attribution:\n ArchSetup Issues: %s\n Base Install Issues: %s\n Unknown Issues: %s\n\nArtifacts:\n Log file: %s\n Report: %s\n Results: %s/\n Issue Report: %s/issue-report.txt\n Pre-install logs: %s/pre-install/\n Post-install logs: %s/post-install/\n Analysis: %s/analysis/' \
+ "${#ARCHSETUP_ISSUES[@]}" "${#BASE_INSTALL_ISSUES[@]}" \
+ "${#UNKNOWN_ISSUES[@]}" "$LOGFILE" "$REPORT_FILE" "$TEST_RESULTS_DIR" \
+ "$TEST_RESULTS_DIR" "$TEST_RESULTS_DIR" "$TEST_RESULTS_DIR" \
+ "$TEST_RESULTS_DIR"
+write_archsetup_test_report \
+ "$REPORT_FILE" "ArchSetup Test Report" "$TIMESTAMP" \
+ "QEMU snapshot-based" "$RUN_REPORT_CONTEXT" "$ARCHSETUP_COMPLETED" \
+ "$TEST_PASSED" "$VALIDATION_PASSED" "$VALIDATION_FAILED" \
+ "$VALIDATION_WARNINGS" "$RUN_REPORT_ARTIFACTS"
info "Test report saved: $REPORT_FILE"
diff --git a/tests/maint-scenarios/test_scenario_plan.py b/tests/maint-scenarios/test_scenario_plan.py
index 9a72db2..d2dc8ea 100644
--- a/tests/maint-scenarios/test_scenario_plan.py
+++ b/tests/maint-scenarios/test_scenario_plan.py
@@ -213,6 +213,67 @@ class UsageTests(unittest.TestCase):
NSPAWN_RUNNER = os.path.join(REPO_ROOT, "scripts", "testing",
"run-maint-nspawn.sh")
+SCENARIO_LIB = os.path.join(REPO_ROOT, "scripts", "testing", "lib",
+ "maint-scenario.sh")
+
+
+class SharedScenarioLibraryTests(unittest.TestCase):
+ """Both transports share scenario parsing and execution semantics."""
+
+ def test_both_runners_source_the_shared_library(self):
+ for runner in (RUNNER, NSPAWN_RUNNER):
+ with open(runner) as f:
+ source = f.read()
+ self.assertIn('source "$SCRIPT_DIR/lib/maint-scenario.sh"', source)
+ self.assertNotRegex(source, r"(?m)^_scenario_var\(\)")
+ self.assertNotRegex(source, r"(?m)^_validate_scenario\(\)")
+ self.assertNotRegex(source, r"(?m)^run_scenario\(\)")
+
+ def test_shared_runner_records_pass_and_cleans_contract_symbols(self):
+ with tempfile.TemporaryDirectory() as d:
+ scenario = write_scenario(d, "10-pass.sh")
+ script = f"""
+source "{SCENARIO_LIB}"
+section() {{ :; }}; step() {{ :; }}; success() {{ :; }}; error() {{ :; }}
+mexec() {{ :; }}; mfix() {{ :; }}
+S_FILES=("{scenario}"); S_NAMES=("pass"); S_DESCS=("works")
+PASS=(); FAIL=()
+run_scenario 0
+printf 'pass=%s fail=%s desc=%s fn=%s\\n' \
+ "${{#PASS[@]}}" "${{#FAIL[@]}}" "${{SCENARIO_DESC-unset}}" \
+ "$(declare -F scenario_break || echo unset)"
+"""
+ proc = subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, cwd=REPO_ROOT)
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertIn("pass=1 fail=0 desc=unset fn=unset", proc.stdout)
+
+ def test_shared_runner_stops_after_failed_fix(self):
+ with tempfile.TemporaryDirectory() as d:
+ scenario = write_scenario(
+ d, "10-fail.sh",
+ body=GOOD_SCENARIO.format(desc="fails", group="g",
+ profiles="any").replace(
+ "scenario_fix() { mfix some_remedy; }",
+ "scenario_fix() { return 1; }",
+ ).replace(
+ 'scenario_assert() { mexec "true"; }',
+ 'scenario_assert() { echo ASSERT_RAN; }',
+ ),
+ )
+ script = f"""
+source "{SCENARIO_LIB}"
+section() {{ :; }}; step() {{ :; }}; success() {{ :; }}; error() {{ :; }}
+S_FILES=("{scenario}"); S_NAMES=("fail"); S_DESCS=("fails")
+PASS=(); FAIL=()
+run_scenario 0
+printf 'pass=%s fail=%s\\n' "${{#PASS[@]}}" "${{#FAIL[@]}}"
+"""
+ proc = subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, cwd=REPO_ROOT)
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertNotIn("ASSERT_RAN", proc.stdout)
+ self.assertIn("pass=0 fail=1", proc.stdout)
class NspawnPlanTests(unittest.TestCase):
diff --git a/tests/vm-framework/test_run_common.py b/tests/vm-framework/test_run_common.py
new file mode 100644
index 0000000..c5f22e5
--- /dev/null
+++ b/tests/vm-framework/test_run_common.py
@@ -0,0 +1,97 @@
+"""Tests for the run-test transport-independent polling/report core."""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+RUN_COMMON = os.path.join(
+ REPO_ROOT, "scripts", "testing", "lib", "run-common.sh")
+VM_RUNNER = os.path.join(REPO_ROOT, "scripts", "testing", "run-test.sh")
+BARE_RUNNER = os.path.join(
+ REPO_ROOT, "scripts", "testing", "run-test-baremetal.sh")
+
+
+def run_bash(body):
+ return subprocess.run(
+ ["bash", "-c", f'source "{RUN_COMMON}"\n{body}'],
+ capture_output=True, text=True, cwd=REPO_ROOT, timeout=10,
+ )
+
+
+class SharedCoreWiring(unittest.TestCase):
+ def test_both_runners_source_shared_core_and_avoid_ps_grep(self):
+ for runner in (VM_RUNNER, BARE_RUNNER):
+ with open(runner) as f:
+ source = f.read()
+ self.assertIn('source "$SCRIPT_DIR/lib/run-common.sh"', source)
+ self.assertNotIn("ps aux | grep '[b]ash archsetup'", source)
+
+ def test_vm_runner_adapts_the_password_taking_transport(self):
+ with open(VM_RUNNER) as f:
+ source = f.read()
+ self.assertIn('vm_exec "$ROOT_PASSWORD" "$@"', source)
+ self.assertIn("archsetup_process_running vm_transport", source)
+ self.assertIn("wait_for_archsetup vm_transport", source)
+
+
+class PollArchsetup(unittest.TestCase):
+ def test_uses_one_pgrep_liveness_probe_until_process_stops(self):
+ proc = run_bash(textwrap.dedent("""\
+ checks=0
+ transport() {
+ [ "$1" = "pgrep -f '[b]ash archsetup' > /dev/null" ] ||
+ { echo "bad-command:$1"; return 2; }
+ checks=$((checks + 1))
+ [ "$checks" -lt 3 ]
+ }
+ progress() { echo "progress:$1"; }
+ wait_for_archsetup transport 5 0 1 progress
+ echo "rc=$? checks=$checks polls=$ARCHSETUP_POLL_COUNT"
+ """))
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertNotIn("bad-command", proc.stdout)
+ self.assertIn("progress:1", proc.stdout)
+ self.assertIn("progress:2", proc.stdout)
+ self.assertIn("rc=0 checks=3 polls=2", proc.stdout)
+
+ def test_returns_124_at_poll_limit(self):
+ proc = run_bash(textwrap.dedent("""\
+ transport() { return 0; }
+ set +e
+ wait_for_archsetup transport 2 0 10
+ rc=$?
+ set -e
+ echo "rc=$rc polls=$ARCHSETUP_POLL_COUNT"
+ """))
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertIn("rc=124 polls=2", proc.stdout)
+
+
+class Report(unittest.TestCase):
+ def test_writes_common_report_with_transport_specific_details(self):
+ with tempfile.TemporaryDirectory() as d:
+ report = os.path.join(d, "report.txt")
+ proc = run_bash(textwrap.dedent(f"""\
+ write_archsetup_test_report "{report}" \
+ "Bare Metal ArchSetup Test Report" "stamp" \
+ "Bare Metal ZFS" "Target: ratio.local" \
+ yes true 12 1 2 "Results: {d}/"
+ """))
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ with open(report) as f:
+ text = f.read()
+ self.assertIn("Bare Metal ArchSetup Test Report", text)
+ self.assertIn("Test Method: Bare Metal ZFS", text)
+ self.assertIn("Target: ratio.local", text)
+ self.assertIn("ArchSetup Completed: yes", text)
+ self.assertIn("Validation: PASSED", text)
+ self.assertIn("Passed: 12", text)
+ self.assertIn(f"Results: {d}/", text)
+
+
+if __name__ == "__main__":
+ unittest.main()