ref:3457767f159021b7e3bb4722b57a6f34771ce0cf

ci: build, test, lint and run the QEMU end-to-end tests on Anvil

.anvil.yml runs scripts/ci/anvil-test.sh, which also runs locally: - build and unit-test with SUNSHINE_ENABLE_QEMU on and off in an Ubuntu 24.04 container (gcc-14, Node 22); any failed test fails the step, and only the three platform suites that need a capture method may fail their setup - lint: clang-format 23.1.1 on changed files and doxygen 1.18.0 with upstream's configuration (warnings fail) - packaging: unit, hook and helper tests with every tool required - e2e on an x86_64 host with KVM (image: bare): shm, qmp, audio, input (absolute and relative), reboot, and dmabuf, which reports SKIPPED without a DRM render node - requirements: anvil requirement status Also tests/e2e/qemu/desktop/serve_desktop.sh serves the desktop VM to a real Moonlight client, with persistent pairing and guest disk, and prints the web UI credentials and the WSL2 networking steps. Refs #9 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 3457767f159021b7e3bb4722b57a6f34771ce0cf
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-13 10:50
Parents: a8c9585
5 files changed +406 -2
Type
.anvil.yml +91 −0
@@ -1,0 +1,91 @@
# CI for fangorn/sunshine-qemu (#9): Sunshine with the QEMU D-Bus display backend.
#
# Every step calls scripts/ci/anvil-test.sh, so a local run of the same command matches CI.
#
# Containerized steps (Ubuntu 24.04, the distro upstream's linux_build.sh builds with gcc-14 on) build and unit-test
# Sunshine with the backend on and off (REQ-CMP-001), lint (clang-format and doxygen as upstream checks them) and run
# the packaging tests. Build and test share a step: the runner may run steps in different workspaces, so a later
# step can't count on an earlier step's build directory.
#
# The end-to-end step needs KVM, which containers don't get, so it runs directly on an x86_64 runner host
# (image: bare) with that host's QEMU. It needs gcc-14, Sunshine's build dependencies, dbus-daemon, and QEMU with
# the D-Bus display on the host; DMABUF is reported as skipped on hosts without a DRM render node.
image: ubuntu:24.04
prepare:
- apt-get update
- >-
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
ca-certificates curl git xz-utils python3 python3-venv
build-essential cmake ninja-build pkg-config gcc-14 g++-14
libcap-dev libcurl4-openssl-dev libdrm-dev libevdev-dev libgbm-dev libminiupnpc-dev libnuma-dev libopus-dev
libpipewire-0.3-dev libpulse-dev libssl-dev libsystemd-dev libudev-dev libwayland-dev
libx11-dev libxcb-shm0-dev libxcb-xfixes0-dev libxcb1-dev libxfixes-dev libxrandr-dev libxtst-dev
libvulkan-dev libva-dev glslang-tools python3-jinja2 qt6-base-dev qt6-svg-dev libgl-dev systemd-dev udev
libglib2.0-dev libglib2.0-dev-bin dbus-daemon xvfb xauth imagemagick
systemd shellcheck graphviz
# Node 22 for the web UI (the distro's Node 18 is too old, as linux_build.sh notes for 24.04)
- curl -fsSL https://nodejs.org/dist/v22.23.2/node-v22.23.2-linux-x64.tar.xz | tar -xJ -C /opt && ln -sf /opt/node-v22.23.2-linux-x64/bin/node /usr/local/bin/node && ln -sf /opt/node-v22.23.2-linux-x64/bin/npm /usr/local/bin/npm && ln -sf /opt/node-v22.23.2-linux-x64/bin/npx /usr/local/bin/npx
# the clang-format and doxygen versions upstream's lint and docs builds use
- python3 -m venv /opt/lint && /opt/lint/bin/pip install --quiet clang-format==23.1.1
- curl -fsSL https://www.doxygen.nl/files/doxygen-1.18.0.linux.bin.tar.gz | tar -xz -C /opt && ln -sf /opt/doxygen-1.18.0/bin/doxygen /usr/local/bin/doxygen
steps:
- name: build-and-unit-test-qemu-on
runs_on: [linux, amd64]
timeout_seconds: 7200
run: |
set -e
scripts/ci/anvil-test.sh submodules
scripts/ci/anvil-test.sh build on
scripts/ci/anvil-test.sh unit on
artifacts:
- name: unit-on
path: ci-artifacts/
- name: build-and-unit-test-qemu-off
runs_on: [linux, amd64]
timeout_seconds: 7200
run: |
set -e
scripts/ci/anvil-test.sh submodules
scripts/ci/anvil-test.sh build off
scripts/ci/anvil-test.sh unit off
artifacts:
- name: unit-off
path: ci-artifacts/
- name: lint
runs_on: [linux, amd64]
timeout_seconds: 2400
run: |
set -e
scripts/ci/anvil-test.sh submodules
CLANG_FORMAT=/opt/lint/bin/clang-format scripts/ci/anvil-test.sh lint origin/main
- name: packaging
runs_on: [linux, amd64]
timeout_seconds: 900
run: scripts/ci/anvil-test.sh packaging
- name: e2e
image: bare
runs_on: [linux, amd64]
timeout_seconds: 7200
depends_on: [build-and-unit-test-qemu-on]
run: |
set -e
scripts/ci/anvil-test.sh submodules
scripts/ci/anvil-test.sh build on Release
for mode in shm qmp audio input-absolute input-relative reset dmabuf; do
scripts/ci/anvil-test.sh e2e "$mode"
done
artifacts:
- name: e2e
path: ci-artifacts/
- name: requirements
runs_on: [linux, amd64]
timeout_seconds: 600
depends_on: [build-and-unit-test-qemu-on, build-and-unit-test-qemu-off, lint, packaging, e2e]
run: scripts/ci/anvil-test.sh requirements
.gitignore +3 −0
@@ -58,3 +58,6 @@
.venv/
venv/
# CI (scripts/ci/anvil-test.sh)
ci-artifacts/
scripts/ci/anvil-test.sh +197 −0
@@ -1,0 +1,197 @@
#!/usr/bin/env bash
# CI steps for fangorn/sunshine-qemu (.anvil.yml), runnable locally so a local run matches CI.
#
# Usage: scripts/ci/anvil-test.sh <command> [args]
#
# submodules initialize submodules (the runner doesn't)
# build <on|off> [type] configure and build cmake-build-ci-<on|off> (SUNSHINE_ENABLE_QEMU=ON/OFF, tests on;
# type Debug by default, Release builds only the sunshine binary without tests)
# unit <on|off> run test_sunshine; fails on any failed test. Only the platform test suites that
# need a display capture method may fail their setup (no GPU or display in CI)
# packaging the systemd unit, libvirt hook and helper script tests, with every tool required
# lint [base] clang-format on C/C++ files changed since base (default origin/main), doxygen
# e2e <mode> end-to-end stream against a real QEMU guest: shm (bus transport), qmp, audio,
# input-absolute, input-relative, reset, dmabuf (skipped without a DRM render node)
# requirements anvil requirement status for this repository
#
# Environment: CC/CXX (default gcc-14/g++-14), JOBS (default nproc), CI_ARTIFACTS (default ci-artifacts),
# CLANG_FORMAT, DOXYGEN, QEMU (for e2e; default qemu-system-x86_64).
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo}"
jobs="${JOBS:-$(nproc)}"
artifacts="${CI_ARTIFACTS:-${repo}/ci-artifacts}"
export CC="${CC:-gcc-14}" CXX="${CXX:-g++-14}"
# suites that call platf::init(), which needs a capture method (a display or GPU) the CI hosts don't have
known_platform_suites=("Configurations/AudioTest" "MouseInputs/MouseHIDTest" "EncoderVariants/EncoderTest")
section() {
echo
echo "=== $* ==="
}
cmd_submodules() {
git config --global --add safe.directory '*' 2> /dev/null || true
git submodule sync --recursive
git submodule update --init --recursive --jobs 8
}
cmd_build() {
local flag="${1:?build on|off}" type="${2:-Debug}"
local dir="cmake-build-ci-${flag}"
local enable=ON tests=ON target=all
[[ "${flag}" == off ]] && enable=OFF
if [[ "${type}" == Release ]]; then
dir="cmake-build-ci-${flag}-release"
tests=OFF
target=sunshine
fi
section "configure ${dir} (SUNSHINE_ENABLE_QEMU=${enable}, ${type})"
cmake -S . -B "${dir}" -G Ninja -DCMAKE_BUILD_TYPE="${type}" -DBUILD_TESTS="${tests}" -DBUILD_DOCS=OFF \
-DCUDA_FAIL_ON_MISSING=OFF -DSUNSHINE_ENABLE_QEMU="${enable}"
section "build ${dir}"
ninja -C "${dir}" -j "${jobs}" "${target}"
if [[ "${enable}" == ON && "${tests}" == ON ]]; then
grep -q "SUNSHINE_BUILD_QEMU" "${dir}/build.ninja" || { echo "SUNSHINE_ENABLE_QEMU=ON but the backend wasn't built" >&2; exit 1; }
fi
if [[ "${enable}" == OFF ]]; then
! grep -q "SUNSHINE_BUILD_QEMU" "${dir}/build.ninja" || { echo "SUNSHINE_ENABLE_QEMU=OFF but the backend was built" >&2; exit 1; }
fi
}
cmd_unit() {
local flag="${1:?unit on|off}"
local dir="cmake-build-ci-${flag}"
mkdir -p "${artifacts}"
local xml="${artifacts}/unit-${flag}.xml" log="${artifacts}/unit-${flag}.log"
section "test_sunshine (${dir})"
set +e
(cd "${dir}/tests" && timeout 3600 xvfb-run -a ./test_sunshine --gtest_brief=1 --gtest_output="xml:${xml}") > "${log}" 2>&1
local rc=$?
set -e
tail -n 40 "${log}"
python3 - "${xml}" "${log}" "${rc}" "${known_platform_suites[@]}" << 'EOF'
import re, sys
import xml.etree.ElementTree as ET
xml, log, rc, known = sys.argv[1], sys.argv[2], int(sys.argv[3]), set(sys.argv[4:])
try:
root = ET.parse(xml).getroot()
except (OSError, ET.ParseError) as e:
sys.exit(f"no test report ({e}); test_sunshine exit {rc}")
failed = [f"{s.get('name')}.{c.get('name')}" for s in root.iter('testsuite') for c in s.iter('testcase')
if c.find('failure') is not None or c.find('error') is not None]
tests = int(root.get('tests', 0))
text = open(log, errors='replace').read()
suites = set(re.findall(r'^\[ FAILED \] (\S+): SetUpTestSuite or TearDownTestSuite', text, re.M))
unexpected = sorted(suites - known)
print(f"tests: {tests}, failed tests: {len(failed)}, failed suite setups: {sorted(suites)}")
if tests == 0:
sys.exit("no tests ran")
if failed:
sys.exit("failed tests:\n " + "\n ".join(failed))
if unexpected:
sys.exit("unexpected suite setup failures: " + ", ".join(unexpected))
if rc not in (0, 1):
sys.exit(f"test_sunshine exited {rc}")
if rc == 1 and not suites:
sys.exit("test_sunshine exited 1 without a failed test or suite")
print("unit tests OK")
EOF
}
cmd_packaging() {
section "packaging tests"
SUNSHINE_QEMU_PKG_REQUIRE_TOOLS=1 bash tests/packaging/linux/qemu/test_sunshine_qemu.sh
}
cmd_lint() {
local base="${1:-origin/main}"
local clang_format="${CLANG_FORMAT:-clang-format}"
local doxygen="${DOXYGEN:-doxygen}"
section "clang-format ($("${clang_format}" --version | head -1)) on files changed since ${base}"
git fetch --quiet origin main 2> /dev/null || true
mapfile -t files < <(git diff --name-only --diff-filter=ACMR "${base}"...HEAD -- '*.c' '*.cpp' '*.h' '*.hpp' '*.m' '*.mm' | grep -v '^third-party/' || true)
if ((${#files[@]})); then
"${clang_format}" --dry-run --Werror "${files[@]}"
echo "${#files[@]} file(s) formatted"
else
echo "no C/C++ changes"
fi
section "doxygen ($("${doxygen}" --version)), upstream configuration, warnings fail"
local out="${artifacts}/doxygen"
rm -rf "${out}"
mkdir -p "${out}/html"
cp third-party/doxyconfig/doxyconfig-header.html third-party/doxyconfig/doxyconfig-Doxyfile \
third-party/doxyconfig/doxyconfig-icons.js third-party/doxyconfig/doxyconfig.css docs/
cat docs/Doxyfile >> docs/doxyconfig-Doxyfile
cp sunshine.svg sunshine.ico "${out}/"
(cd docs && READTHEDOCS_OUTPUT="${out}" READTHEDOCS_VERSION=0.0.0 DOXYCONFIG_THREADS=0 "${doxygen}" doxyconfig-Doxyfile > "${out}/doxygen.log" 2>&1) || {
grep -E "warning:|error:" "${out}/doxygen.log" | head -50
echo "doxygen failed" >&2
exit 1
}
echo "doxygen OK"
}
cmd_e2e() {
local mode="${1:?e2e <mode>}"
local sunshine="${SUNSHINE_BIN:-${repo}/cmake-build-ci-on-release/sunshine}"
export SUNSHINE_BIN="${sunshine}"
export E2E_ARTIFACTS="${artifacts}/e2e-${mode}"
mkdir -p "${E2E_ARTIFACTS}"
if [[ ! -x cmake-build-e2e-client/moonlight_e2e_client ]]; then
section "build the E2E client"
cmake -S tests/e2e/moonlight_client -B cmake-build-e2e-client -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
ninja -C cmake-build-e2e-client
fi
export CLIENT_BIN="${repo}/cmake-build-e2e-client/moonlight_e2e_client"
if [[ ! -r /dev/kvm || ! -w /dev/kvm ]]; then
echo "no usable /dev/kvm: the guest runs under TCG with longer timeouts"
export E2E_TIMEOUT="${E2E_TIMEOUT:-300}"
fi
section "e2e ${mode} (QEMU: $("${QEMU:-qemu-system-x86_64}" --version | head -1))"
case "${mode}" in
shm) tests/e2e/qemu/e2e_stream.sh ;;
qmp) E2E_TRANSPORT=qmp tests/e2e/qemu/e2e_stream.sh ;;
audio) E2E_AUDIO=1 tests/e2e/qemu/e2e_stream.sh ;;
input-absolute) E2E_INPUT=absolute tests/e2e/qemu/e2e_stream.sh ;;
input-relative) E2E_INPUT=relative tests/e2e/qemu/e2e_stream.sh ;;
reset) E2E_RESET_AFTER_FRAMES=60 tests/e2e/qemu/e2e_stream.sh ;;
dmabuf)
if ! compgen -G "/dev/dri/renderD*" > /dev/null; then
echo "SKIPPED: e2e dmabuf needs a DRM render node (/dev/dri/renderD*); this runner has none (REQ-CAP-003 not checked)"
return 0
fi
E2E_GL=1 tests/e2e/qemu/e2e_stream.sh
;;
*)
echo "unknown e2e mode ${mode}" >&2
exit 2
;;
esac
}
cmd_requirements() {
section "requirement traceability"
local anvil="${ANVIL_BIN:-anvil}"
if ! command -v "${anvil}" > /dev/null; then
mkdir -p "${HOME}/.local/bin"
curl -fsSL "${ANVIL_SERVER_URL:-https://anvil.fangorn.io}/runner/download?os=$(uname -s)&arch=$(uname -m)" -o "${HOME}/.local/bin/anvil"
chmod +x "${HOME}/.local/bin/anvil"
anvil="${HOME}/.local/bin/anvil"
fi
"${anvil}" requirement status --repo fangorn/sunshine-qemu
}
command="${1:-}"
shift || true
case "${command}" in
submodules | build | unit | packaging | lint | e2e | requirements) "cmd_${command}" "$@" ;;
*)
sed -n '2,20p' "${BASH_SOURCE[0]}"
exit 2
;;
esac
tests/e2e/qemu/desktop/run_desktop.sh +3 −2
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Boot the desktop guest with its display and audio on a private D-Bus bus.
# Usage: run_desktop.sh <work-dir> [width] [height] [extra qemu args...]
# Environment: QEMU, DESKTOP_CACHE, SSH_PORT, SNAPSHOT=off to write to the base image (maintenance only).
# Environment: QEMU, DESKTOP_CACHE, DESKTOP_IMAGE (disk to boot, default <cache>/desktop-base.qcow2), SSH_PORT,
# SNAPSHOT=off to keep the guest's changes on that disk.
# Writes <work>/bus.sock, dbus.pid, qemu.pid, qmp.sock, serial.log, ssh.port; prints the bus address when the desktop is up.
set -euo pipefail
@@ -22,7 +23,7 @@
for _ in $(seq 100); do [[ -S "$work/bus.sock" ]] && break; sleep 0.05; done
"$Q" -name sq-desktop -accel kvm -cpu host -smp 4 -m 4G -nodefaults \
-drive file="$D/desktop-base.qcow2",if=virtio,format=qcow2,snapshot=${SNAPSHOT:-on} \
-drive file="${DESKTOP_IMAGE:-$D/desktop-base.qcow2}",if=virtio,format=qcow2,snapshot=${SNAPSHOT:-on} \
-device virtio-vga,xres="$width",yres="$height",edid=on \
-display dbus,addr="$bus",audiodev=snd0 \
-audiodev dbus,id=snd0 \
tests/e2e/qemu/desktop/serve_desktop.sh +112 −0
@@ -1,0 +1,112 @@
#!/usr/bin/env bash
# Serve the desktop VM (build_desktop.sh) through Sunshine for a manual test with a real Moonlight client.
#
# Boots the desktop with its display and audio on a private bus, starts Sunshine with capture = qemu on the standard
# GameStream ports, and keeps running until Ctrl+C. Pairing and settings persist in SERVE_DIR between runs.
#
# Environment:
# SUNSHINE_BIN Sunshine binary (default: <repo>/cmake-build-release-cuda/sunshine)
# QEMU QEMU binary (default: qemu-system-x86_64; QEMU 10+ shares the framebuffer)
# DESKTOP_CACHE desktop image location (default: ~/.cache/sunshine-qemu/desktop)
# SERVE_DIR Sunshine state, logs and the web UI password (default: ~/.local/state/sunshine-qemu/desktop)
# GUEST_WIDTH/GUEST_HEIGHT guest resolution (default: 1920x1080)
# ENCODER nvenc, vaapi, vulkan or software (default: nvenc)
# PORT Sunshine base port (default: 47989, what Moonlight expects)
# SERVE_EXIT_AFTER seconds to run before shutting down, for automated checks (default: run until Ctrl+C)
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${script_dir}/../../../.." && pwd)"
sunshine_bin="${SUNSHINE_BIN:-${repo}/cmake-build-release-cuda/sunshine}"
desktop_cache="${DESKTOP_CACHE:-${HOME}/.cache/sunshine-qemu/desktop}"
serve_dir="${SERVE_DIR:-${HOME}/.local/state/sunshine-qemu/desktop}"
port="${PORT:-47989}"
[[ -x "${sunshine_bin}" ]] || { echo "missing ${sunshine_bin}; build Sunshine first" >&2; exit 2; }
[[ -f "${desktop_cache}/desktop-base.qcow2" ]] || { echo "missing the desktop image; run ${script_dir}/build_desktop.sh" >&2; exit 2; }
mkdir -p "${serve_dir}"
chmod 700 "${serve_dir}"
work="$(mktemp -d "${TMPDIR:-/tmp}/sunshine-serve.XXXXXX")"
sunshine_pid=""
cleanup() {
set +e
echo
echo "stopping"
[[ -n "${sunshine_pid}" ]] && kill "${sunshine_pid}" 2>/dev/null && wait "${sunshine_pid}" 2>/dev/null
for pidfile in "${work}/vm/qemu.pid" "${work}/vm/dbus.pid"; do
[[ -f "${pidfile}" ]] && kill "$(cat "${pidfile}")" 2>/dev/null
done
rm -rf "${work}"
}
trap cleanup EXIT INT TERM
# the guest's changes persist in an overlay next to Sunshine's state, so you can install and configure things
overlay="${serve_dir}/desktop.qcow2"
if [[ ! -f "${overlay}" ]]; then
qemu-img create -q -f qcow2 -F qcow2 -b "${desktop_cache}/desktop-base.qcow2" "${overlay}"
fi
echo "booting the desktop VM (${GUEST_WIDTH:-1920}x${GUEST_HEIGHT:-1080})..."
bus_address="$(SNAPSHOT=off DESKTOP_IMAGE="${overlay}" "${script_dir}/run_desktop.sh" "${work}/vm" "${GUEST_WIDTH:-1920}" "${GUEST_HEIGHT:-1080}")"
cat > "${serve_dir}/sunshine.conf" <<EOF
capture = qemu
qemu_dbus_address = ${bus_address}
encoder = ${ENCODER:-nvenc}
port = ${port}
sunshine_name = sunshine-qemu-desktop
file_state = ${serve_dir}/sunshine_state.json
credentials_file = ${serve_dir}/sunshine_credentials.json
pkey = ${serve_dir}/sunshine.key
cert = ${serve_dir}/sunshine.crt
file_apps = ${serve_dir}/apps.json
log_path = ${serve_dir}/sunshine.log
upnp = disabled
system_tray = disabled
origin_web_ui_allowed = lan
EOF
[[ -f "${serve_dir}/apps.json" ]] || echo '{"env": {}, "apps": [{"name": "Desktop", "image-path": "desktop.png"}]}' > "${serve_dir}/apps.json"
if [[ ! -f "${serve_dir}/web-ui-password" ]]; then
head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n' > "${serve_dir}/web-ui-password"
chmod 600 "${serve_dir}/web-ui-password"
"${sunshine_bin}" "${serve_dir}/sunshine.conf" --creds sunshine "$(cat "${serve_dir}/web-ui-password")" > "${serve_dir}/creds.log" 2>&1
fi
"${sunshine_bin}" "${serve_dir}/sunshine.conf" > "${serve_dir}/sunshine.stdout" 2>&1 &
sunshine_pid=$!
for _ in $(seq 1 300); do
kill -0 "${sunshine_pid}" 2>/dev/null || { echo "Sunshine exited; see ${serve_dir}/sunshine.log" >&2; exit 1; }
curl -s --max-time 1 "http://127.0.0.1:${port}/serverinfo" | grep -q 'status_code="200"' && break
sleep 0.2
done
address="$(hostname -I 2>/dev/null | awk '{print $1}')"
cat <<EOF
Sunshine is streaming the desktop VM.
Moonlight: add the host ${address:-<IP of this machine>} (port ${port}), then pair
Web UI: https://${address:-localhost}:$((port + 1)) user: sunshine password: $(cat "${serve_dir}/web-ui-password")
enter the PIN Moonlight shows under PIN in the web UI
Guest: user sq, password sunshine; ssh -p $(cat "${work}/vm/ssh.port") -i ${desktop_cache}/ssh_key sq@127.0.0.1
Logs: ${serve_dir}/sunshine.log
EOF
if grep -qi microsoft /proc/version 2>/dev/null; then
cat <<'EOF'
This is WSL2: other devices can't reach it through WSL's default NAT. Either set networkingMode=mirrored under
[wsl2] in %UserProfile%\.wslconfig and run `wsl --shutdown`, or forward TCP 47984, 47989, 47990, 48010 and
UDP 47998-48000, 48002, 48010 from Windows to this WSL address with netsh/New-NetFirewallRule.
EOF
fi
if [[ -n "${SERVE_EXIT_AFTER:-}" ]]; then
sleep "${SERVE_EXIT_AFTER}"
else
echo "Ctrl+C to stop"
wait "${sunshine_pid}"
fi