From 159b5cfd283cf2a8335c15b8e040cf6c0003be79 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 08:25:56 +0300 Subject: [PATCH] feat(dx): headless L2 script and a smoke workflow scripts/dev/headless_app.sh starts Xvfb on a free display, runs a copy of a built bundle with QUERYA_PORTABLE=1 on a throw-away profile, waits for the window, checks that it stays up, optionally takes a screenshot and stops everything. Exit codes: 0 ok, 1 died / no window, 2 usage or missing tool. The Headless smoke workflow builds the Linux bundle and runs it (on changes to the script, weekly, on demand) and keeps the screenshot and log. docs/dev-containers.md describes the three levels as one command each. Refs #1487 --- .github/workflows/headless-smoke.yml | 67 ++++++++++ docs/dev-containers.md | 38 ++++-- scripts/dev/headless_app.sh | 184 +++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/headless-smoke.yml create mode 100755 scripts/dev/headless_app.sh diff --git a/.github/workflows/headless-smoke.yml b/.github/workflows/headless-smoke.yml new file mode 100644 index 00000000..eb6785c4 --- /dev/null +++ b/.github/workflows/headless-smoke.yml @@ -0,0 +1,67 @@ +name: Headless smoke (L2) + +# Builds the Linux bundle and runs the real app under Xvfb in portable mode with +# scripts/dev/headless_app.sh: it has to start, show a window and stay up. The +# screenshot is kept as an artifact. Not part of ci.yml: a Linux build takes +# minutes, so this runs when the script or this file changes, weekly, and on +# demand. +on: + pull_request: + paths: + - scripts/dev/headless_app.sh + - .github/workflows/headless-smoke.yml + schedule: + - cron: "0 4 * * 2" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: headless-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: App under Xvfb + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev \ + libsecret-1-dev xvfb xdotool imagemagick + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.1' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build Linux (release) + run: flutter build linux --release + + - name: Run the app headless + run: | + mkdir -p build/headless + scripts/dev/headless_app.sh \ + --bundle build/linux/x64/release/bundle \ + --screenshot build/headless/screenshot.png \ + --log build/headless/app.log \ + --wait 90 --keep 5 + + - name: Keep the screenshot and the log + if: always() + uses: actions/upload-artifact@v4 + with: + name: headless-smoke + path: build/headless/ + if-no-files-found: ignore + retention-days: 14 diff --git a/docs/dev-containers.md b/docs/dev-containers.md index 574434f7..1863a220 100644 --- a/docs/dev-containers.md +++ b/docs/dev-containers.md @@ -121,28 +121,46 @@ The **Demo Playground** (a bundled SQLite database) needs none of this. ## Headless modes -"Headless" means no display and no window. There are three levels; the first -two work today, the third is a recipe that is not wrapped in a script yet +"Headless" means no display and no window. There are three levels, each one +command and each runnable in a container or on a CI runner (tracked in [#1487](https://github.com/QueryaHub/Querya-Desktop/issues/1487)). | Level | What | Command | |-------|------|---------| | L0 | unit, widget, e2e and guard tests | `flutter test --exclude-tags=golden` | | L1 | an extension against a real database, no GUI | `querya-ext-tester ./ext --connect '{...}' --sandbox` (see *Testing an extension*) | -| L2 | the real app under a virtual display, in portable mode | see below | +| L2 | the real app under a virtual display, in portable mode | `scripts/dev/headless_app.sh` | L1 needs a tester built from 0.4.19 or newer: older ones exited 0 whatever the result. -L2, by hand (untested in the container, it is the same recipe as a normal -Linux run): +### L2: `scripts/dev/headless_app.sh` + +Starts Xvfb on a free display, runs a **copy** of a built bundle with +`QUERYA_PORTABLE=1` (so its `QueryaData` is a throw-away profile and the bundle +stays untouched), waits for the window, keeps watching for a few seconds, and +stops everything. ```bash -Xvfb :99 -screen 0 1280x800x24 & # DISPLAY=:99 is already set -QUERYA_PORTABLE=1 flutter run -d linux # data goes to ./QueryaData of the bundle -import -window root shot.png # ImageMagick, if installed +flutter build linux --release # or --debug +scripts/dev/headless_app.sh # picks build/linux/x64/release/bundle +scripts/dev/headless_app.sh --bundle path/to/bundle --screenshot out/shot.png --log out/app.log ``` +Exit code 0: the app started, showed a window and stayed up; 1: it died or no +window appeared (the last lines of its output are printed); 2: a missing bundle +or tool. It needs `xvfb`, plus `xdotool` or `xwininfo` to see the window and +ImageMagick (`import`) for `--screenshot`; all of them are in the CI job below, +the dev container image has Xvfb. + +The [Headless smoke (L2)](../.github/workflows/headless-smoke.yml) workflow builds +the Linux bundle and runs the script (on changes to it, weekly, and on demand), +keeping the screenshot and the log as an artifact. + +Whether L2 should grow a real "headless Querya" (core services without a window, +so that `querya-mcp` does not need a running Desktop) is left open until this +exists. + ## Run the app in the container The container has no display of its own. Two ways: @@ -229,8 +247,8 @@ a usage error. ## Known gaps -- **Headless L2** (the app under Xvfb in a script and a CI job) and the - extension-author container are not done yet; both are tracked in +- **The extension-author container** (Dev Container Feature, test database + images, template repository, registry CI) is not done yet; it is tracked in [#1487](https://github.com/QueryaHub/Querya-Desktop/issues/1487). ## Troubleshooting diff --git a/scripts/dev/headless_app.sh b/scripts/dev/headless_app.sh new file mode 100755 index 00000000..a9e7a58d --- /dev/null +++ b/scripts/dev/headless_app.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# L2 headless run: the real Querya app under a virtual display, in portable +# mode, on a throw-away data directory. No window server or GPU is needed. +# +# scripts/dev/headless_app.sh [options] +# +# --bundle DIR built bundle (default: build/linux/x64/release/bundle, +# then .../debug/bundle); must contain `querya_desktop` +# --screenshot FILE save a screenshot of the virtual screen (PNG) +# --wait SECONDS how long to wait for the window (default 60) +# --keep SECONDS how long it has to stay alive after the window shows up +# (default 3); catches crashes right after start +# --log FILE copy the app's stdout / stderr there +# --keep-data do not delete the temporary data directory +# -h, --help this text +# +# Exit codes: 0 the app started, showed a window and stayed up; 1 it died, or no +# window appeared in time; 2 usage error, missing bundle or missing tool. +# +# Needs: Xvfb; xdotool or xwininfo to see the window (without them the check is +# "the process is still alive after --wait"); ImageMagick `import` for +# --screenshot. Environment: QUERYA_HEADLESS_DISPLAY to force a display number. +set -u + +bundle="" +screenshot="" +wait_s=60 +keep_s=3 +log_out="" +keep_data=0 + +usage() { sed -n '2,/^set -u/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; } +die() { printf 'headless_app: %s\n' "$*" >&2; exit 2; } +say() { printf 'headless_app: %s\n' "$*"; } + +while [ $# -gt 0 ]; do + case "$1" in + --bundle) bundle="${2:-}"; shift 2 ;; + --screenshot) screenshot="${2:-}"; shift 2 ;; + --wait) wait_s="${2:-}"; shift 2 ;; + --keep) keep_s="${2:-}"; shift 2 ;; + --log) log_out="${2:-}"; shift 2 ;; + --keep-data) keep_data=1; shift ;; + -h | --help) usage; exit 0 ;; + *) die "unknown option: $1 (see --help)" ;; + esac +done + +if [ -z "$bundle" ]; then + for candidate in build/linux/x64/release/bundle build/linux/x64/debug/bundle; do + if [ -x "$candidate/querya_desktop" ]; then bundle="$candidate"; break; fi + done +fi +[ -n "$bundle" ] || die "no bundle found; build one (flutter build linux) or pass --bundle DIR" +[ -x "$bundle/querya_desktop" ] || die "$bundle/querya_desktop is missing or not executable" +command -v Xvfb >/dev/null 2>&1 || die "Xvfb is not installed (apt-get install xvfb)" +if [ -n "$screenshot" ] && ! command -v import >/dev/null 2>&1; then + die "--screenshot needs ImageMagick's 'import' (apt-get install imagemagick)" +fi + +work="$(mktemp -d "${TMPDIR:-/tmp}/querya-headless.XXXXXX")" +app_pid="" +xvfb_pid="" + +cleanup() { + local status=$? + if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then + kill -TERM "$app_pid" 2>/dev/null + for _ in $(seq 1 20); do + kill -0 "$app_pid" 2>/dev/null || break + sleep 0.5 + done + kill -KILL "$app_pid" 2>/dev/null + fi + if [ -n "$xvfb_pid" ]; then + kill -TERM "$xvfb_pid" 2>/dev/null + wait "$xvfb_pid" 2>/dev/null + fi + if [ -n "$log_out" ] && [ -f "$work/app.log" ]; then + cp "$work/app.log" "$log_out" 2>/dev/null + fi + if [ "$keep_data" -eq 1 ]; then + say "data and log kept in $work" + else + rm -rf "$work" + fi + exit "$status" +} +trap cleanup EXIT + +fail_with_log() { + say "$1" + if [ -f "$work/app.log" ]; then + say "--- last lines of the app output ---" + tail -n 40 "$work/app.log" >&2 + fi + exit 1 +} + +# A free display number. +if [ -n "${QUERYA_HEADLESS_DISPLAY:-}" ]; then + num="${QUERYA_HEADLESS_DISPLAY#:}" +else + num=99 + while [ -e "/tmp/.X${num}-lock" ] || [ -S "/tmp/.X11-unix/X${num}" ]; do + num=$((num + 1)) + [ "$num" -lt 200 ] || die "no free X display between :99 and :199" + done +fi +export DISPLAY=":${num}" + +Xvfb "$DISPLAY" -screen 0 1280x800x24 -nolisten tcp >"$work/xvfb.log" 2>&1 & +xvfb_pid=$! +for _ in $(seq 1 40); do + [ -S "/tmp/.X11-unix/X${num}" ] && break + kill -0 "$xvfb_pid" 2>/dev/null || { cat "$work/xvfb.log" >&2; die "Xvfb did not start"; } + sleep 0.25 +done +[ -S "/tmp/.X11-unix/X${num}" ] || die "Xvfb on $DISPLAY did not come up" +say "virtual display $DISPLAY" + +# Portable mode keeps its data in {bundle}/QueryaData, so run a copy: the +# bundle that was built stays untouched and every run starts on an empty profile. +cp -a "$bundle" "$work/bundle" || die "could not copy $bundle" +export QUERYA_PORTABLE=1 +export NO_AT_BRIDGE=1 + +runner=() +if command -v dbus-run-session >/dev/null 2>&1; then + runner=(dbus-run-session --) +fi +${runner[@]+"${runner[@]}"} "$work/bundle/querya_desktop" >"$work/app.log" 2>&1 & +app_pid=$! +say "started querya_desktop (pid $app_pid)" + +have_window_tool=0 +if command -v xdotool >/dev/null 2>&1 || command -v xwininfo >/dev/null 2>&1; then + have_window_tool=1 +fi + +window_up() { + if command -v xdotool >/dev/null 2>&1; then + xdotool search --onlyvisible --name '.' >/dev/null 2>&1 + else + # A top-level window with a real size (nothing else maps one on a bare Xvfb). + xwininfo -root -tree 2>/dev/null | grep -Eq '[0-9]{3,4}x[0-9]{3,4}\+' + fi +} + +deadline=$((SECONDS + wait_s)) +shown=0 +while [ "$SECONDS" -lt "$deadline" ]; do + kill -0 "$app_pid" 2>/dev/null || fail_with_log "the app exited before showing a window" + if [ "$have_window_tool" -eq 1 ]; then + if window_up; then shown=1; break; fi + fi + sleep 1 +done + +if [ "$have_window_tool" -eq 1 ]; then + [ "$shown" -eq 1 ] || fail_with_log "no window after ${wait_s}s" + say "window is up" +else + say "no xdotool / xwininfo: only checking that the process stays alive" +fi + +end=$((SECONDS + keep_s)) +while [ "$SECONDS" -lt "$end" ]; do + kill -0 "$app_pid" 2>/dev/null || fail_with_log "the app exited after showing its window" + sleep 0.5 +done + +if [ -n "$screenshot" ]; then + mkdir -p "$(dirname "$screenshot")" + if import -display "$DISPLAY" -window root "$screenshot" 2>"$work/import.log"; then + say "screenshot: $screenshot" + else + cat "$work/import.log" >&2 + fail_with_log "could not take the screenshot" + fi +fi + +say "ok" +exit 0