Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ jobs:
run: |
set -x
scripts/rip-environment runtests -p
- name: Upload UI smoke screenshots
if: always()
uses: actions/upload-artifact@v7
with:
name: ui-smoke-screenshots-gcc
path: |
tests/ui-smoke/**/screenshot.png
tests/ui-smoke/**/confirm.png
if-no-files-found: ignore
retention-days: 14
- name: Verify no untracked or modified files after test
run: |
.github/scripts/verify-clean-repo.sh
Expand Down Expand Up @@ -112,6 +122,16 @@ jobs:
run: |
set -x
scripts/rip-environment runtests -p
- name: Upload UI smoke screenshots
if: always()
uses: actions/upload-artifact@v7
with:
name: ui-smoke-screenshots-clang
path: |
tests/ui-smoke/**/screenshot.png
tests/ui-smoke/**/confirm.png
if-no-files-found: ignore
retention-days: 14
- name: Verify no untracked or modified files after test
run: |
.github/scripts/verify-clean-repo.sh
Expand Down
1 change: 1 addition & 0 deletions debian/control.top.in
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Build-Depends:
tk@TCLTK_VERSION@-dev,
xvfb <!nocheck>,
x11-xserver-utils <!nocheck>,
imagemagick <!nocheck>,
python3-opengl <!nocheck>,
python3-pyqt5 <!nocheck>,
python3-pyqt5.qsci <!nocheck>,
Expand Down
3 changes: 3 additions & 0 deletions tests/ui-smoke/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ linuxcnc.err
linuxcnc.pid
ui-smoke.out
ui-smoke.err
screenshot.png
confirm.png
ui-smoke-qt.png
11 changes: 11 additions & 0 deletions tests/ui-smoke/README
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ Shared helpers live in _lib/:
checkresult.sh shared pass/fail predicate
skip-if-missing.sh shared skip predicate

Failure diagnostics (failure path only, no cost on a green run):
crashdump.sh arms a core dump and prints a native backtrace if a GUI
segfaults (the Qt/dbus/GL frames PYTHONFAULTHANDLER misses)
screenshot.sh photographs the Xvfb root window before teardown. On a
failure it captures the cause (a GUI hung on a blocking
modal leaves no core and no traceback); on a clean Phase 2
run it captures confirm.png, the GUI in its post-movement
idle state (final DRO / toolpath) for visual confirmation.
CI uploads both as build artifacts (ui-smoke-screenshots-*
in ci.yml).

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
Expand Down
12 changes: 12 additions & 0 deletions tests/ui-smoke/_lib/drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
# stability check catches that.
STATE_STABILITY_S = 0.5
STATE_RETRY_BUDGET = 6
# Pause after homing before requesting AUTO. gmoccapy only enables AUTO
# once it has processed the all-homed signal in its own event loop (and
# re-asserts MANUAL itself on that signal). Requesting AUTO before then is
# rejected: it bounces back to MANUAL with an "It is not possible to
# change to Auto Mode" warning. ensure_mode would retry and win, but the
# warning lingers on screen; this settle lets the GUI catch up first.
POST_HOME_SETTLE_S = 2.0

# linuxcnc launcher PID, written to linuxcnc.pid by the launcher and read
# once at startup. The driver watches it so a GUI crash, which tears
Expand Down Expand Up @@ -297,6 +304,11 @@ def run_program(cmd, stat, ngc_path, expect_delta_mm, tol, run_timeout):
if not home_all(cmd, stat, timeout=60.0):
return False

# Let the GUI react to the all-homed transition before requesting AUTO,
# so it does not reject the mode change (see POST_HOME_SETTLE_S).
time.sleep(POST_HOME_SETTLE_S)
stat.poll()

if not ensure_mode(cmd, stat, linuxcnc.MODE_AUTO, "MODE_AUTO"):
return False

Expand Down
39 changes: 39 additions & 0 deletions tests/ui-smoke/_lib/gmoccapy-prepare.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/bash
# Sourced by the gmoccapy ui-smoke tests (smoke and quit) to run gmoccapy
# against a writable copy of its sim config. Sets GMOCCAPY_INI to the
# mirrored ini path; the caller then execs run-gui.sh or quit-launch.sh
# with "$GMOCCAPY_INI". Must be sourced with LIB_DIR already set.
#
# gmoccapy writes its preferences file next to the config: with no
# PREFERENCE_FILE_PATH in the ini, getiniinfo falls back to
# <config-dir>/<MACHINE>.pref. CI mounts the workspace read-only for the
# runtime user, so that write raises PermissionError partway through
# __init__ (during _get_pref_data, before the MDIHistory widget's
# _hal_init runs). gmoccapy pops an error dialog and limps on in a
# half-initialised state: the interp-idle handler then hits a widget with
# no .stat and throws a second dialog. Both vanish once the config dir is
# writable. Mirror it to tmp, same fix qtdragon-prepare.sh uses.

