@@ -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