diff --git a/debian/control.top.in b/debian/control.top.in index 1016d940fc9..8a11b559f73 100644 --- a/debian/control.top.in +++ b/debian/control.top.in @@ -48,6 +48,23 @@ Build-Depends: tk@TCLTK_VERSION@-dev, xvfb , x11-xserver-utils , + python3-opengl , + python3-pyqt5 , + python3-pyqt5.qsci , + python3-pyqt5.qtsvg , + python3-pyqt5.qtopengl , + python3-pyqt5.qtwebengine , + pyqt5-dev-tools , + python3-dbus , + python3-dbus.mainloop.pyqt5 , + python3-qtpy , + python3-cairo , + python3-gi , + python3-gi-cairo , + gir1.2-gtk-3.0 , + gir1.2-gtksource-4 , + python3-numpy , + python3-configobj , libfmt-dev, yapps2 Build-Depends-Indep: diff --git a/tests/ui-smoke/.gitignore b/tests/ui-smoke/.gitignore new file mode 100644 index 00000000000..7aada78178c --- /dev/null +++ b/tests/ui-smoke/.gitignore @@ -0,0 +1,9 @@ +# Runtime artifacts left by launch.sh; per Bertho's clean-tree rule, +# generated build/test outputs must be gitignored or committed. +linuxcnc.out +linuxcnc.err +linuxcnc.pid +ui-smoke.out +ui-smoke.err +result +stderr diff --git a/tests/ui-smoke/README b/tests/ui-smoke/README new file mode 100644 index 00000000000..eb9ea8ef0ff --- /dev/null +++ b/tests/ui-smoke/README @@ -0,0 +1,26 @@ +UI smoke tests +~~~~~~~~~~~~~~ + +These tests launch each GUI (axis, touchy, gmoccapy, qtdragon) under +xvfb-run against an existing sim config and verify the GUI starts and +the NML task is reachable. Phase 1 ("does it start"); functional +checks (load G-code, jog, MDI) belong in tests/ui-functional/. + +Each test directory contains: + test.sh launches the GUI under xvfb-run, runs drive.py + checkresult examines the captured output for crash markers + skip skips this test only when xvfb-run is not on the host + +Shared helpers live in _lib/: + drive.py common NML driver, prints UI_SMOKE_OK on success + launch.sh xvfb-run wrapper, signal escalation for shutdown + cleanup-runtime.sh belt-and-suspenders: kill stray daemons, ipcrm + shared memory, drop /tmp/linuxcnc.lock + checkresult.sh shared pass/fail predicate + skip-if-missing.sh shared skip predicate + +Skip vs fail policy: the only condition we skip on is xvfb-run absence +(rare local dev env). Python and gi typelib deps the GUIs need are +declared in debian/control under !nocheck so apt-get build-dep +installs them on CI; if they are missing the test should fail loudly +rather than silently skip, so missing deps surface during review. diff --git a/tests/ui-smoke/_lib/checkresult.sh b/tests/ui-smoke/_lib/checkresult.sh new file mode 100755 index 00000000000..baace8e10c1 --- /dev/null +++ b/tests/ui-smoke/_lib/checkresult.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Shared result check for UI smoke tests. +# +# Pass if the driver printed UI_SMOKE_OK and did not print UI_SMOKE_FAIL. +# The driver only emits UI_SMOKE_OK after a successful NML round-trip +# (linuxcnc task ready and stat.poll() still alive after settle), so +# this is sufficient evidence that the GUI booted. We do not grep for +# generic crash markers like "Segmentation fault" or "Traceback": +# - linuxcnc's own scripts/linuxcnc Cleanup may emit shutdown-side +# segfaults (Qt/GTK teardown races) that are out of scope for a +# startup smoke test; +# - launch.sh interleaves linuxcnc.{out,err} before ui-smoke.{out,err} +# so an "in-log ordering" check would still catch shutdown noise. +set -u +LOG="${1:-/dev/stdin}" +content=$(cat "$LOG") + +if grep -q '^UI_SMOKE_FAIL' <<< "$content"; then + echo "FAIL: driver reported UI_SMOKE_FAIL" >&2 + exit 1 +fi + +if ! grep -q '^UI_SMOKE_OK$' <<< "$content"; then + echo "FAIL: driver did not report UI_SMOKE_OK" >&2 + exit 1 +fi + +exit 0 diff --git a/tests/ui-smoke/_lib/cleanup-runtime.sh b/tests/ui-smoke/_lib/cleanup-runtime.sh new file mode 100755 index 00000000000..fa9d625c32b --- /dev/null +++ b/tests/ui-smoke/_lib/cleanup-runtime.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Reset linuxcnc-related runtime state so the next ui-smoke test starts +# from a clean environment. Used as both a pre-launch belt-and-braces +# cleanup and as a post-shutdown last-resort if scripts/linuxcnc's own +# SIGTERM trap could not reap everything in time. +# +# Single source of truth for the daemon list and shared memory keys +# (BsAtHome review, PR #3999) so they are not duplicated across +# launch.sh. + +set -u + +DAEMONS=(linuxcncsvr milltask halui rtapi_app) +SHM_KEYS=(0x48414c32 0x48484c34 0x00000064) + +for proc in "${DAEMONS[@]}"; do + pkill -KILL -x "$proc" 2>/dev/null || true +done + +rm -f /tmp/linuxcnc.lock +halrun -U 2>/dev/null || true + +for key in "${SHM_KEYS[@]}"; do + shmid=$(ipcs -m | awk -v k="$key" 'tolower($1)==k {print $2}') + [ -n "$shmid" ] && ipcrm -m "$shmid" 2>/dev/null || true +done + +exit 0 diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py new file mode 100755 index 00000000000..1ee90a42234 --- /dev/null +++ b/tests/ui-smoke/_lib/drive.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# Minimal UI smoke driver: confirm linuxcnc task came up and the GUI +# did not crash. The smoke layer answers Bertho's "does it start" +# question only; functional behaviour (home, run a file, verify +# position) belongs in tests/ui-functional/ (Phase 2). + +import linuxcnc +import sys +import time + +CONNECT_TIMEOUT_S = 60.0 +SETTLE_S = 3.0 + + +def connect_and_wait_ready(timeout): + """Wait until linuxcnc.stat().poll() returns without error and + reports a non-negative echo_serial_number. The NML status buffer + can be 'invalid err=3' for the first ~30s while linuxcncsvr is + still initialising; recreate the stat object on every iteration so + a stale invalid buffer does not stick after linuxcncsvr is ready.""" + deadline = time.monotonic() + timeout + last_err = None + while time.monotonic() < deadline: + try: + stat = linuxcnc.stat() + stat.poll() + if stat.echo_serial_number >= 0: + return linuxcnc.command(), stat + except linuxcnc.error as e: + last_err = e + time.sleep(0.5) + sys.stderr.write( + f"UI_SMOKE_FAIL: task not ready within {timeout}s " + f"(last NML error: {last_err})\n") + return None, None + + +def main(): + cmd, stat = connect_and_wait_ready(CONNECT_TIMEOUT_S) + if cmd is None: + return 1 + + # Give the GUI process enough time to finish constructing itself + # (load .ui files, compile resources.py if needed, etc.) and + # settle. If the GUI was going to crash on startup it has crashed + # by now. + time.sleep(SETTLE_S) + + # Re-check task is still alive; a GUI crash may have torn linuxcnc + # down via Cleanup. + try: + stat.poll() + except linuxcnc.error as e: + sys.stderr.write(f"UI_SMOKE_FAIL: task disappeared after GUI startup: {e}\n") + return 1 + + print("UI_SMOKE_OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh new file mode 100755 index 00000000000..3731e8f6c24 --- /dev/null +++ b/tests/ui-smoke/_lib/launch.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# Shared launcher for UI smoke tests. +# Usage: launch.sh +# +# Spawns linuxcnc -r under xvfb-run, then runs the common driver +# script against it via NML. Captures stdout/stderr to per-test files. +# +# Skip vs fail (BsAtHome / hdiethelm review, PR #3999): the only thing +# we skip on is xvfb-run absence, which can happen on a developer's +# minimal machine. CI is expected to have all required deps; if any +# python module the GUI needs is missing the test should fail loudly +# rather than silently skip, so missing deps surface during review. +# Per-GUI deps are declared in debian/control under !nocheck, so +# apt-get build-dep installs them on CI. + +set -u + +CONFIG_INI="$1" +TEST_DIR="${TEST_DIR:-$(cd "$(dirname "$0")" && pwd)}" +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v xvfb-run >/dev/null 2>&1; then + echo "SKIP: xvfb-run not installed" >&2 + exit 77 +fi + +cd "$TEST_DIR" || exit 1 +rm -f ui-smoke.out ui-smoke.err linuxcnc.pid + +# Pre-launch cleanup: a previous ui-smoke test in the same job may +# have left a daemon listening on the NML TCP port or HAL shared +# memory still attached. Run the shared cleanup once before we start. +bash "$LIB_DIR/cleanup-runtime.sh" + +# Launch linuxcnc inside xvfb-run. The outer timeout is a safety net +# so a wedged GUI cannot hang CI. +LINUXCNC_TIMEOUT=240 +DRIVER_TIMEOUT=90 + +# Force software OpenGL (Mesa llvmpipe). CI runners have no GPU and +# Qt/GL widgets segfault under hardware GL with no display. The Qt- +# specific knobs cover qtdragon's QtQuick + RHI paths. +export LIBGL_ALWAYS_SOFTWARE=1 +export GALLIUM_DRIVER=llvmpipe +export QT_QUICK_BACKEND=software +export QSG_RHI_BACKEND=software +export QT_OPENGL=software + +# touchy's filechooser reads $HOME/linuxcnc/nc_files at startup and +# crashes if the dir is missing (no try/except in filechooser.py). +# CI runners have a clean $HOME with no linuxcnc/ tree; create the +# minimum set of dirs the GUIs poke at on boot. +mkdir -p "$HOME/linuxcnc/nc_files" + +xvfb-run -a --server-args="-screen 0 1024x768x24" \ + timeout "$LINUXCNC_TIMEOUT" \ + bash -c " + # Run linuxcnc in its own process group so we can signal the + # whole group cleanly (linuxcnc forks task, motion, GUI, halrun). + setsid linuxcnc -r '$CONFIG_INI' >linuxcnc.out 2>linuxcnc.err & + LINUXCNC_PGID=\$! + echo \$LINUXCNC_PGID >linuxcnc.pid + + # The driver polls NML readiness itself (BsAtHome review: + # avoid real-clock waits where status polling will do). + timeout $DRIVER_TIMEOUT python3 '$LIB_DIR/drive.py' >ui-smoke.out 2>ui-smoke.err + DRIVE_RC=\$? + + # Clean shutdown: GUI-specific quit first (lets linuxcnc's + # own SIGTERM trap run Cleanup which unloads halrun and reaps + # shared memory). axis-remote works only for axis but is + # harmless otherwise. Then SIGTERM to the script PID so the + # trap runs in-process. Wait up to 60s for Cleanup to finish + # before falling back to SIGKILL + cleanup-runtime.sh. + if command -v axis-remote >/dev/null 2>&1; then + axis-remote --quit 2>/dev/null || true + fi + + kill -TERM \$LINUXCNC_PGID 2>/dev/null || true + for i in \$(seq 60); do + kill -0 \$LINUXCNC_PGID 2>/dev/null || break + sleep 1 + done + if kill -0 \$LINUXCNC_PGID 2>/dev/null; then + echo 'WARN: linuxcnc did not exit on SIGTERM, escalating to KILL' >&2 + kill -KILL -\$LINUXCNC_PGID 2>/dev/null || true + sleep 2 + bash '$LIB_DIR/cleanup-runtime.sh' + fi + + exit \$DRIVE_RC + " +RC=$? + +# Surface logs so checkresult and CI artifact upload can see them. +echo "=== linuxcnc.out ===" +[ -f linuxcnc.out ] && cat linuxcnc.out +echo "=== linuxcnc.err ===" +[ -f linuxcnc.err ] && cat linuxcnc.err +echo "=== ui-smoke.out ===" +[ -f ui-smoke.out ] && cat ui-smoke.out +echo "=== ui-smoke.err ===" +[ -f ui-smoke.err ] && cat ui-smoke.err + +exit "$RC" diff --git a/tests/ui-smoke/_lib/skip-if-missing.sh b/tests/ui-smoke/_lib/skip-if-missing.sh new file mode 100755 index 00000000000..77a0155efcc --- /dev/null +++ b/tests/ui-smoke/_lib/skip-if-missing.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Shared "skip" predicate for ui-smoke tests. +# runtests semantics: a `skip` script that returns non-zero causes the +# test to be skipped. Per-test skip files invoke this. +# +# We only skip on xvfb-run absence (rare local dev env). Python / +# typelib deps are declared in debian/control under !nocheck so CI +# always has them; missing deps should fail the test loudly rather +# than silently skip (BsAtHome / hdiethelm review, PR #3999). +set -u + +if ! command -v xvfb-run >/dev/null 2>&1; then + echo "skip: xvfb-run not installed" >&2 + exit 1 +fi + +exit 0 diff --git a/tests/ui-smoke/axis/checkresult b/tests/ui-smoke/axis/checkresult new file mode 100755 index 00000000000..bbe14f5f99f --- /dev/null +++ b/tests/ui-smoke/axis/checkresult @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/checkresult.sh" "$@" diff --git a/tests/ui-smoke/axis/skip b/tests/ui-smoke/axis/skip new file mode 100755 index 00000000000..c1c260edf05 --- /dev/null +++ b/tests/ui-smoke/axis/skip @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" diff --git a/tests/ui-smoke/axis/test.sh b/tests/ui-smoke/axis/test.sh new file mode 100755 index 00000000000..8f7385a5ecd --- /dev/null +++ b/tests/ui-smoke/axis/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ +exec "$(dirname "$0")/../_lib/launch.sh" \ + "$(cd "$(dirname "$0")"/../../../configs/sim/axis && pwd)/axis.ini" diff --git a/tests/ui-smoke/gmoccapy/checkresult b/tests/ui-smoke/gmoccapy/checkresult new file mode 100755 index 00000000000..bbe14f5f99f --- /dev/null +++ b/tests/ui-smoke/gmoccapy/checkresult @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/checkresult.sh" "$@" diff --git a/tests/ui-smoke/gmoccapy/skip b/tests/ui-smoke/gmoccapy/skip new file mode 100755 index 00000000000..c1c260edf05 --- /dev/null +++ b/tests/ui-smoke/gmoccapy/skip @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" diff --git a/tests/ui-smoke/gmoccapy/test.sh b/tests/ui-smoke/gmoccapy/test.sh new file mode 100755 index 00000000000..563fbc7824c --- /dev/null +++ b/tests/ui-smoke/gmoccapy/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ +exec "$(dirname "$0")/../_lib/launch.sh" \ + "$(cd "$(dirname "$0")"/../../../configs/sim/gmoccapy && pwd)/gmoccapy.ini" diff --git a/tests/ui-smoke/qtdragon/checkresult b/tests/ui-smoke/qtdragon/checkresult new file mode 100755 index 00000000000..bbe14f5f99f --- /dev/null +++ b/tests/ui-smoke/qtdragon/checkresult @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/checkresult.sh" "$@" diff --git a/tests/ui-smoke/qtdragon/skip b/tests/ui-smoke/qtdragon/skip new file mode 100755 index 00000000000..c1c260edf05 --- /dev/null +++ b/tests/ui-smoke/qtdragon/skip @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" diff --git a/tests/ui-smoke/qtdragon/test.sh b/tests/ui-smoke/qtdragon/test.sh new file mode 100755 index 00000000000..9b8aef1e56d --- /dev/null +++ b/tests/ui-smoke/qtdragon/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ +exec "$(dirname "$0")/../_lib/launch.sh" \ + "$(cd "$(dirname "$0")"/../../../configs/sim/qtdragon/qtdragon_xyz && pwd)/qtdragon_metric.ini" diff --git a/tests/ui-smoke/touchy/checkresult b/tests/ui-smoke/touchy/checkresult new file mode 100755 index 00000000000..bbe14f5f99f --- /dev/null +++ b/tests/ui-smoke/touchy/checkresult @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/checkresult.sh" "$@" diff --git a/tests/ui-smoke/touchy/skip b/tests/ui-smoke/touchy/skip new file mode 100755 index 00000000000..c1c260edf05 --- /dev/null +++ b/tests/ui-smoke/touchy/skip @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" diff --git a/tests/ui-smoke/touchy/test.sh b/tests/ui-smoke/touchy/test.sh new file mode 100755 index 00000000000..3b493a660c6 --- /dev/null +++ b/tests/ui-smoke/touchy/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ +exec "$(dirname "$0")/../_lib/launch.sh" \ + "$(cd "$(dirname "$0")"/../../../configs/sim/touchy && pwd)/touchy.ini"