: "${LIB_DIR:?gmoccapy-prepare.sh must be sourced with LIB_DIR set}"

SRC_DIR="$(cd "$LIB_DIR/../../../configs/sim/gmoccapy" && pwd)"

WORK_DIR="$(mktemp -d -t ui-smoke-gmoccapy.XXXXXX)"
trap 'rm -rf "$WORK_DIR"' EXIT
cp -r "$SRC_DIR/." "$WORK_DIR/"

# Seed the preference file (config dir + <MACHINE>.pref; MACHINE=gmoccapy)
# so the first-run "Important change(s)" modal stays hidden. That dialog
# runs a nested gtk loop, so under xvfb it never gets dismissed: it sits
# on top of the UI in the confirm shot and, worse, swallows the SIGTERM
# in the quit test (the loop keeps running after main_quit). A real user
# ticks "Don't show this again" once; hide_startup_messsage replicates
# that. The triple-s key matches gmoccapy's own (sic).
cat >"$WORK_DIR/gmoccapy.pref" <<'PREF'
[DEFAULT]
hide_startup_messsage = 99
PREF

# Consumed by the sourcing test.sh, which execs the launcher with it.
# shellcheck disable=SC2034
GMOCCAPY_INI="$WORK_DIR/gmoccapy.ini"
7 changes: 7 additions & 0 deletions tests/ui-smoke/_lib/launch-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,10 @@ export SDL_AUDIODRIVER=dummy
# names the line; for a C/C++ crash (Qt, dbus, GL) it shows the Python
# frame that called in. The native side is captured by crashdump.sh.
export PYTHONFAULTHANDLER=1

# Xvfb virtual screen for the launchers' xvfb-run. There is no window
# manager under xvfb-run, so a GUI's maximize() is a no-op and it renders
# at its natural size; a screen smaller than the window clips the grab
# (the failure/confirm screenshot then misses panels). 1920x1080 fits
# every sim GUI so the whole window is captured.
export UI_SMOKE_XVFB_SCREEN="${UI_SMOKE_XVFB_SCREEN:-1920x1080x24}"
32 changes: 31 additions & 1 deletion tests/ui-smoke/_lib/launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ DRIVER_TIMEOUT=180
. "$LIB_DIR/crashdump.sh"
crashdump_arm

# Absolute path the offscreen-Qt self-grab writes to. An offscreen GUI
# runs with its cwd at the (writable) config mirror, not the test dir, so
# a relative name would land out of reach; pin it to the test dir, which
# is this shell's cwd. Harmless for the GTK GUIs, which ignore it.
export UI_SMOKE_QT_SHOT="$PWD/ui-smoke-qt.png"

# Export the per-invocation values so the inner bash -c receives them
# as proper env vars (avoids embedding paths into the inner script
# via quoting, which breaks on apostrophes / spaces).
Expand All @@ -52,7 +58,7 @@ export CONFIG_INI LIB_DIR DRIVER_TIMEOUT
# LIB_DIR and DRIVER_TIMEOUT are expanded by the inner bash (which sees
# them via the exported env), not by the outer shell.
# shellcheck disable=SC2016
xvfb-run -a --server-args="-screen 0 1024x768x24" \
xvfb-run -a --server-args="-screen 0 $UI_SMOKE_XVFB_SCREEN" \
timeout "$LINUXCNC_TIMEOUT" \
bash -c '
# Run linuxcnc in its own process group so we can signal the
Expand All @@ -70,6 +76,25 @@ xvfb-run -a --server-args="-screen 0 1024x768x24" \
timeout "$DRIVER_TIMEOUT" python3 "$LIB_DIR/drive.py" "$@" >ui-smoke.out 2>ui-smoke.err
DRIVE_RC=$?

# Photograph the root window before teardown, while DISPLAY is the
# Xvfb server and the GUI is still up. On failure the picture shows
# the cause (a hung GUI on a blocking modal leaves no core for
# crashdump.sh and no Python traceback). On a clean Phase 2 run it
# is a confirmation shot of the GUI in its post-movement idle state
# (final DRO / toolpath), so a reviewer can eyeball the result. The
# short settle lets the GUI repaint the final position first.
. "$LIB_DIR/screenshot.sh"
if [ "$DRIVE_RC" -ne 0 ]; then
screenshot_grab screenshot.png
else
case " $* " in
*" --run-program "*)
sleep 0.5
screenshot_grab confirm.png
;;
esac
fi

