From 7f52b5a58687ee176d82ac6c37ff2882853d71bb Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Fri, 20 Feb 2026 23:18:39 +0200 Subject: [PATCH 01/13] Add arm64 build and E2E test system - Update build.yml to build both armhf and arm64 using CustomPiOS v2 board system (BASE_BOARD matrix) with base_image_downloader - Add testing/ directory with Docker+QEMU-based E2E test harness that boots an OctoPi arm64 image, verifies SSH access, and checks OctoPrint web server availability - Add e2e-test.yml workflow triggered by build completion that runs E2E tests on the arm64 artifact and uploads screenshot + logs --- .github/workflows/build.yml | 100 +++++++++++++------------ .github/workflows/e2e-test.yml | 112 ++++++++++++++++++++++++++++ testing/.dockerignore | 1 + testing/.gitignore | 1 + testing/Dockerfile | 11 +++ testing/run-test.sh | 82 ++++++++++++++++++++ testing/scripts/boot-qemu.sh | 29 +++++++ testing/scripts/entrypoint.sh | 112 ++++++++++++++++++++++++++++ testing/scripts/prepare-image.sh | 93 +++++++++++++++++++++++ testing/scripts/wait-for-ssh.sh | 44 +++++++++++ testing/tests/test_boot.sh | 24 ++++++ testing/tests/test_octoprint_web.sh | 46 ++++++++++++ 12 files changed, 607 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/e2e-test.yml create mode 100644 testing/.dockerignore create mode 100644 testing/.gitignore create mode 100644 testing/Dockerfile create mode 100755 testing/run-test.sh create mode 100755 testing/scripts/boot-qemu.sh create mode 100755 testing/scripts/entrypoint.sh create mode 100755 testing/scripts/prepare-image.sh create mode 100755 testing/scripts/wait-for-ssh.sh create mode 100755 testing/tests/test_boot.sh create mode 100755 testing/tests/test_octoprint_web.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8deaf620..7ddb7632 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,64 +3,68 @@ name: Build Image on: repository_dispatch: push: - schedule: + schedule: - cron: '0 0 * * *' jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - board: raspberrypiarmhf + arch: armhf + - board: raspberrypiarm64 + arch: arm64 steps: - - name: Install Dependencies - run: | - sudo apt update - sudo apt install coreutils p7zip-full qemu-user-static python3-git + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y coreutils p7zip-full qemu-user-static \ + python3-git python3-yaml - - name: Checkout CustomPiOS - uses: actions/checkout@v2 - with: - repository: 'guysoft/CustomPiOS' - path: CustomPiOS + - name: Checkout CustomPiOS + uses: actions/checkout@v4 + with: + repository: 'guysoft/CustomPiOS' + path: CustomPiOS - - name: Checkout Project Repository - uses: actions/checkout@v2 - with: - path: repository - submodules: true + - name: Checkout Project Repository + uses: actions/checkout@v4 + with: + path: repository + submodules: true - - name: Download Raspbian Image - run: | - cd repository/src/image - wget -c --trust-server-names 'https://downloads.raspberrypi.org/raspios_lite_armhf_latest' + - name: Update CustomPiOS Paths + run: | + cd repository/src + ../../CustomPiOS/src/update-custompios-paths - - name: Update CustomPiOS Paths - run: | - cd repository/src - ../../CustomPiOS/src/update-custompios-paths - - # - name: Force apt mirror to work around intermittent mirror hiccups - # run: | - # echo "OCTOPI_APTMIRROR=http://mirror.us.leaseweb.net/raspbian/raspbian" > repository/src/config.local + - name: Download Base Image + run: | + cd repository/src + export DIST_PATH=$(pwd) + export CUSTOM_PI_OS_PATH=$(cat custompios_path) + export BASE_BOARD=${{ matrix.board }} + $CUSTOM_PI_OS_PATH/base_image_downloader_wrapper.sh - - name: Build Image - run: | - sudo modprobe loop - cd repository/src - sudo bash -x ./build_dist + - name: Build Image + run: | + sudo modprobe loop + cd repository/src + sudo BASE_BOARD=${{ matrix.board }} bash -x ./build_dist - - name: Copy output - id: copy - run: | - source repository/src/config - NOW=$(date +"%Y-%m-%d-%H%M") - IMAGE=$NOW-octopi-$DIST_VERSION + - name: Copy output + id: copy + run: | + source repository/src/config + NOW=$(date +"%Y-%m-%d-%H%M") + IMAGE="${NOW}-octopi-${DIST_VERSION}-${{ matrix.arch }}" + cp repository/src/workspace/*.img ${IMAGE}.img + echo "image=${IMAGE}" >> $GITHUB_OUTPUT - cp repository/src/workspace/*.img $IMAGE.img - - echo "::set-output name=image::$IMAGE" - - # artifact upload will take care of zipping for us - - uses: actions/upload-artifact@v4 - if: github.event_name == 'schedule' - with: - name: ${{ steps.copy.outputs.image }} - path: ${{ steps.copy.outputs.image }}.img + - uses: actions/upload-artifact@v4 + with: + name: octopi-${{ matrix.arch }} + path: ${{ steps.copy.outputs.image }}.img diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 00000000..1448cd26 --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,112 @@ +name: E2E Test + +on: + workflow_run: + workflows: ["Build Image"] + types: [completed] + workflow_dispatch: + inputs: + image_url: + description: "OctoPi image zip URL (arm64). Leave empty to use stable 1.1.0." + required: false + +jobs: + e2e-test: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: > + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Download arm64 image from build + if: github.event_name == 'workflow_run' + uses: actions/download-artifact@v4 + with: + name: octopi-arm64 + path: image/ + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download arm64 image from URL + if: github.event_name == 'workflow_dispatch' + run: | + URL="${{ github.event.inputs.image_url }}" + if [ -z "$URL" ]; then + URL="https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip" + fi + wget -q --show-progress -O octopi.zip "$URL" + mkdir -p image && unzip octopi.zip '*.img' -d image/ + + - name: Build test Docker image + run: DOCKER_BUILDKIT=0 docker build -t octopi-e2e-test ./testing/ + + - name: Start E2E test container + run: | + mkdir -p artifacts + IMG=$(find image/ -name '*.img' | head -1) + docker run -d --name octopi-test \ + -p 9980:9980 \ + -v "$PWD/artifacts:/output" \ + -v "$(realpath $IMG):/input/image.img:ro" \ + -e ARTIFACTS_DIR=/output \ + -e KEEP_ALIVE=true \ + octopi-e2e-test + + - name: Wait for tests to complete + run: | + for i in $(seq 1 180); do + [ -f artifacts/exit-code ] && break + sleep 5 + done + if [ ! -f artifacts/exit-code ]; then + echo "ERROR: Tests did not complete within 15 minutes" + docker logs octopi-test 2>&1 | tail -80 + exit 1 + fi + echo "Tests finished with exit code: $(cat artifacts/exit-code)" + cat artifacts/test-results.txt 2>/dev/null || true + + - name: Wait for OctoPrint web server + run: | + for i in $(seq 1 60); do + HTTP=$(curl -4 -s -o /dev/null -w '%{http_code}' \ + --connect-timeout 5 http://127.0.0.1:9980 2>/dev/null || true) + [ "$HTTP" = "200" ] && break + sleep 5 + done + + - name: Take OctoPrint screenshot + run: | + npx --yes puppeteer browsers install chrome + node -e " + const puppeteer = require('puppeteer'); + (async () => { + const browser = await puppeteer.launch({ + headless: 'new', args: ['--no-sandbox'] + }); + const page = await browser.newPage(); + await page.setViewport({width: 1280, height: 900}); + await page.goto('http://127.0.0.1:9980', { + waitUntil: 'networkidle2', timeout: 60000 + }); + await page.screenshot({path: 'artifacts/octoprint-screenshot.png'}); + await browser.close(); + })(); + " + + - name: Collect logs and stop container + if: always() + run: | + docker logs octopi-test > artifacts/container.log 2>&1 || true + docker stop octopi-test 2>/dev/null || true + + - name: Check test result + run: exit "$(cat artifacts/exit-code 2>/dev/null || echo 1)" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-test-results + path: artifacts/ diff --git a/testing/.dockerignore b/testing/.dockerignore new file mode 100644 index 00000000..47241b6e --- /dev/null +++ b/testing/.dockerignore @@ -0,0 +1 @@ +images/ diff --git a/testing/.gitignore b/testing/.gitignore new file mode 100644 index 00000000..47241b6e --- /dev/null +++ b/testing/.gitignore @@ -0,0 +1 @@ +images/ diff --git a/testing/Dockerfile b/testing/Dockerfile new file mode 100644 index 00000000..248d7882 --- /dev/null +++ b/testing/Dockerfile @@ -0,0 +1,11 @@ +FROM ptrsr/pi-ci:latest + +ENV LIBGUESTFS_BACKEND=direct + +RUN apt-get update && apt-get install -y --no-install-recommends sshpass openssh-client curl && rm -rf /var/lib/apt/lists/* + +COPY scripts/ /test/scripts/ +COPY tests/ /test/tests/ +RUN chmod +x /test/scripts/*.sh /test/tests/*.sh + +ENTRYPOINT ["/test/scripts/entrypoint.sh"] diff --git a/testing/run-test.sh b/testing/run-test.sh new file mode 100755 index 00000000..5f943ed1 --- /dev/null +++ b/testing/run-test.sh @@ -0,0 +1,82 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +IMAGE_DIR="${SCRIPT_DIR}/images" + +OCTOPI_URL="${IMAGE_URL:-https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip}" +OCTOPI_ZIP="octopi-bookworm-arm64-lite-1.1.0.zip" +OCTOPI_MD5="74cfd8e6c5b6ff9d8443aaa357201bcd" +DOCKER_IMAGE="octopi-e2e-test" +HTTP_PORT=9980 + +mkdir -p "$IMAGE_DIR" + +if [ -n "$IMAGE_PATH" ]; then + IMG_FILE="$(readlink -f "$IMAGE_PATH")" + echo "Using provided image: $IMG_FILE" +else + ZIP_PATH="${IMAGE_DIR}/${OCTOPI_ZIP}" + + if [ ! -f "$ZIP_PATH" ]; then + echo "Downloading OctoPi arm64 image from $OCTOPI_URL..." + wget -q --show-progress -O "$ZIP_PATH" "$OCTOPI_URL" + else + echo "Using cached download: $ZIP_PATH" + fi + + if [ "$OCTOPI_URL" = "https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip" ]; then + echo "Verifying checksum..." + ACTUAL_MD5=$(md5sum "$ZIP_PATH" | awk '{print $1}') + if [ "$ACTUAL_MD5" != "$OCTOPI_MD5" ]; then + echo "ERROR: MD5 mismatch! Expected: $OCTOPI_MD5 Got: $ACTUAL_MD5" + rm -f "$ZIP_PATH" + exit 1 + fi + echo "Checksum OK." + fi + + IMG_NAME=$(unzip -Z1 "$ZIP_PATH" | grep '\.img$' | head -1) + if [ -z "$IMG_NAME" ]; then + echo "ERROR: No .img file found inside $ZIP_PATH" + exit 1 + fi + + IMG_FILE="${IMAGE_DIR}/${IMG_NAME}" + + if [ ! -f "$IMG_FILE" ]; then + echo "Extracting $IMG_NAME..." + unzip -o "$ZIP_PATH" "$IMG_NAME" -d "$IMAGE_DIR" + else + echo "Using cached image: $IMG_FILE" + fi +fi + +if [ ! -f "$IMG_FILE" ]; then + echo "ERROR: Image file not found: $IMG_FILE" + exit 1 +fi + +echo "" +echo "Image: $IMG_FILE" +echo "Size: $(du -h "$IMG_FILE" | awk '{print $1}')" +echo "" + +echo "Building Docker image..." +DOCKER_BUILDKIT=0 docker build -t "$DOCKER_IMAGE" "$SCRIPT_DIR" + +DOCKER_RUN_ARGS="docker run --rm" +if [ -n "$KEEP_ALIVE" ]; then + DOCKER_RUN_ARGS+=" -p ${HTTP_PORT}:${HTTP_PORT}" + DOCKER_RUN_ARGS+=" -e KEEP_ALIVE=true" +fi +if [ -n "$ARTIFACTS_DIR" ]; then + DOCKER_RUN_ARGS+=" -v $(realpath "$ARTIFACTS_DIR"):/output" + DOCKER_RUN_ARGS+=" -e ARTIFACTS_DIR=/output" +fi + +echo "" +echo "Running E2E test..." +$DOCKER_RUN_ARGS \ + -v "${IMG_FILE}:/input/image.img:ro" \ + "$DOCKER_IMAGE" diff --git a/testing/scripts/boot-qemu.sh b/testing/scripts/boot-qemu.sh new file mode 100755 index 00000000..c83facfc --- /dev/null +++ b/testing/scripts/boot-qemu.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +IMAGE_FILE="${1:?Usage: $0 }" +KERNEL="${2:-/base/kernel.img}" +SSH_PORT="${3:-2222}" +LOG_FILE="${4:-/tmp/qemu-serial.log}" +HTTP_PORT="${5:-8080}" + +echo "=== Starting QEMU (aarch64, -M virt) ===" +echo " Image: $IMAGE_FILE" +echo " Kernel: $KERNEL" +echo " SSH: port $SSH_PORT -> guest:22" +echo " HTTP: port $HTTP_PORT -> guest:80" + +qemu-system-aarch64 \ + -machine virt \ + -cpu cortex-a72 \ + -m 2G \ + -smp 4 \ + -kernel "$KERNEL" \ + -append "rw console=ttyAMA0 root=/dev/vda2 rootfstype=ext4 rootdelay=1 loglevel=2" \ + -drive "file=$IMAGE_FILE,format=qcow2,id=hd0,if=none,cache=writeback" \ + -device virtio-blk,drive=hd0,bootindex=0 \ + -netdev "user,id=mynet,hostfwd=tcp::${SSH_PORT}-:22,hostfwd=tcp::${HTTP_PORT}-:80" \ + -device virtio-net-pci,netdev=mynet \ + -nographic \ + -no-reboot \ + 2>&1 | tee "$LOG_FILE" diff --git a/testing/scripts/entrypoint.sh b/testing/scripts/entrypoint.sh new file mode 100755 index 00000000..f1f511ad --- /dev/null +++ b/testing/scripts/entrypoint.sh @@ -0,0 +1,112 @@ +#!/bin/bash +set -e + +INPUT_IMAGE="/input/image.img" +WORK_DIR="/work" +IMAGE_FILE="${WORK_DIR}/distro.qcow2" +KERNEL="/base/kernel.img" +SSH_PORT=2222 +SSH_TIMEOUT="${SSH_TIMEOUT:-600}" +LOG_FILE="/tmp/qemu-serial.log" +HTTP_PORT="${HTTP_PORT:-9980}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR="$(dirname "$SCRIPT_DIR")/tests" + +echo "============================================" +echo " OctoPi E2E Test" +echo "============================================" + +if [ ! -f "$INPUT_IMAGE" ]; then + echo "ERROR: No image found at $INPUT_IMAGE" + echo "Mount an OctoPi .img file with: -v /path/to/image.img:/input/image.img:ro" + exit 1 +fi + +if [ ! -f "$KERNEL" ]; then + echo "ERROR: No kernel found at $KERNEL" + exit 1 +fi + +cleanup() { + if [ -n "$QEMU_PID" ] && kill -0 "$QEMU_PID" 2>/dev/null; then + echo "Stopping QEMU (pid $QEMU_PID)..." + kill "$QEMU_PID" 2>/dev/null || true + wait "$QEMU_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +echo "" +echo "--- Step 1: Prepare image ---" +"$SCRIPT_DIR/prepare-image.sh" "$INPUT_IMAGE" "$IMAGE_FILE" + +echo "" +echo "--- Step 2: Boot QEMU ---" +"$SCRIPT_DIR/boot-qemu.sh" "$IMAGE_FILE" "$KERNEL" "$SSH_PORT" "$LOG_FILE" "$HTTP_PORT" & +QEMU_PID=$! +echo "QEMU started (pid $QEMU_PID)" + +echo "" +echo "--- Step 3: Wait for SSH ---" +set +e +"$SCRIPT_DIR/wait-for-ssh.sh" localhost "$SSH_PORT" "$SSH_TIMEOUT" +SSH_WAIT_RC=$? +set -e +if [ "$SSH_WAIT_RC" -ne 0 ]; then + echo "SSH wait failed. QEMU log tail:" + tail -50 "$LOG_FILE" 2>/dev/null || true + if [ -n "$ARTIFACTS_DIR" ]; then + cp "$LOG_FILE" "$ARTIFACTS_DIR/qemu-boot.log" 2>/dev/null || true + echo "1" > "$ARTIFACTS_DIR/exit-code" + fi + exit 1 +fi + +echo "" +echo "--- Step 4: Run tests ---" +TEST_RESULT=0 +for test_script in "$TEST_DIR"/test_*.sh; do + if [ -x "$test_script" ]; then + echo "Running $(basename "$test_script")..." + if [ -n "$ARTIFACTS_DIR" ]; then + if "$test_script" localhost "$SSH_PORT" "$ARTIFACTS_DIR"; then + echo " -> PASSED" + else + echo " -> FAILED" + TEST_RESULT=1 + fi + else + if "$test_script" localhost "$SSH_PORT"; then + echo " -> PASSED" + else + echo " -> FAILED" + TEST_RESULT=1 + fi + fi + fi +done + +echo "" +echo "============================================" +if [ "$TEST_RESULT" -eq 0 ]; then + echo " ALL TESTS PASSED" +else + echo " SOME TESTS FAILED" +fi +echo "============================================" + +if [ -n "$ARTIFACTS_DIR" ]; then + echo "Collecting artifacts to $ARTIFACTS_DIR..." + cp "$LOG_FILE" "$ARTIFACTS_DIR/qemu-boot.log" 2>/dev/null || true + echo "$TEST_RESULT" > "$ARTIFACTS_DIR/exit-code" + echo "TEST_RESULT=$TEST_RESULT" > "$ARTIFACTS_DIR/test-results.txt" +fi + +if [ -n "$KEEP_ALIVE" ]; then + echo "Keeping container alive (KEEP_ALIVE set)..." + trap - EXIT + sleep infinity +else + exit "$TEST_RESULT" +fi diff --git a/testing/scripts/prepare-image.sh b/testing/scripts/prepare-image.sh new file mode 100755 index 00000000..dde90806 --- /dev/null +++ b/testing/scripts/prepare-image.sh @@ -0,0 +1,93 @@ +#!/bin/bash +set -e +INPUT_IMAGE="${1:?Usage: $0 }" +OUTPUT_IMAGE="${2:?Usage: $0 }" +PIPASS=$(openssl passwd -6 raspberry) + +echo '=== Preparing image ===' +mkdir -p /work +echo 'Converting to qcow2...' +qemu-img convert -f raw -O qcow2 "$INPUT_IMAGE" "$OUTPUT_IMAGE" +echo 'Patching image (rootfs)...' +export LIBGUESTFS_BACKEND=direct +export LIBGUESTFS_DEBUG=0 +export LIBGUESTFS_TRACE=0 +guestfish -a "$OUTPUT_IMAGE" < /dev/tcp/"$HOST"/"$PORT") 2>/dev/null; then + RESULT=$(sshpass -p "$PASS" ssh $SSH_OPTS -p "$PORT" "${USER}@${HOST}" true 2>&1) + RC=$? + if [ "$RC" -eq 0 ]; then + echo "" + echo "SSH is ready (took ${ELAPSED}s)" + exit 0 + fi + if [ $(( ATTEMPT % 6 )) -eq 0 ]; then + echo "" + echo "[${ELAPSED}s] Port open, sshpass rc=$RC output: $RESULT" + echo "[${ELAPSED}s] Trying verbose SSH..." + sshpass -p "$PASS" ssh -v $SSH_OPTS -p "$PORT" "${USER}@${HOST}" true 2>&1 | tail -20 + else + printf "x" + fi + else + printf "." + fi + sleep 5 +done diff --git a/testing/tests/test_boot.sh b/testing/tests/test_boot.sh new file mode 100755 index 00000000..ad4e624f --- /dev/null +++ b/testing/tests/test_boot.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +HOST="${1:-localhost}" +PORT="${2:-2222}" +USER="pi" +PASS="raspberry" + +SSH_CMD="sshpass -p $PASS ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o PubkeyAuthentication=no -o LogLevel=ERROR -p $PORT ${USER}@${HOST}" + +echo "Test: SSH login and run 'echo hello world'" + +OUTPUT=$($SSH_CMD 'echo hello world' 2>/dev/null) + +if [ "$OUTPUT" = "hello world" ]; then + echo " Output: '$OUTPUT'" + echo " PASS: Got expected output" + exit 0 +else + echo " Expected: 'hello world'" + echo " Got: '$OUTPUT'" + echo " FAIL: Unexpected output" + exit 1 +fi diff --git a/testing/tests/test_octoprint_web.sh b/testing/tests/test_octoprint_web.sh new file mode 100755 index 00000000..14c68011 --- /dev/null +++ b/testing/tests/test_octoprint_web.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e + +HOST="${1:-localhost}" +PORT="${2:-2222}" +ARTIFACTS_DIR="${3:-}" +USER="pi" +PASS="raspberry" + +SSH_CMD="sshpass -p $PASS ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o PubkeyAuthentication=no -o LogLevel=ERROR -p $PORT ${USER}@${HOST}" + +echo "Test: OctoPrint web server is accessible" + +OCTOPRINT_READY=0 +for i in $(seq 1 24); do + HTTP_CODE=$($SSH_CMD "curl -s -o /dev/null -w '%{http_code}' http://localhost" 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "200" ]; then + OCTOPRINT_READY=1 + break + fi + printf "W" + sleep 5 +done +echo "" + +if [ "$OCTOPRINT_READY" -eq 0 ]; then + echo " FAIL: OctoPrint web server not reachable after 120s" + exit 1 +fi + +echo " OctoPrint web server is ready (HTTP 200)" + +FULL_HTML=$($SSH_CMD "curl -s http://localhost" 2>/dev/null) + +if [ -n "$ARTIFACTS_DIR" ]; then + echo "$FULL_HTML" > "$ARTIFACTS_DIR/octoprint.html" + echo " Saved HTML to $ARTIFACTS_DIR/octoprint.html" +fi + +if echo "$FULL_HTML" | grep -q "OctoPrint"; then + echo " PASS: OctoPrint web UI returned expected content" + exit 0 +else + echo " FAIL: Response did not contain 'OctoPrint'" + exit 1 +fi From 2a9064d0d9105fd942eef47d0fa2470c51e70098 Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Sat, 21 Feb 2026 23:04:15 +0200 Subject: [PATCH 02/13] Move E2E test into build.yml, enable e2e-test on feature branch - Add e2e-test job to build.yml with needs: build, downloads the octopi-arm64 artifact and runs the QEMU test + screenshot - Change e2e-test.yml to trigger on push to feature/e2e and devel using a stable arm64 image (workflow_run doesn't work from non-default branches) --- .github/workflows/build.yml | 85 ++++++++++++++++++++++++++++++++++ .github/workflows/e2e-test.yml | 30 ++---------- 2 files changed, 89 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ddb7632..7244b4ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,3 +68,88 @@ jobs: with: name: octopi-${{ matrix.arch }} path: ${{ steps.copy.outputs.image }}.img + + e2e-test: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Download arm64 image from build + uses: actions/download-artifact@v4 + with: + name: octopi-arm64 + path: image/ + + - name: Build test Docker image + run: DOCKER_BUILDKIT=0 docker build -t octopi-e2e-test ./testing/ + + - name: Start E2E test container + run: | + mkdir -p artifacts + IMG=$(find image/ -name '*.img' | head -1) + docker run -d --name octopi-test \ + -p 9980:9980 \ + -v "$PWD/artifacts:/output" \ + -v "$(realpath $IMG):/input/image.img:ro" \ + -e ARTIFACTS_DIR=/output \ + -e KEEP_ALIVE=true \ + octopi-e2e-test + + - name: Wait for tests to complete + run: | + for i in $(seq 1 180); do + [ -f artifacts/exit-code ] && break + sleep 5 + done + if [ ! -f artifacts/exit-code ]; then + echo "ERROR: Tests did not complete within 15 minutes" + docker logs octopi-test 2>&1 | tail -80 + exit 1 + fi + echo "Tests finished with exit code: $(cat artifacts/exit-code)" + cat artifacts/test-results.txt 2>/dev/null || true + + - name: Wait for OctoPrint web server + run: | + for i in $(seq 1 60); do + HTTP=$(curl -4 -s -o /dev/null -w '%{http_code}' \ + --connect-timeout 5 http://127.0.0.1:9980 2>/dev/null || true) + [ "$HTTP" = "200" ] && echo "OctoPrint ready" && break + sleep 5 + done + + - name: Take OctoPrint screenshot + run: | + npx --yes puppeteer browsers install chrome + node -e " + const puppeteer = require('puppeteer'); + (async () => { + const browser = await puppeteer.launch({ + headless: 'new', args: ['--no-sandbox'] + }); + const page = await browser.newPage(); + await page.setViewport({width: 1280, height: 900}); + await page.goto('http://127.0.0.1:9980', { + waitUntil: 'networkidle2', timeout: 60000 + }); + await page.screenshot({path: 'artifacts/octoprint-screenshot.png'}); + await browser.close(); + })(); + " + + - name: Collect logs and stop container + if: always() + run: | + docker logs octopi-test > artifacts/container.log 2>&1 || true + docker stop octopi-test 2>/dev/null || true + + - name: Check test result + run: exit "$(cat artifacts/exit-code 2>/dev/null || echo 1)" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-test-results + path: artifacts/ diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 1448cd26..ca3b8c34 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -1,41 +1,19 @@ name: E2E Test on: - workflow_run: - workflows: ["Build Image"] - types: [completed] - workflow_dispatch: - inputs: - image_url: - description: "OctoPi image zip URL (arm64). Leave empty to use stable 1.1.0." - required: false + push: + branches: [feature/e2e, devel] jobs: e2e-test: runs-on: ubuntu-latest timeout-minutes: 30 - if: > - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' steps: - uses: actions/checkout@v4 - - name: Download arm64 image from build - if: github.event_name == 'workflow_run' - uses: actions/download-artifact@v4 - with: - name: octopi-arm64 - path: image/ - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Download arm64 image from URL - if: github.event_name == 'workflow_dispatch' + - name: Download stable arm64 image run: | - URL="${{ github.event.inputs.image_url }}" - if [ -z "$URL" ]; then - URL="https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip" - fi + URL="https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip" wget -q --show-progress -O octopi.zip "$URL" mkdir -p image && unzip octopi.zip '*.img' -d image/ From daf0122b3d2be57fcf25827a092c0421d90039ef Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Sun, 22 Feb 2026 00:04:29 +0200 Subject: [PATCH 03/13] Remove standalone e2e-test.yml E2E testing lives in build.yml as a job that tests the built arm64 image. No need for a separate workflow against a stable image. --- .github/workflows/e2e-test.yml | 90 ---------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 .github/workflows/e2e-test.yml diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml deleted file mode 100644 index ca3b8c34..00000000 --- a/.github/workflows/e2e-test.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: E2E Test - -on: - push: - branches: [feature/e2e, devel] - -jobs: - e2e-test: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - - name: Download stable arm64 image - run: | - URL="https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip" - wget -q --show-progress -O octopi.zip "$URL" - mkdir -p image && unzip octopi.zip '*.img' -d image/ - - - name: Build test Docker image - run: DOCKER_BUILDKIT=0 docker build -t octopi-e2e-test ./testing/ - - - name: Start E2E test container - run: | - mkdir -p artifacts - IMG=$(find image/ -name '*.img' | head -1) - docker run -d --name octopi-test \ - -p 9980:9980 \ - -v "$PWD/artifacts:/output" \ - -v "$(realpath $IMG):/input/image.img:ro" \ - -e ARTIFACTS_DIR=/output \ - -e KEEP_ALIVE=true \ - octopi-e2e-test - - - name: Wait for tests to complete - run: | - for i in $(seq 1 180); do - [ -f artifacts/exit-code ] && break - sleep 5 - done - if [ ! -f artifacts/exit-code ]; then - echo "ERROR: Tests did not complete within 15 minutes" - docker logs octopi-test 2>&1 | tail -80 - exit 1 - fi - echo "Tests finished with exit code: $(cat artifacts/exit-code)" - cat artifacts/test-results.txt 2>/dev/null || true - - - name: Wait for OctoPrint web server - run: | - for i in $(seq 1 60); do - HTTP=$(curl -4 -s -o /dev/null -w '%{http_code}' \ - --connect-timeout 5 http://127.0.0.1:9980 2>/dev/null || true) - [ "$HTTP" = "200" ] && break - sleep 5 - done - - - name: Take OctoPrint screenshot - run: | - npx --yes puppeteer browsers install chrome - node -e " - const puppeteer = require('puppeteer'); - (async () => { - const browser = await puppeteer.launch({ - headless: 'new', args: ['--no-sandbox'] - }); - const page = await browser.newPage(); - await page.setViewport({width: 1280, height: 900}); - await page.goto('http://127.0.0.1:9980', { - waitUntil: 'networkidle2', timeout: 60000 - }); - await page.screenshot({path: 'artifacts/octoprint-screenshot.png'}); - await browser.close(); - })(); - " - - - name: Collect logs and stop container - if: always() - run: | - docker logs octopi-test > artifacts/container.log 2>&1 || true - docker stop octopi-test 2>/dev/null || true - - - name: Check test result - run: exit "$(cat artifacts/exit-code 2>/dev/null || echo 1)" - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: e2e-test-results - path: artifacts/ From 896303c1b02979ec461c556ee0f616ec4144872a Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Sun, 22 Feb 2026 13:57:03 +0200 Subject: [PATCH 04/13] Fix puppeteer screenshot: npm install instead of npx browsers install --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7244b4ca..ef7c6af8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -122,7 +122,7 @@ jobs: - name: Take OctoPrint screenshot run: | - npx --yes puppeteer browsers install chrome + npm install puppeteer node -e " const puppeteer = require('puppeteer'); (async () => { From caf03f9d30cf02d53562c50696d8527b09e8a8e3 Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Sun, 22 Feb 2026 22:44:16 +0200 Subject: [PATCH 05/13] Wait for OctoPrint Setup Wizard before taking screenshot The previous screenshot captured OctoPrint's "starting up" loading screen instead of the actual UI. Now both the CI workflow and the E2E test wait for OctoPrint to fully finish its startup phase by checking for CONFIG_WIZARD in the page HTML, and puppeteer waits for the #wizard_dialog to become visible before capturing. --- .github/workflows/build.yml | 46 ++++++++++++++++++++++++----- testing/tests/test_octoprint_web.sh | 23 ++++++++++----- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ef7c6af8..9b66ed73 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -111,14 +111,23 @@ jobs: echo "Tests finished with exit code: $(cat artifacts/exit-code)" cat artifacts/test-results.txt 2>/dev/null || true - - name: Wait for OctoPrint web server + - name: Wait for OctoPrint to fully start run: | - for i in $(seq 1 60); do - HTTP=$(curl -4 -s -o /dev/null -w '%{http_code}' \ - --connect-timeout 5 http://127.0.0.1:9980 2>/dev/null || true) - [ "$HTTP" = "200" ] && echo "OctoPrint ready" && break + echo "Waiting for OctoPrint to finish startup..." + for i in $(seq 1 90); do + BODY=$(curl -4 -s --connect-timeout 5 http://127.0.0.1:9980 2>/dev/null || true) + if echo "$BODY" | grep -q "CONFIG_WIZARD"; then + echo "OctoPrint fully started (Setup Wizard ready)" + exit 0 + elif echo "$BODY" | grep -q "starting up"; then + printf "S" + else + printf "." + fi sleep 5 done + echo "" + echo "WARNING: OctoPrint may not be fully started yet, proceeding anyway" - name: Take OctoPrint screenshot run: | @@ -131,10 +140,31 @@ jobs: }); const page = await browser.newPage(); await page.setViewport({width: 1280, height: 900}); - await page.goto('http://127.0.0.1:9980', { - waitUntil: 'networkidle2', timeout: 60000 - }); + + // Retry loading until OctoPrint finishes its startup phase + for (let attempt = 0; attempt < 30; attempt++) { + await page.goto('http://127.0.0.1:9980', { + waitUntil: 'networkidle2', timeout: 60000 + }); + const html = await page.content(); + if (html.includes('CONFIG_WIZARD') || html.includes('id=\"login\"')) break; + console.log('OctoPrint still starting up, retrying in 10s... (attempt ' + (attempt+1) + ')'); + await new Promise(r => setTimeout(r, 10000)); + } + + // Wait for the Setup Wizard dialog to appear + try { + await page.waitForSelector('#wizard_dialog', { visible: true, timeout: 120000 }); + } catch (e) { + console.log('Wizard did not appear, taking screenshot of current state'); + } + + // Dismiss notification popovers by clicking the wizard body + try { await page.click('#wizard_dialog .modal-body'); } catch(e) {} + await new Promise(r => setTimeout(r, 2000)); + await page.screenshot({path: 'artifacts/octoprint-screenshot.png'}); + console.log('Screenshot captured'); await browser.close(); })(); " diff --git a/testing/tests/test_octoprint_web.sh b/testing/tests/test_octoprint_web.sh index 14c68011..adcfa3d7 100755 --- a/testing/tests/test_octoprint_web.sh +++ b/testing/tests/test_octoprint_web.sh @@ -9,26 +9,35 @@ PASS="raspberry" SSH_CMD="sshpass -p $PASS ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o PubkeyAuthentication=no -o LogLevel=ERROR -p $PORT ${USER}@${HOST}" -echo "Test: OctoPrint web server is accessible" +echo "Test: OctoPrint web server is accessible and fully started" OCTOPRINT_READY=0 -for i in $(seq 1 24); do +for i in $(seq 1 60); do + BODY=$($SSH_CMD "curl -s http://localhost" 2>/dev/null || echo "") HTTP_CODE=$($SSH_CMD "curl -s -o /dev/null -w '%{http_code}' http://localhost" 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "200" ]; then - OCTOPRINT_READY=1 - break + if echo "$BODY" | grep -q "starting up"; then + printf "S" + elif echo "$BODY" | grep -q "CONFIG_WIZARD\|OctoPrint"; then + OCTOPRINT_READY=1 + break + else + printf "W" + fi + else + printf "." fi - printf "W" sleep 5 done echo "" if [ "$OCTOPRINT_READY" -eq 0 ]; then - echo " FAIL: OctoPrint web server not reachable after 120s" + echo " FAIL: OctoPrint did not fully start within 300s" exit 1 fi -echo " OctoPrint web server is ready (HTTP 200)" +echo " OctoPrint web server is fully started (HTTP 200, UI loaded)" FULL_HTML=$($SSH_CMD "curl -s http://localhost" 2>/dev/null) From b547ccf3b55599bb7027b3cf587359b71d895984 Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Wed, 11 Mar 2026 01:16:59 +0200 Subject: [PATCH 06/13] Refactor e2e tests to use shared CustomPiOS distro_testing framework Replace standalone testing scripts with the shared framework from CustomPiOS/src/distro_testing/. The CI workflow now checks out CustomPiOS and copies the shared scripts into the build context. OctoPi-specific logic moves into hooks/ (haproxy IPv4 patching, headless browser screenshot). Removes run-test.sh and testing/scripts/ (now provided by the framework). --- .github/workflows/build.yml | 15 +++++ testing/.gitignore | 2 + testing/Dockerfile | 15 ++++- testing/hooks/prepare-image.sh | 30 +++++++++ testing/hooks/screenshot.sh | 36 ++++++++++ testing/run-test.sh | 82 ---------------------- testing/scripts/boot-qemu.sh | 29 -------- testing/scripts/entrypoint.sh | 112 ------------------------------- testing/scripts/prepare-image.sh | 93 ------------------------- testing/scripts/wait-for-ssh.sh | 44 ------------ testing/tests/test_boot.sh | 24 ------- 11 files changed, 95 insertions(+), 387 deletions(-) create mode 100755 testing/hooks/prepare-image.sh create mode 100755 testing/hooks/screenshot.sh delete mode 100755 testing/run-test.sh delete mode 100755 testing/scripts/boot-qemu.sh delete mode 100755 testing/scripts/entrypoint.sh delete mode 100755 testing/scripts/prepare-image.sh delete mode 100755 testing/scripts/wait-for-ssh.sh delete mode 100755 testing/tests/test_boot.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b66ed73..db71e165 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,7 @@ jobs: uses: actions/checkout@v4 with: repository: 'guysoft/CustomPiOS' + ref: feature/e2e path: CustomPiOS - name: Checkout Project Repository @@ -76,12 +77,25 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Checkout CustomPiOS + uses: actions/checkout@v4 + with: + repository: 'guysoft/CustomPiOS' + ref: feature/e2e + path: CustomPiOS + - name: Download arm64 image from build uses: actions/download-artifact@v4 with: name: octopi-arm64 path: image/ + - name: Prepare testing context + run: | + mkdir -p testing/custompios + cp -r CustomPiOS/src/distro_testing/scripts testing/custompios/scripts + cp -r CustomPiOS/src/distro_testing/tests testing/custompios/tests + - name: Build test Docker image run: DOCKER_BUILDKIT=0 docker build -t octopi-e2e-test ./testing/ @@ -94,6 +108,7 @@ jobs: -v "$PWD/artifacts:/output" \ -v "$(realpath $IMG):/input/image.img:ro" \ -e ARTIFACTS_DIR=/output \ + -e DISTRO_NAME="OctoPi" \ -e KEEP_ALIVE=true \ octopi-e2e-test diff --git a/testing/.gitignore b/testing/.gitignore index 47241b6e..8a86c2b0 100644 --- a/testing/.gitignore +++ b/testing/.gitignore @@ -1 +1,3 @@ images/ +custompios/ +*.png diff --git a/testing/Dockerfile b/testing/Dockerfile index 248d7882..ab56a399 100644 --- a/testing/Dockerfile +++ b/testing/Dockerfile @@ -2,10 +2,19 @@ FROM ptrsr/pi-ci:latest ENV LIBGUESTFS_BACKEND=direct -RUN apt-get update && apt-get install -y --no-install-recommends sshpass openssh-client curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends \ + sshpass openssh-client curl socat imagemagick \ + && rm -rf /var/lib/apt/lists/* -COPY scripts/ /test/scripts/ +# Shared framework from CustomPiOS (copied into build context by CI) +COPY custompios/scripts/ /test/scripts/ +COPY custompios/tests/ /test/tests/ + +# OctoPi-specific tests and hooks COPY tests/ /test/tests/ -RUN chmod +x /test/scripts/*.sh /test/tests/*.sh +COPY hooks/ /test/hooks/ + +RUN chmod +x /test/scripts/*.sh /test/tests/*.sh; \ + chmod +x /test/hooks/*.sh 2>/dev/null || true ENTRYPOINT ["/test/scripts/entrypoint.sh"] diff --git a/testing/hooks/prepare-image.sh b/testing/hooks/prepare-image.sh new file mode 100755 index 00000000..66ca08a9 --- /dev/null +++ b/testing/hooks/prepare-image.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e +IMAGE_FILE="${1:?Usage: $0 }" + +export LIBGUESTFS_BACKEND=direct +export LIBGUESTFS_DEBUG=0 +export LIBGUESTFS_TRACE=0 + +echo '=== OctoPi-specific image patches ===' + +echo 'Downloading haproxy config for IPv4 patching...' +guestfish -a "$IMAGE_FILE" </dev/null || echo "") + +if [ -n "$BODY" ]; then + echo "$BODY" > "$ARTIFACTS_DIR/octoprint-ui.html" + echo " Saved OctoPrint HTML to artifacts" +fi + +# Attempt headless screenshot from inside the container if a browser is available +HTTP_PORT="${QEMU_HTTP_PORT:-8080}" +for BROWSER in chromium chromium-browser google-chrome; do + if command -v "$BROWSER" &>/dev/null; then + echo " Using $BROWSER for headless screenshot..." + "$BROWSER" --headless --disable-gpu --no-sandbox \ + --virtual-time-budget=10000 \ + --screenshot="$ARTIFACTS_DIR/screenshot.png" \ + --window-size=1280,720 \ + "http://localhost:${HTTP_PORT}" 2>/dev/null || true + if [ -f "$ARTIFACTS_DIR/screenshot.png" ]; then + echo " Browser screenshot saved" + exit 0 + fi + fi +done + +echo " No headless browser available in container (HTML artifact saved instead)" diff --git a/testing/run-test.sh b/testing/run-test.sh deleted file mode 100755 index 5f943ed1..00000000 --- a/testing/run-test.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/bash -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -IMAGE_DIR="${SCRIPT_DIR}/images" - -OCTOPI_URL="${IMAGE_URL:-https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip}" -OCTOPI_ZIP="octopi-bookworm-arm64-lite-1.1.0.zip" -OCTOPI_MD5="74cfd8e6c5b6ff9d8443aaa357201bcd" -DOCKER_IMAGE="octopi-e2e-test" -HTTP_PORT=9980 - -mkdir -p "$IMAGE_DIR" - -if [ -n "$IMAGE_PATH" ]; then - IMG_FILE="$(readlink -f "$IMAGE_PATH")" - echo "Using provided image: $IMG_FILE" -else - ZIP_PATH="${IMAGE_DIR}/${OCTOPI_ZIP}" - - if [ ! -f "$ZIP_PATH" ]; then - echo "Downloading OctoPi arm64 image from $OCTOPI_URL..." - wget -q --show-progress -O "$ZIP_PATH" "$OCTOPI_URL" - else - echo "Using cached download: $ZIP_PATH" - fi - - if [ "$OCTOPI_URL" = "https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip" ]; then - echo "Verifying checksum..." - ACTUAL_MD5=$(md5sum "$ZIP_PATH" | awk '{print $1}') - if [ "$ACTUAL_MD5" != "$OCTOPI_MD5" ]; then - echo "ERROR: MD5 mismatch! Expected: $OCTOPI_MD5 Got: $ACTUAL_MD5" - rm -f "$ZIP_PATH" - exit 1 - fi - echo "Checksum OK." - fi - - IMG_NAME=$(unzip -Z1 "$ZIP_PATH" | grep '\.img$' | head -1) - if [ -z "$IMG_NAME" ]; then - echo "ERROR: No .img file found inside $ZIP_PATH" - exit 1 - fi - - IMG_FILE="${IMAGE_DIR}/${IMG_NAME}" - - if [ ! -f "$IMG_FILE" ]; then - echo "Extracting $IMG_NAME..." - unzip -o "$ZIP_PATH" "$IMG_NAME" -d "$IMAGE_DIR" - else - echo "Using cached image: $IMG_FILE" - fi -fi - -if [ ! -f "$IMG_FILE" ]; then - echo "ERROR: Image file not found: $IMG_FILE" - exit 1 -fi - -echo "" -echo "Image: $IMG_FILE" -echo "Size: $(du -h "$IMG_FILE" | awk '{print $1}')" -echo "" - -echo "Building Docker image..." -DOCKER_BUILDKIT=0 docker build -t "$DOCKER_IMAGE" "$SCRIPT_DIR" - -DOCKER_RUN_ARGS="docker run --rm" -if [ -n "$KEEP_ALIVE" ]; then - DOCKER_RUN_ARGS+=" -p ${HTTP_PORT}:${HTTP_PORT}" - DOCKER_RUN_ARGS+=" -e KEEP_ALIVE=true" -fi -if [ -n "$ARTIFACTS_DIR" ]; then - DOCKER_RUN_ARGS+=" -v $(realpath "$ARTIFACTS_DIR"):/output" - DOCKER_RUN_ARGS+=" -e ARTIFACTS_DIR=/output" -fi - -echo "" -echo "Running E2E test..." -$DOCKER_RUN_ARGS \ - -v "${IMG_FILE}:/input/image.img:ro" \ - "$DOCKER_IMAGE" diff --git a/testing/scripts/boot-qemu.sh b/testing/scripts/boot-qemu.sh deleted file mode 100755 index c83facfc..00000000 --- a/testing/scripts/boot-qemu.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -e - -IMAGE_FILE="${1:?Usage: $0 }" -KERNEL="${2:-/base/kernel.img}" -SSH_PORT="${3:-2222}" -LOG_FILE="${4:-/tmp/qemu-serial.log}" -HTTP_PORT="${5:-8080}" - -echo "=== Starting QEMU (aarch64, -M virt) ===" -echo " Image: $IMAGE_FILE" -echo " Kernel: $KERNEL" -echo " SSH: port $SSH_PORT -> guest:22" -echo " HTTP: port $HTTP_PORT -> guest:80" - -qemu-system-aarch64 \ - -machine virt \ - -cpu cortex-a72 \ - -m 2G \ - -smp 4 \ - -kernel "$KERNEL" \ - -append "rw console=ttyAMA0 root=/dev/vda2 rootfstype=ext4 rootdelay=1 loglevel=2" \ - -drive "file=$IMAGE_FILE,format=qcow2,id=hd0,if=none,cache=writeback" \ - -device virtio-blk,drive=hd0,bootindex=0 \ - -netdev "user,id=mynet,hostfwd=tcp::${SSH_PORT}-:22,hostfwd=tcp::${HTTP_PORT}-:80" \ - -device virtio-net-pci,netdev=mynet \ - -nographic \ - -no-reboot \ - 2>&1 | tee "$LOG_FILE" diff --git a/testing/scripts/entrypoint.sh b/testing/scripts/entrypoint.sh deleted file mode 100755 index f1f511ad..00000000 --- a/testing/scripts/entrypoint.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/bin/bash -set -e - -INPUT_IMAGE="/input/image.img" -WORK_DIR="/work" -IMAGE_FILE="${WORK_DIR}/distro.qcow2" -KERNEL="/base/kernel.img" -SSH_PORT=2222 -SSH_TIMEOUT="${SSH_TIMEOUT:-600}" -LOG_FILE="/tmp/qemu-serial.log" -HTTP_PORT="${HTTP_PORT:-9980}" - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -TEST_DIR="$(dirname "$SCRIPT_DIR")/tests" - -echo "============================================" -echo " OctoPi E2E Test" -echo "============================================" - -if [ ! -f "$INPUT_IMAGE" ]; then - echo "ERROR: No image found at $INPUT_IMAGE" - echo "Mount an OctoPi .img file with: -v /path/to/image.img:/input/image.img:ro" - exit 1 -fi - -if [ ! -f "$KERNEL" ]; then - echo "ERROR: No kernel found at $KERNEL" - exit 1 -fi - -cleanup() { - if [ -n "$QEMU_PID" ] && kill -0 "$QEMU_PID" 2>/dev/null; then - echo "Stopping QEMU (pid $QEMU_PID)..." - kill "$QEMU_PID" 2>/dev/null || true - wait "$QEMU_PID" 2>/dev/null || true - fi -} -trap cleanup EXIT - -echo "" -echo "--- Step 1: Prepare image ---" -"$SCRIPT_DIR/prepare-image.sh" "$INPUT_IMAGE" "$IMAGE_FILE" - -echo "" -echo "--- Step 2: Boot QEMU ---" -"$SCRIPT_DIR/boot-qemu.sh" "$IMAGE_FILE" "$KERNEL" "$SSH_PORT" "$LOG_FILE" "$HTTP_PORT" & -QEMU_PID=$! -echo "QEMU started (pid $QEMU_PID)" - -echo "" -echo "--- Step 3: Wait for SSH ---" -set +e -"$SCRIPT_DIR/wait-for-ssh.sh" localhost "$SSH_PORT" "$SSH_TIMEOUT" -SSH_WAIT_RC=$? -set -e -if [ "$SSH_WAIT_RC" -ne 0 ]; then - echo "SSH wait failed. QEMU log tail:" - tail -50 "$LOG_FILE" 2>/dev/null || true - if [ -n "$ARTIFACTS_DIR" ]; then - cp "$LOG_FILE" "$ARTIFACTS_DIR/qemu-boot.log" 2>/dev/null || true - echo "1" > "$ARTIFACTS_DIR/exit-code" - fi - exit 1 -fi - -echo "" -echo "--- Step 4: Run tests ---" -TEST_RESULT=0 -for test_script in "$TEST_DIR"/test_*.sh; do - if [ -x "$test_script" ]; then - echo "Running $(basename "$test_script")..." - if [ -n "$ARTIFACTS_DIR" ]; then - if "$test_script" localhost "$SSH_PORT" "$ARTIFACTS_DIR"; then - echo " -> PASSED" - else - echo " -> FAILED" - TEST_RESULT=1 - fi - else - if "$test_script" localhost "$SSH_PORT"; then - echo " -> PASSED" - else - echo " -> FAILED" - TEST_RESULT=1 - fi - fi - fi -done - -echo "" -echo "============================================" -if [ "$TEST_RESULT" -eq 0 ]; then - echo " ALL TESTS PASSED" -else - echo " SOME TESTS FAILED" -fi -echo "============================================" - -if [ -n "$ARTIFACTS_DIR" ]; then - echo "Collecting artifacts to $ARTIFACTS_DIR..." - cp "$LOG_FILE" "$ARTIFACTS_DIR/qemu-boot.log" 2>/dev/null || true - echo "$TEST_RESULT" > "$ARTIFACTS_DIR/exit-code" - echo "TEST_RESULT=$TEST_RESULT" > "$ARTIFACTS_DIR/test-results.txt" -fi - -if [ -n "$KEEP_ALIVE" ]; then - echo "Keeping container alive (KEEP_ALIVE set)..." - trap - EXIT - sleep infinity -else - exit "$TEST_RESULT" -fi diff --git a/testing/scripts/prepare-image.sh b/testing/scripts/prepare-image.sh deleted file mode 100755 index dde90806..00000000 --- a/testing/scripts/prepare-image.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash -set -e -INPUT_IMAGE="${1:?Usage: $0 }" -OUTPUT_IMAGE="${2:?Usage: $0 }" -PIPASS=$(openssl passwd -6 raspberry) - -echo '=== Preparing image ===' -mkdir -p /work -echo 'Converting to qcow2...' -qemu-img convert -f raw -O qcow2 "$INPUT_IMAGE" "$OUTPUT_IMAGE" -echo 'Patching image (rootfs)...' -export LIBGUESTFS_BACKEND=direct -export LIBGUESTFS_DEBUG=0 -export LIBGUESTFS_TRACE=0 -guestfish -a "$OUTPUT_IMAGE" < /dev/tcp/"$HOST"/"$PORT") 2>/dev/null; then - RESULT=$(sshpass -p "$PASS" ssh $SSH_OPTS -p "$PORT" "${USER}@${HOST}" true 2>&1) - RC=$? - if [ "$RC" -eq 0 ]; then - echo "" - echo "SSH is ready (took ${ELAPSED}s)" - exit 0 - fi - if [ $(( ATTEMPT % 6 )) -eq 0 ]; then - echo "" - echo "[${ELAPSED}s] Port open, sshpass rc=$RC output: $RESULT" - echo "[${ELAPSED}s] Trying verbose SSH..." - sshpass -p "$PASS" ssh -v $SSH_OPTS -p "$PORT" "${USER}@${HOST}" true 2>&1 | tail -20 - else - printf "x" - fi - else - printf "." - fi - sleep 5 -done diff --git a/testing/tests/test_boot.sh b/testing/tests/test_boot.sh deleted file mode 100755 index ad4e624f..00000000 --- a/testing/tests/test_boot.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -set -e - -HOST="${1:-localhost}" -PORT="${2:-2222}" -USER="pi" -PASS="raspberry" - -SSH_CMD="sshpass -p $PASS ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o PubkeyAuthentication=no -o LogLevel=ERROR -p $PORT ${USER}@${HOST}" - -echo "Test: SSH login and run 'echo hello world'" - -OUTPUT=$($SSH_CMD 'echo hello world' 2>/dev/null) - -if [ "$OUTPUT" = "hello world" ]; then - echo " Output: '$OUTPUT'" - echo " PASS: Got expected output" - exit 0 -else - echo " Expected: 'hello world'" - echo " Got: '$OUTPUT'" - echo " FAIL: Unexpected output" - exit 1 -fi From 8ea7d14beb851ee1a36b32b062972ee925321614 Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Thu, 12 Mar 2026 14:05:12 +0200 Subject: [PATCH 07/13] Fix e2e CI: remove old puppeteer screenshot step and port forward The puppeteer-based screenshot was from the pre-framework approach and fails because the container stops the QEMU port forward after tests complete. Screenshots are now handled inside the container via the hooks/screenshot.sh mechanism. Also remove KEEP_ALIVE and increase the wait timeout to 25 minutes. --- .github/workflows/build.yml | 64 ++----------------------------------- 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db71e165..d7a1a5fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -104,86 +104,26 @@ jobs: mkdir -p artifacts IMG=$(find image/ -name '*.img' | head -1) docker run -d --name octopi-test \ - -p 9980:9980 \ -v "$PWD/artifacts:/output" \ -v "$(realpath $IMG):/input/image.img:ro" \ -e ARTIFACTS_DIR=/output \ -e DISTRO_NAME="OctoPi" \ - -e KEEP_ALIVE=true \ octopi-e2e-test - name: Wait for tests to complete run: | - for i in $(seq 1 180); do + for i in $(seq 1 300); do [ -f artifacts/exit-code ] && break sleep 5 done if [ ! -f artifacts/exit-code ]; then - echo "ERROR: Tests did not complete within 15 minutes" + echo "ERROR: Tests did not complete within 25 minutes" docker logs octopi-test 2>&1 | tail -80 exit 1 fi echo "Tests finished with exit code: $(cat artifacts/exit-code)" cat artifacts/test-results.txt 2>/dev/null || true - - name: Wait for OctoPrint to fully start - run: | - echo "Waiting for OctoPrint to finish startup..." - for i in $(seq 1 90); do - BODY=$(curl -4 -s --connect-timeout 5 http://127.0.0.1:9980 2>/dev/null || true) - if echo "$BODY" | grep -q "CONFIG_WIZARD"; then - echo "OctoPrint fully started (Setup Wizard ready)" - exit 0 - elif echo "$BODY" | grep -q "starting up"; then - printf "S" - else - printf "." - fi - sleep 5 - done - echo "" - echo "WARNING: OctoPrint may not be fully started yet, proceeding anyway" - - - name: Take OctoPrint screenshot - run: | - npm install puppeteer - node -e " - const puppeteer = require('puppeteer'); - (async () => { - const browser = await puppeteer.launch({ - headless: 'new', args: ['--no-sandbox'] - }); - const page = await browser.newPage(); - await page.setViewport({width: 1280, height: 900}); - - // Retry loading until OctoPrint finishes its startup phase - for (let attempt = 0; attempt < 30; attempt++) { - await page.goto('http://127.0.0.1:9980', { - waitUntil: 'networkidle2', timeout: 60000 - }); - const html = await page.content(); - if (html.includes('CONFIG_WIZARD') || html.includes('id=\"login\"')) break; - console.log('OctoPrint still starting up, retrying in 10s... (attempt ' + (attempt+1) + ')'); - await new Promise(r => setTimeout(r, 10000)); - } - - // Wait for the Setup Wizard dialog to appear - try { - await page.waitForSelector('#wizard_dialog', { visible: true, timeout: 120000 }); - } catch (e) { - console.log('Wizard did not appear, taking screenshot of current state'); - } - - // Dismiss notification popovers by clicking the wizard body - try { await page.click('#wizard_dialog .modal-body'); } catch(e) {} - await new Promise(r => setTimeout(r, 2000)); - - await page.screenshot({path: 'artifacts/octoprint-screenshot.png'}); - console.log('Screenshot captured'); - await browser.close(); - })(); - " - - name: Collect logs and stop container if: always() run: | From 45480fad5b6b7d53f5f8d36021ab1345e0668dbd Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Wed, 25 Mar 2026 11:31:04 +0200 Subject: [PATCH 08/13] Fix e2e: wait for CONFIG_WIZARD before capturing OctoPrint artifacts The test and screenshot hook both matched "OctoPrint" in the starting page title instead of waiting for the actual wizard. Now both poll specifically for CONFIG_WIZARD in the response. The test saves HTML from the matching response (not a second curl). Add chromium to the Docker container for headless screenshots. Increase poll timeout to 600s for slower CI runners. --- testing/Dockerfile | 2 +- testing/hooks/screenshot.sh | 28 +++++++++++++++++++-------- testing/tests/test_octoprint_web.sh | 30 ++++++++++++++--------------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/testing/Dockerfile b/testing/Dockerfile index ab56a399..d71c2cd3 100644 --- a/testing/Dockerfile +++ b/testing/Dockerfile @@ -3,7 +3,7 @@ FROM ptrsr/pi-ci:latest ENV LIBGUESTFS_BACKEND=direct RUN apt-get update && apt-get install -y --no-install-recommends \ - sshpass openssh-client curl socat imagemagick \ + sshpass openssh-client curl socat imagemagick chromium \ && rm -rf /var/lib/apt/lists/* # Shared framework from CustomPiOS (copied into build context by CI) diff --git a/testing/hooks/screenshot.sh b/testing/hooks/screenshot.sh index b0d7c8d0..fb3e7b40 100755 --- a/testing/hooks/screenshot.sh +++ b/testing/hooks/screenshot.sh @@ -6,23 +6,35 @@ ARTIFACTS_DIR="${3:-/output}" SSH_CMD="sshpass -p raspberry ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o PubkeyAuthentication=no -o LogLevel=ERROR -p $SSH_PORT ${SSH_HOST}" -echo "Capturing OctoPrint web UI artifacts..." +echo "Waiting for OctoPrint wizard page before capturing..." -# Save rendered HTML via curl through SSH (inside the guest, hitting localhost:80) -BODY=$(${SSH_CMD} -l pi "curl -s http://localhost" 2>/dev/null || echo "") +WIZARD_READY=0 +for i in $(seq 1 24); do + BODY=$(${SSH_CMD} -l pi "curl -s http://localhost" 2>/dev/null || echo "") + if echo "$BODY" | grep -q "CONFIG_WIZARD"; then + WIZARD_READY=1 + echo "$BODY" > "$ARTIFACTS_DIR/octoprint-ui.html" + echo " Saved OctoPrint wizard HTML to artifacts (after ${i}x5s)" + break + fi + printf "." + sleep 5 +done +echo "" -if [ -n "$BODY" ]; then - echo "$BODY" > "$ARTIFACTS_DIR/octoprint-ui.html" - echo " Saved OctoPrint HTML to artifacts" +if [ "$WIZARD_READY" -eq 0 ]; then + echo " WARNING: CONFIG_WIZARD not found after 120s, saving current page" + if [ -n "$BODY" ]; then + echo "$BODY" > "$ARTIFACTS_DIR/octoprint-ui.html" + fi fi -# Attempt headless screenshot from inside the container if a browser is available HTTP_PORT="${QEMU_HTTP_PORT:-8080}" for BROWSER in chromium chromium-browser google-chrome; do if command -v "$BROWSER" &>/dev/null; then echo " Using $BROWSER for headless screenshot..." "$BROWSER" --headless --disable-gpu --no-sandbox \ - --virtual-time-budget=10000 \ + --virtual-time-budget=15000 \ --screenshot="$ARTIFACTS_DIR/screenshot.png" \ --window-size=1280,720 \ "http://localhost:${HTTP_PORT}" 2>/dev/null || true diff --git a/testing/tests/test_octoprint_web.sh b/testing/tests/test_octoprint_web.sh index adcfa3d7..bc54fcc6 100755 --- a/testing/tests/test_octoprint_web.sh +++ b/testing/tests/test_octoprint_web.sh @@ -9,21 +9,21 @@ PASS="raspberry" SSH_CMD="sshpass -p $PASS ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o PubkeyAuthentication=no -o LogLevel=ERROR -p $PORT ${USER}@${HOST}" -echo "Test: OctoPrint web server is accessible and fully started" +echo "Test: OctoPrint web server is accessible with CONFIG_WIZARD" OCTOPRINT_READY=0 -for i in $(seq 1 60); do +for i in $(seq 1 120); do BODY=$($SSH_CMD "curl -s http://localhost" 2>/dev/null || echo "") HTTP_CODE=$($SSH_CMD "curl -s -o /dev/null -w '%{http_code}' http://localhost" 2>/dev/null || echo "000") if [ "$HTTP_CODE" = "200" ]; then - if echo "$BODY" | grep -q "starting up"; then - printf "S" - elif echo "$BODY" | grep -q "CONFIG_WIZARD\|OctoPrint"; then + if echo "$BODY" | grep -q "CONFIG_WIZARD"; then OCTOPRINT_READY=1 break + elif echo "$BODY" | grep -q "starting up\|still starting"; then + printf "S" else - printf "W" + printf "?" fi else printf "." @@ -33,23 +33,23 @@ done echo "" if [ "$OCTOPRINT_READY" -eq 0 ]; then - echo " FAIL: OctoPrint did not fully start within 300s" + echo " FAIL: OctoPrint CONFIG_WIZARD did not appear within 600s" + echo " Last HTTP code: $HTTP_CODE" + echo " Last body (first 200 chars): $(echo "$BODY" | head -c 200)" exit 1 fi -echo " OctoPrint web server is fully started (HTTP 200, UI loaded)" - -FULL_HTML=$($SSH_CMD "curl -s http://localhost" 2>/dev/null) +echo " OctoPrint CONFIG_WIZARD is loaded (HTTP 200)" if [ -n "$ARTIFACTS_DIR" ]; then - echo "$FULL_HTML" > "$ARTIFACTS_DIR/octoprint.html" - echo " Saved HTML to $ARTIFACTS_DIR/octoprint.html" + echo "$BODY" > "$ARTIFACTS_DIR/octoprint.html" + echo " Saved wizard HTML to $ARTIFACTS_DIR/octoprint.html" fi -if echo "$FULL_HTML" | grep -q "OctoPrint"; then - echo " PASS: OctoPrint web UI returned expected content" +if echo "$BODY" | grep -q "OctoPrint"; then + echo " PASS: OctoPrint wizard page returned expected content" exit 0 else - echo " FAIL: Response did not contain 'OctoPrint'" + echo " FAIL: Wizard page did not contain 'OctoPrint'" exit 1 fi From 68321d2d1ea768255e47ee84f362b9fbbc0b4e2e Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Wed, 25 Mar 2026 14:38:53 +0200 Subject: [PATCH 09/13] Fix e2e: show headless Chromium errors, add missing deps for screenshot The headless screenshot silently failed (2>/dev/null hid the error). Now show stderr, use --headless=new, --disable-dev-shm-usage, and add missing shared libraries (libnss3, libgbm1, etc.) needed for Chromium headless in the container environment. --- testing/Dockerfile | 4 +++- testing/hooks/screenshot.sh | 27 ++++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/testing/Dockerfile b/testing/Dockerfile index d71c2cd3..8d5a8837 100644 --- a/testing/Dockerfile +++ b/testing/Dockerfile @@ -3,7 +3,9 @@ FROM ptrsr/pi-ci:latest ENV LIBGUESTFS_BACKEND=direct RUN apt-get update && apt-get install -y --no-install-recommends \ - sshpass openssh-client curl socat imagemagick chromium \ + sshpass openssh-client curl socat imagemagick \ + chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libgbm1 \ + libasound2t64 libxcomposite1 libxdamage1 libxrandr2 fonts-liberation \ && rm -rf /var/lib/apt/lists/* # Shared framework from CustomPiOS (copied into build context by CI) diff --git a/testing/hooks/screenshot.sh b/testing/hooks/screenshot.sh index fb3e7b40..a0a36f11 100755 --- a/testing/hooks/screenshot.sh +++ b/testing/hooks/screenshot.sh @@ -30,19 +30,32 @@ if [ "$WIZARD_READY" -eq 0 ]; then fi HTTP_PORT="${QEMU_HTTP_PORT:-8080}" -for BROWSER in chromium chromium-browser google-chrome; do - if command -v "$BROWSER" &>/dev/null; then - echo " Using $BROWSER for headless screenshot..." - "$BROWSER" --headless --disable-gpu --no-sandbox \ + +echo " Checking forwarded port accessibility..." +curl -s -o /dev/null -w " Port $HTTP_PORT -> HTTP %{http_code}\n" "http://localhost:${HTTP_PORT}" || echo " Port $HTTP_PORT not reachable" + +for BROWSER in chromium chromium-browser; do + BROWSER_PATH=$(command -v "$BROWSER" 2>/dev/null || true) + if [ -n "$BROWSER_PATH" ]; then + echo " Taking headless screenshot with $BROWSER_PATH ..." + "$BROWSER_PATH" \ + --headless=new \ + --no-sandbox \ + --disable-gpu \ + --disable-software-rasterizer \ + --disable-dev-shm-usage \ --virtual-time-budget=15000 \ --screenshot="$ARTIFACTS_DIR/screenshot.png" \ --window-size=1280,720 \ - "http://localhost:${HTTP_PORT}" 2>/dev/null || true + "http://localhost:${HTTP_PORT}" 2>&1 | tail -5 || true if [ -f "$ARTIFACTS_DIR/screenshot.png" ]; then - echo " Browser screenshot saved" + echo " Screenshot saved to $ARTIFACTS_DIR/screenshot.png" + ls -la "$ARTIFACTS_DIR/screenshot.png" exit 0 + else + echo " Screenshot file was NOT created by $BROWSER" fi fi done -echo " No headless browser available in container (HTML artifact saved instead)" +echo " No screenshot produced (headless browser may have failed)" From c2cc14fa071bc9bebd09fa3a4f5de8d6c2564bb0 Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Wed, 25 Mar 2026 17:51:40 +0200 Subject: [PATCH 10/13] Fix e2e screenshot: skip snap stub, find real chromium binary The base image has /usr/bin/chromium-browser as a snap wrapper stub that prints "requires the chromium snap". Now search for the actual chromium binary at /usr/lib/chromium/chromium first, skipping any snap stubs. Add diagnostic output if no working binary is found. --- testing/hooks/screenshot.sh | 52 +++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/testing/hooks/screenshot.sh b/testing/hooks/screenshot.sh index a0a36f11..e5d397c2 100755 --- a/testing/hooks/screenshot.sh +++ b/testing/hooks/screenshot.sh @@ -34,28 +34,36 @@ HTTP_PORT="${QEMU_HTTP_PORT:-8080}" echo " Checking forwarded port accessibility..." curl -s -o /dev/null -w " Port $HTTP_PORT -> HTTP %{http_code}\n" "http://localhost:${HTTP_PORT}" || echo " Port $HTTP_PORT not reachable" -for BROWSER in chromium chromium-browser; do - BROWSER_PATH=$(command -v "$BROWSER" 2>/dev/null || true) - if [ -n "$BROWSER_PATH" ]; then - echo " Taking headless screenshot with $BROWSER_PATH ..." - "$BROWSER_PATH" \ - --headless=new \ - --no-sandbox \ - --disable-gpu \ - --disable-software-rasterizer \ - --disable-dev-shm-usage \ - --virtual-time-budget=15000 \ - --screenshot="$ARTIFACTS_DIR/screenshot.png" \ - --window-size=1280,720 \ - "http://localhost:${HTTP_PORT}" 2>&1 | tail -5 || true - if [ -f "$ARTIFACTS_DIR/screenshot.png" ]; then - echo " Screenshot saved to $ARTIFACTS_DIR/screenshot.png" - ls -la "$ARTIFACTS_DIR/screenshot.png" - exit 0 - else - echo " Screenshot file was NOT created by $BROWSER" - fi +# Find real chromium binary (skip snap stubs that print "requires the chromium snap") +BROWSER_PATH="" +for candidate in /usr/lib/chromium/chromium /usr/bin/chromium /snap/bin/chromium; do + if [ -x "$candidate" ] && ! "$candidate" --version 2>&1 | grep -q "snap"; then + BROWSER_PATH="$candidate" + break fi done -echo " No screenshot produced (headless browser may have failed)" +if [ -n "$BROWSER_PATH" ]; then + echo " Taking headless screenshot with $BROWSER_PATH ..." + "$BROWSER_PATH" \ + --headless=new \ + --no-sandbox \ + --disable-gpu \ + --disable-software-rasterizer \ + --disable-dev-shm-usage \ + --virtual-time-budget=15000 \ + --screenshot="$ARTIFACTS_DIR/screenshot.png" \ + --window-size=1280,720 \ + "http://localhost:${HTTP_PORT}" 2>&1 | tail -10 || true + if [ -f "$ARTIFACTS_DIR/screenshot.png" ]; then + echo " Screenshot saved to $ARTIFACTS_DIR/screenshot.png" + ls -la "$ARTIFACTS_DIR/screenshot.png" + else + echo " Screenshot file was NOT created" + fi +else + echo " No working chromium binary found. Checking available:" + ls -la /usr/bin/chromium* /usr/lib/chromium/chromium 2>&1 || true + echo " dpkg -l chromium:" + dpkg -l chromium 2>&1 | tail -3 || true +fi From 0fb920045c0e40385077a491ea2ea90363a8508f Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Thu, 26 Mar 2026 23:52:21 +0200 Subject: [PATCH 11/13] Fix e2e screenshot: use google-chrome-stable, add wizard validation The Ubuntu-based ptrsr/pi-ci image only has a snap stub for chromium. Install google-chrome-stable from Google's apt repo instead, which provides a real headless browser binary. The test now takes a screenshot right when CONFIG_WIZARD is confirmed and validates the PNG is >10KB (a real rendered page, not blank). The screenshot hook also uses google-chrome-stable as a redundant capture. --- testing/Dockerfile | 7 +++--- testing/hooks/screenshot.sh | 33 ++++++----------------------- testing/tests/test_octoprint_web.sh | 29 +++++++++++++++++++++---- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/testing/Dockerfile b/testing/Dockerfile index 8d5a8837..f1815965 100644 --- a/testing/Dockerfile +++ b/testing/Dockerfile @@ -3,9 +3,10 @@ FROM ptrsr/pi-ci:latest ENV LIBGUESTFS_BACKEND=direct RUN apt-get update && apt-get install -y --no-install-recommends \ - sshpass openssh-client curl socat imagemagick \ - chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libgbm1 \ - libasound2t64 libxcomposite1 libxdamage1 libxrandr2 fonts-liberation \ + sshpass openssh-client curl socat imagemagick wget gnupg \ + && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \ + && apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \ && rm -rf /var/lib/apt/lists/* # Shared framework from CustomPiOS (copied into build context by CI) diff --git a/testing/hooks/screenshot.sh b/testing/hooks/screenshot.sh index e5d397c2..10c23206 100755 --- a/testing/hooks/screenshot.sh +++ b/testing/hooks/screenshot.sh @@ -30,40 +30,21 @@ if [ "$WIZARD_READY" -eq 0 ]; then fi HTTP_PORT="${QEMU_HTTP_PORT:-8080}" - -echo " Checking forwarded port accessibility..." -curl -s -o /dev/null -w " Port $HTTP_PORT -> HTTP %{http_code}\n" "http://localhost:${HTTP_PORT}" || echo " Port $HTTP_PORT not reachable" - -# Find real chromium binary (skip snap stubs that print "requires the chromium snap") -BROWSER_PATH="" -for candidate in /usr/lib/chromium/chromium /usr/bin/chromium /snap/bin/chromium; do - if [ -x "$candidate" ] && ! "$candidate" --version 2>&1 | grep -q "snap"; then - BROWSER_PATH="$candidate" - break - fi -done +BROWSER_PATH=$(command -v google-chrome-stable 2>/dev/null || true) if [ -n "$BROWSER_PATH" ]; then echo " Taking headless screenshot with $BROWSER_PATH ..." - "$BROWSER_PATH" \ - --headless=new \ - --no-sandbox \ - --disable-gpu \ - --disable-software-rasterizer \ - --disable-dev-shm-usage \ - --virtual-time-budget=15000 \ + "$BROWSER_PATH" --headless=new --no-sandbox --disable-gpu \ + --disable-dev-shm-usage --virtual-time-budget=15000 \ --screenshot="$ARTIFACTS_DIR/screenshot.png" \ --window-size=1280,720 \ - "http://localhost:${HTTP_PORT}" 2>&1 | tail -10 || true + "http://localhost:${HTTP_PORT}" 2>&1 | tail -5 || true if [ -f "$ARTIFACTS_DIR/screenshot.png" ]; then - echo " Screenshot saved to $ARTIFACTS_DIR/screenshot.png" - ls -la "$ARTIFACTS_DIR/screenshot.png" + SIZE=$(stat -c%s "$ARTIFACTS_DIR/screenshot.png" 2>/dev/null || echo "0") + echo " Screenshot saved (${SIZE} bytes)" else echo " Screenshot file was NOT created" fi else - echo " No working chromium binary found. Checking available:" - ls -la /usr/bin/chromium* /usr/lib/chromium/chromium 2>&1 || true - echo " dpkg -l chromium:" - dpkg -l chromium 2>&1 | tail -3 || true + echo " No google-chrome-stable found in container" fi diff --git a/testing/tests/test_octoprint_web.sh b/testing/tests/test_octoprint_web.sh index bc54fcc6..b9435847 100755 --- a/testing/tests/test_octoprint_web.sh +++ b/testing/tests/test_octoprint_web.sh @@ -46,10 +46,31 @@ if [ -n "$ARTIFACTS_DIR" ]; then echo " Saved wizard HTML to $ARTIFACTS_DIR/octoprint.html" fi -if echo "$BODY" | grep -q "OctoPrint"; then - echo " PASS: OctoPrint wizard page returned expected content" - exit 0 -else +if ! echo "$BODY" | grep -q "OctoPrint"; then echo " FAIL: Wizard page did not contain 'OctoPrint'" exit 1 fi + +HTTP_PORT="${QEMU_HTTP_PORT:-8080}" +BROWSER=$(command -v google-chrome-stable 2>/dev/null || true) +if [ -n "$BROWSER" ] && [ -n "$ARTIFACTS_DIR" ]; then + echo " Taking wizard screenshot via headless Chrome..." + "$BROWSER" --headless=new --no-sandbox --disable-gpu \ + --disable-dev-shm-usage --virtual-time-budget=15000 \ + --screenshot="$ARTIFACTS_DIR/screenshot.png" \ + --window-size=1280,720 \ + "http://localhost:${HTTP_PORT}" 2>/dev/null || true + if [ -f "$ARTIFACTS_DIR/screenshot.png" ]; then + SIZE=$(stat -c%s "$ARTIFACTS_DIR/screenshot.png" 2>/dev/null || echo "0") + if [ "$SIZE" -gt 10000 ]; then + echo " Screenshot saved (${SIZE} bytes) -- wizard page captured" + else + echo " WARNING: Screenshot is only ${SIZE} bytes (may be blank)" + fi + else + echo " WARNING: Screenshot was not created" + fi +fi + +echo " PASS: OctoPrint wizard page verified" +exit 0 From 91c0b83d8d2878c7e69c386433bc8605121a5215 Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Mon, 30 Mar 2026 16:34:24 +0300 Subject: [PATCH 12/13] Fix e2e timeout: add 30s kill timer to Chrome, increase wait to 35min Chrome headless can hang indefinitely if the page doesn't load. Wrap all Chrome calls with `timeout 30` to kill after 30 seconds. Increase the workflow wait loop from 25 to 35 min and job timeout from 30 to 45 min, since OctoPrint can take 10+ min to start on slower CI runners. --- .github/workflows/build.yml | 6 +++--- testing/hooks/screenshot.sh | 2 +- testing/tests/test_octoprint_web.sh | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d7a1a5fe..51d9ea68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,7 +73,7 @@ jobs: e2e-test: needs: build runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 @@ -112,12 +112,12 @@ jobs: - name: Wait for tests to complete run: | - for i in $(seq 1 300); do + for i in $(seq 1 420); do [ -f artifacts/exit-code ] && break sleep 5 done if [ ! -f artifacts/exit-code ]; then - echo "ERROR: Tests did not complete within 25 minutes" + echo "ERROR: Tests did not complete within 35 minutes" docker logs octopi-test 2>&1 | tail -80 exit 1 fi diff --git a/testing/hooks/screenshot.sh b/testing/hooks/screenshot.sh index 10c23206..e0dd2247 100755 --- a/testing/hooks/screenshot.sh +++ b/testing/hooks/screenshot.sh @@ -34,7 +34,7 @@ BROWSER_PATH=$(command -v google-chrome-stable 2>/dev/null || true) if [ -n "$BROWSER_PATH" ]; then echo " Taking headless screenshot with $BROWSER_PATH ..." - "$BROWSER_PATH" --headless=new --no-sandbox --disable-gpu \ + timeout 30 "$BROWSER_PATH" --headless=new --no-sandbox --disable-gpu \ --disable-dev-shm-usage --virtual-time-budget=15000 \ --screenshot="$ARTIFACTS_DIR/screenshot.png" \ --window-size=1280,720 \ diff --git a/testing/tests/test_octoprint_web.sh b/testing/tests/test_octoprint_web.sh index b9435847..7fec1a26 100755 --- a/testing/tests/test_octoprint_web.sh +++ b/testing/tests/test_octoprint_web.sh @@ -55,7 +55,7 @@ HTTP_PORT="${QEMU_HTTP_PORT:-8080}" BROWSER=$(command -v google-chrome-stable 2>/dev/null || true) if [ -n "$BROWSER" ] && [ -n "$ARTIFACTS_DIR" ]; then echo " Taking wizard screenshot via headless Chrome..." - "$BROWSER" --headless=new --no-sandbox --disable-gpu \ + timeout 30 "$BROWSER" --headless=new --no-sandbox --disable-gpu \ --disable-dev-shm-usage --virtual-time-budget=15000 \ --screenshot="$ARTIFACTS_DIR/screenshot.png" \ --window-size=1280,720 \ From 4ce3dfdf0222bc0a5313fce487435f1aa5d7b98b Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Tue, 31 Mar 2026 22:19:35 +0300 Subject: [PATCH 13/13] Add chromium fallback browser for e2e screenshot capture Install chromium in the Docker container alongside google-chrome-stable. Update screenshot.sh to try google-chrome-stable, chromium, and chromium-browser in order, with screenshot size validation. --- testing/Dockerfile | 2 +- testing/hooks/screenshot.sh | 29 +++++++++++++++++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/testing/Dockerfile b/testing/Dockerfile index f1815965..aaaf9baf 100644 --- a/testing/Dockerfile +++ b/testing/Dockerfile @@ -3,7 +3,7 @@ FROM ptrsr/pi-ci:latest ENV LIBGUESTFS_BACKEND=direct RUN apt-get update && apt-get install -y --no-install-recommends \ - sshpass openssh-client curl socat imagemagick wget gnupg \ + sshpass openssh-client curl socat imagemagick wget gnupg chromium \ && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \ && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \ && apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \ diff --git a/testing/hooks/screenshot.sh b/testing/hooks/screenshot.sh index e0dd2247..ff93de83 100755 --- a/testing/hooks/screenshot.sh +++ b/testing/hooks/screenshot.sh @@ -30,21 +30,34 @@ if [ "$WIZARD_READY" -eq 0 ]; then fi HTTP_PORT="${QEMU_HTTP_PORT:-8080}" -BROWSER_PATH=$(command -v google-chrome-stable 2>/dev/null || true) +SCREENSHOT_TAKEN=0 + +for BROWSER in google-chrome-stable chromium chromium-browser; do + BROWSER_PATH=$(command -v "$BROWSER" 2>/dev/null || true) + [ -n "$BROWSER_PATH" ] || continue -if [ -n "$BROWSER_PATH" ]; then echo " Taking headless screenshot with $BROWSER_PATH ..." - timeout 30 "$BROWSER_PATH" --headless=new --no-sandbox --disable-gpu \ + timeout 30 "$BROWSER_PATH" --headless --no-sandbox --disable-gpu \ --disable-dev-shm-usage --virtual-time-budget=15000 \ --screenshot="$ARTIFACTS_DIR/screenshot.png" \ --window-size=1280,720 \ - "http://localhost:${HTTP_PORT}" 2>&1 | tail -5 || true + "http://localhost:${HTTP_PORT}" 2>/dev/null || true + if [ -f "$ARTIFACTS_DIR/screenshot.png" ]; then SIZE=$(stat -c%s "$ARTIFACTS_DIR/screenshot.png" 2>/dev/null || echo "0") - echo " Screenshot saved (${SIZE} bytes)" + if [ "$SIZE" -gt 10000 ]; then + echo " Screenshot saved (${SIZE} bytes) -- wizard page captured" + SCREENSHOT_TAKEN=1 + break + else + echo " WARNING: Screenshot is only ${SIZE} bytes (may be blank), trying next browser" + rm -f "$ARTIFACTS_DIR/screenshot.png" + fi else - echo " Screenshot file was NOT created" + echo " Screenshot file was NOT created by $BROWSER, trying next browser" fi -else - echo " No google-chrome-stable found in container" +done + +if [ "$SCREENSHOT_TAKEN" -eq 0 ]; then + echo " WARNING: No browser produced a valid screenshot" fi