# Clean shutdown: GUI-specific quit first (lets linuxcnc end
# its own SIGTERM trap run Cleanup which unloads halrun and
# reaps shared memory). axis-remote works only for axis but is
Expand Down Expand Up @@ -109,4 +134,9 @@ echo "=== ui-smoke.err ==="
# If the GUI dumped a core, print its native backtrace.
crashdump_report

# Note any screenshot so the CI artifact step and reviewer know it is
# there to download: screenshot.png on failure, confirm.png on a clean run.
[ -f screenshot.png ] && echo "=== screenshot: $TEST_DIR/screenshot.png ==="
[ -f confirm.png ] && echo "=== confirm: $TEST_DIR/confirm.png ==="

exit "$RC"
40 changes: 40 additions & 0 deletions tests/ui-smoke/_lib/qtdragon-prepare.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,46 @@ class _BlockFinder(MetaPathFinder):
return None

sys.meta_path.insert(0, _BlockFinder())


# Native screenshot for the offscreen Qt platform. An X root grab gets a
# black frame (offscreen never draws to the X server), so the launcher
# signals this process instead: on SIGUSR1 we grab the main qtvcp window
# to ui-smoke-qt.png in the cwd (the test dir). The grab is deferred into
# the Qt event loop via singleShot so it never runs in signal context.
import os
import signal

def _ui_smoke_grab():
try:
from qtpy.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
return
target, best = None, -1
for w in app.topLevelWidgets():
if not w.isVisible():
continue
area = w.width() * w.height()
if area > best:
target, best = w, area
if target is not None:
out = os.environ.get('UI_SMOKE_QT_SHOT', 'ui-smoke-qt.png')
target.grab().save(out)
except Exception:
pass

def _ui_smoke_on_sigusr1(signum, frame):
try:
from qtpy.QtCore import QTimer
QTimer.singleShot(0, _ui_smoke_grab)
except Exception:
pass

try:
signal.signal(signal.SIGUSR1, _ui_smoke_on_sigusr1)
except Exception:
pass
PY
export PYTHONPATH="$SHIM_DIR${PYTHONPATH:+:$PYTHONPATH}"

Expand Down
14 changes: 13 additions & 1 deletion tests/ui-smoke/_lib/quit-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@ QUIT_GRACE=15
. "$LIB_DIR/crashdump.sh"
crashdump_arm

# Absolute path the offscreen-Qt self-grab writes to (the test dir, this
# shell's cwd); see launch.sh for why a relative name would miss.
export UI_SMOKE_QT_SHOT="$PWD/ui-smoke-qt.png"

export CONFIG_INI LIB_DIR DRIVER_TIMEOUT GUI_MATCH QUIT_GRACE

# shellcheck disable=SC2016
xvfb-run -a --server-args="-screen 0 1024x768x24" \
xvfb-run -a --server-args="-screen 0 $UI_SMOKE_XVFB_SCREEN" \
timeout "$LINUXCNC_TIMEOUT" \
bash -c '
setsid linuxcnc -r "$CONFIG_INI" >linuxcnc.out 2>linuxcnc.err &
Expand Down Expand Up @@ -95,6 +99,11 @@ xvfb-run -a --server-args="-screen 0 1024x768x24" \

if kill -0 "$GUI_PID" 2>/dev/null; then
echo "UI_SMOKE_QUIT_FAIL: GUI (pid $GUI_PID) still alive ${QUIT_GRACE}s after SIGTERM"
# A GUI that absorbs SIGTERM is usually blocked on a modal it
# cannot dismiss headless. Photograph it before teardown so the
# offending dialog is visible. The GUI is still up here.
. "$LIB_DIR/screenshot.sh"
screenshot_grab screenshot.png
RC=1
else
echo "UI_SMOKE_QUIT_OK: GUI exited ${waited}s after SIGTERM"
Expand Down Expand Up @@ -126,4 +135,7 @@ echo "=== ui-smoke.err ==="
# If the GUI dumped a core, print its native backtrace.
crashdump_report

# Note any failure screenshot for the CI artifact step and reviewer.
[ -f screenshot.png ] && echo "=== screenshot: $TEST_DIR/screenshot.png ==="

exit "$RC"
84 changes: 84 additions & 0 deletions tests/ui-smoke/_lib/screenshot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/bin/bash
# Screen capture for the UI smoke launchers. Complements crashdump.sh:
# that one fires only on a segfault, but a GUI can also fail by hanging
# (a modal dialog blocking headless startup, a wedged event loop) with no
# core and no Python traceback. A picture of the root window at failure
# time shows what is actually on screen. Source with no state needed; the
# grab is a no-op (with a logged reason) when there is no display or no
# grabber, so it can never turn a pass into a fail.
#
# Must be called from inside the xvfb-run subshell, where DISPLAY points
# at the Xvfb server and the GUI window still exists. CI uploads the PNG
# as a build artifact (see .github/workflows/ci.yml).

screenshot_grab() {
out="$1"
# Offscreen Qt (qtdragon) renders in memory, never to the X server, so
# an X root grab gets a black frame. Ask the GUI to grab itself
# instead: the qtdragon shim installs a SIGUSR1 handler that saves its
# top-level window to ui-smoke-qt.png in the test dir.
if [ "${QT_QPA_PLATFORM:-}" = "offscreen" ]; then
screenshot_grab_qt "$out"
return 0
fi
if [ -z "${DISPLAY:-}" ]; then
echo "screenshot: no DISPLAY set, skipping $out"
return 0
fi
# ImageMagick's import grabs X11 directly with no xwd dependency and
# is the grabber present in the CI image. Fall back to xwd|convert for
# local dev boxes that have xwd but not import.
if command -v import >/dev/null 2>&1; then
if import -window root "$out" 2>/dev/null; then
echo "screenshot: wrote $out"
else
echo "screenshot: import failed for $out"
fi
elif command -v xwd >/dev/null 2>&1 && command -v convert >/dev/null 2>&1; then
if xwd -root -display "$DISPLAY" 2>/dev/null | convert xwd:- "$out" 2>/dev/null; then
echo "screenshot: wrote $out"
else
echo "screenshot: xwd|convert failed for $out"
fi
else
echo "screenshot: no grabber (import or xwd) available, skipping $out"
fi
return 0
}

# Native grab for an offscreen Qt GUI. Find the qtvcp python process,
# SIGUSR1 it, and wait for the shim's handler to drop ui-smoke-qt.png in
# the test dir (the GUI's cwd), then move it to $out. No-op (logged) if
# the GUI or the grab is not found, so it can never fail a test.
screenshot_grab_qt() {
out="$1"
# The GUI shim saves to this absolute path (set by the launcher),
# since the offscreen GUI's cwd is the config mirror, not the test dir.
shot="${UI_SMOKE_QT_SHOT:-ui-smoke-qt.png}"
rm -f "$shot"
pid=""
for p in $(pgrep -f "qtvcp" 2>/dev/null); do
arg0=$(tr '\0' '\n' <"/proc/$p/cmdline" 2>/dev/null | head -1)
case "$(basename "$arg0" 2>/dev/null)" in
python*) pid="$p"; break ;;
esac
done
if [ -z "$pid" ]; then
echo "screenshot: qtvcp process not found, skipping $out"
return 0
fi
kill -USR1 "$pid" 2>/dev/null || true
waited=0
while [ "$waited" -lt 10 ]; do
[ -s "$shot" ] && break
sleep 0.5
waited=$((waited + 1))
done
if [ -s "$shot" ]; then
mv -f "$shot" "$out"
echo "screenshot: wrote $out (qt native grab)"
else
echo "screenshot: qt native grab produced no file for $out"
fi
return 0
}
6 changes: 3 additions & 3 deletions tests/ui-smoke/gmoccapy-quit/test.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/bash
exec "$(dirname "$0")/../_lib/quit-launch.sh" \
"$(cd "$(dirname "$0")/../../../configs/sim" && pwd)/gmoccapy/gmoccapy.ini" \
"bin/gmoccapy"
LIB_DIR="$(cd "$(dirname "$0")/../_lib" && pwd)"
. "$LIB_DIR/gmoccapy-prepare.sh"
exec "$LIB_DIR/quit-launch.sh" "$GMOCCAPY_INI" "bin/gmoccapy"
3 changes: 2 additions & 1 deletion tests/ui-smoke/gmoccapy/test.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/bin/bash
LIB_DIR="$(cd "$(dirname "$0")/../_lib" && pwd)"
exec "$LIB_DIR/run-gui.sh" gmoccapy/gmoccapy.ini \
. "$LIB_DIR/gmoccapy-prepare.sh"
exec "$LIB_DIR/run-gui.sh" "$GMOCCAPY_INI" \
--run-program "$LIB_DIR/smoke.ngc" --expect-delta-mm 1,1,0
Loading