diff --git a/.github/workflows/linux-bundles.yml b/.github/workflows/linux-bundles.yml index da845c1..db5d8d9 100644 --- a/.github/workflows/linux-bundles.yml +++ b/.github/workflows/linux-bundles.yml @@ -2,7 +2,10 @@ name: Linux bundles # Builds the Linux AppImage and .deb of the desktop app with # scripts/build-linux.sh and keeps them as workflow artifacts for 3 days, so a -# Linux user (or a reviewer) can try a build of a pull request. It publishes +# Linux user (or a reviewer) can try a build of a pull request. Then +# scripts/linux-smoke.sh starts both on a virtual display with an empty HOME, +# on the build image (22.04) and on ubuntu-latest, and checks that the app comes +# up as a new user would see it; screenshots and logs are kept for 3 days too. It publishes # nothing: no release, no tag, no secret, no signing (there is no signing and no # updater on Linux). Runs only on GitHub-hosted runners (this repo is public); # never a self-hosted one (tests/test_workflows_guard.py). @@ -17,6 +20,7 @@ on: paths: - "desktop/**" - "scripts/build-linux.sh" + - "scripts/linux-smoke.sh" - ".github/workflows/linux-bundles.yml" permissions: @@ -37,7 +41,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev \ - libayatana-appindicator3-dev librsvg2-dev patchelf + libayatana-appindicator3-dev librsvg2-dev patchelf \ + xvfb x11-utils imagemagick # The sidecar is frozen with Python 3.14, the version the macOS release uses. - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 @@ -87,3 +92,64 @@ jobs: path: desktop/src-tauri/target/release/bundle/deb/*.deb if-no-files-found: error retention-days: 3 + + # After the uploads, so the bundles are there even when a check fails. + - name: Smoke-test the AppImage + run: OUT=smoke scripts/linux-smoke.sh appimage desktop/src-tauri/target/release/bundle/appimage/*.AppImage + + - name: Smoke-test the .deb + if: ${{ !cancelled() }} + run: OUT=smoke scripts/linux-smoke.sh deb desktop/src-tauri/target/release/bundle/deb/*.deb + + - name: Upload the smoke-test screenshots and logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: conductloop-linux-smoke-ubuntu-22.04 + path: smoke/ + if-no-files-found: warn + retention-days: 3 + + # The same bundles on the newest image: they are built on 22.04 for an older + # glibc, so check that they also start (and the .deb installs) on a newer one. + linux-smoke: + needs: linux-bundles + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Plus the graphics libraries every Linux desktop has: an AppImage takes + # libEGL/libGL from the system (they are on AppImage's exclude list), and + # this runner image is a server without them ("libEGL.so.1: cannot open + # shared object file" otherwise). The .deb gets them from its dependencies. + - name: Display tools and a desktop's graphics libraries + run: | + sudo apt-get update + sudo apt-get install -y xvfb x11-utils imagemagick libegl1 libgl1 libgles2 libgbm1 + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: conductloop-linux-appimage + path: bundles/appimage + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: conductloop-linux-deb + path: bundles/deb + + - name: Smoke-test the AppImage + run: OUT=smoke scripts/linux-smoke.sh appimage bundles/appimage/*.AppImage + + - name: Smoke-test the .deb + if: ${{ !cancelled() }} + run: OUT=smoke scripts/linux-smoke.sh deb bundles/deb/*.deb + + - name: Upload the smoke-test screenshots and logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: conductloop-linux-smoke-ubuntu-latest + path: smoke/ + if-no-files-found: warn + retention-days: 3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7388bcd..10575c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ User-facing changes to ConductLoop (the desktop app and its local server), newes ## Unreleased +- CI only: the Linux bundles workflow now **starts the AppImage and the .deb** it builds, on Ubuntu 22.04 (the build image) and on ubuntu-latest, on a virtual display with an empty HOME and no keyring. `scripts/linux-smoke.sh` checks that the local server answers, that `/api/platform` says Linux with the in-app browser off, that it is a first run with the welcome page, that a ConductLoop window is on screen, and that closing the app stops the server; screenshots and logs are kept for 3 days. Until now the Linux packages were only built, never run. The script also works by hand on a Linux machine. +- CI only: a "Windows (compile and tests)" workflow checks on every relevant pull request that the desktop wrapper still compiles for Windows and that the Windows-safe part of the Python tests passes there. It builds no installer, signs nothing, publishes nothing and is not a required check. Windows is still not supported. - Windows: a CI workflow, "Windows bundles", builds the NSIS installer and the .msi on `windows-latest` and keeps them for 3 days as downloadable artifacts. It only runs when started by hand (it is not a check of pull requests), signs nothing and publishes nothing. CI only: the app does not change. - **Windows: a script to build an installer (experimental, unsigned).** `scripts/build-windows.ps1` freezes the server with PyInstaller and runs `tauri build --bundles nsis` (an `.msi` with `-Bundles nsis,msi`); the Windows settings live in `desktop/src-tauri/tauri.windows.conf.json`, so the macOS build does not change. The installer is not signed, so Windows SmartScreen warns when it runs ("More info", then "Run anyway"), and it needs WebView2 (included in Windows 11 and current Windows 10; the installer downloads it if missing). No updates on Windows: the app does not look for updates at start-up there. On Windows the bundled server now runs in UTF-8 mode. Not tested on a real Windows machine yet; Windows is still not a supported platform. `docs/PORTABILITY.md` has the status. @@ -15,7 +17,6 @@ changes is the first part of the list below (the notes home, the Brain without a key, Setup); the Linux and Windows items are groundwork for those systems and leave the Mac app as it was. -- CI only: a "Windows (compile and tests)" workflow checks on every relevant pull request that the desktop wrapper still compiles for Windows and that the Windows-safe part of the Python tests passes there. It builds no installer, signs nothing, publishes nothing and is not a required check. Windows is still not supported. - **A home for your notes.** Without projects (a fresh install has none), the dashboard opened on an empty project canvas and search hid behind ⌘P. It now opens on "Your notes": how many are indexed, a box to ask a question (it goes diff --git a/scripts/linux-smoke.sh b/scripts/linux-smoke.sh new file mode 100755 index 0000000..f8bb0d4 --- /dev/null +++ b/scripts/linux-smoke.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Smoke-test a Linux build of the desktop app: start it on a display with an +# empty HOME, and check that it comes up the way a new user would see it. +# +# scripts/linux-smoke.sh deb path/to/ConductLoop__amd64.deb +# scripts/linux-smoke.sh appimage path/to/ConductLoop__amd64.AppImage +# +# Checks, in order (each prints ✓ or ✗; the exit status is 1 if any failed): +# 1. the local server answers /api/health on 127.0.0.1:7777 within $WAIT s; +# 2. /api/platform says Linux, with the in-app browser off; +# 3. it is a first run: /api/onboarding/state is fresh and /welcome is served; +# 4. a ConductLoop window is on the display (needs xwininfo; skipped without); +# 5. closing the app stops its server (the port is free within 15 s). +# A screenshot of the display after start-up goes to $OUT/linux-smoke-.png +# (needs ImageMagick's `import`) and the app's output to linux-smoke-.log. +# +# The .deb is installed with `sudo apt-get install`. The AppImage runs with +# --appimage-extract-and-run, so it needs no FUSE. Without $DISPLAY the script +# starts Xvfb on :99. Everything the app writes goes to a temporary HOME (data, +# config, logs); there is no D-Bus session, so no keyring either: the app must +# say so and keep working. Publishes nothing. +# +# Needs: Linux x86_64, curl, python3, Xvfb when there is no display; optional: +# xwininfo (x11-utils) and import (imagemagick). +set -euo pipefail + +MODE="${1:-}" +PKG="${2:-}" +OUT="${OUT:-$PWD}" +WAIT="${WAIT:-120}" +BASE="http://127.0.0.1:7777" + +usage() { sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; } +case "$MODE" in + deb|appimage) ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; +esac +if [[ -z "$PKG" || ! -f "$PKG" ]]; then + echo "✗ no package at '$PKG'" >&2 + exit 2 +fi +if [[ "$(uname -s)" != "Linux" ]]; then + echo "✗ linux-smoke.sh must run on Linux (this is $(uname -s))" >&2 + exit 2 +fi +PKG="$(realpath "$PKG")" +mkdir -p "$OUT" +LOG="$OUT/linux-smoke-$MODE.log" +SHOT="$OUT/linux-smoke-$MODE.png" +failures=0 +report() { echo "$1"; [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && echo "- $1" >> "$GITHUB_STEP_SUMMARY"; return 0; } +ok() { report "✓ $1"; } +bad() { report "✗ $1"; failures=$((failures + 1)); } + +if curl -s -m 2 -o /dev/null "$BASE/api/health"; then + echo "✗ something already answers on $BASE: stop it first" >&2 + exit 2 +fi + +case "$MODE" in + deb) + # The log belongs to the user running the script, not to root. + # shellcheck disable=SC2024 + sudo apt-get install -y "$PKG" > "$OUT/linux-smoke-apt.log" 2>&1 || { + tail -20 "$OUT/linux-smoke-apt.log"; echo "✗ apt could not install $PKG" >&2; exit 1; } + # dpkg-deb lists "usr/bin/x" or "./usr/bin/x": make it absolute either way. + BIN="$(dpkg-deb -c "$PKG" | awk '{print $NF}' | sed -E 's#^\.?/?#/#' | grep '/conductloop-desktop$' | head -1)" + ARGS=() + ;; + appimage) + chmod +x "$PKG" + BIN="$PKG" + ARGS=(--appimage-extract-and-run) + ;; +esac +[[ -n "$BIN" && -x "$BIN" ]] || { echo "✗ no conductloop-desktop binary in $PKG" >&2; exit 1; } +# shellcheck source=/dev/null +[[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && echo "### Linux smoke test: $MODE ($(. /etc/os-release && echo "$PRETTY_NAME"))" >> "$GITHUB_STEP_SUMMARY" + +TMP="$(mktemp -d)" +XVFB_PID="" +APP_PID="" +cleanup() { + [[ -n "$APP_PID" ]] && kill "$APP_PID" 2>/dev/null || true + pkill -f conductloop-desktop 2>/dev/null || true + pkill -f conductloop-server 2>/dev/null || true + [[ -n "$XVFB_PID" ]] && kill "$XVFB_PID" 2>/dev/null || true + rm -rf "$TMP" +} +trap cleanup EXIT + +if [[ -z "${DISPLAY:-}" ]]; then + Xvfb :99 -screen 0 1280x800x24 -nolisten tcp > "$OUT/linux-smoke-xvfb.log" 2>&1 & + XVFB_PID=$! + export DISPLAY=:99 + sleep 2 +fi + +# A new user: empty HOME, no session bus (so no Secret Service), nothing set. +# WEBKIT_DISABLE_DMABUF_RENDERER: Xvfb has no GPU device for WebKitGTK's DMA-BUF +# renderer, which otherwise draws an empty window there. +mkdir -p "$TMP/home" +started=$(date +%s) +env -i PATH="/usr/local/bin:/usr/bin:/bin" HOME="$TMP/home" DISPLAY="$DISPLAY" \ + LANG=C.UTF-8 NO_AT_BRIDGE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 \ + TMPDIR="$TMP" "$BIN" "${ARGS[@]}" > "$LOG" 2>&1 & +APP_PID=$! + +# 1. The server comes up. +up="" +for _ in $(seq 1 "$WAIT"); do + if curl -s -m 2 -o /dev/null -w '%{http_code}' "$BASE/api/health" | grep -q '^200$'; then up=1; break; fi + kill -0 "$APP_PID" 2>/dev/null || break + sleep 1 +done +if [[ -n "$up" ]]; then + version="$(curl -s "$BASE/api/health" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version"))')" + ok "server answered in $(( $(date +%s) - started )) s as $version" +else + bad "the server did not answer within $WAIT s (app $(kill -0 "$APP_PID" 2>/dev/null && echo running || echo exited))" + tail -40 "$LOG" || true + exit 1 +fi + +# 2. It knows it is on Linux. +if curl -s "$BASE/api/platform" | python3 -c ' +import json, sys +p = json.load(sys.stdin) +for k in ("browser", "code_execution", "keychain"): + v = p.get(k) or {} + print((" %s: available=%s %s %s" % (k, v.get("available"), v.get("reason") or "", v.get("message") or "")).rstrip()) +sys.exit(0 if p.get("platform") == "linux" and (p.get("browser") or {}).get("available") is False else 1) +'; then + ok "/api/platform: linux, in-app browser off" +else + bad "/api/platform does not say linux with the browser off" +fi + +# 3. A first run. +if curl -s "$BASE/api/onboarding/state" | python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("fresh") is True else 1)' \ + && curl -s "$BASE/welcome" | grep -q "ConductLoop"; then + ok "first run: onboarding is fresh and /welcome is served" +else + bad "first run: onboarding not fresh or /welcome missing" +fi + +# 4. A window, and what it shows. +sleep 8 +if command -v xwininfo >/dev/null; then + if xwininfo -root -tree | grep -q '"ConductLoop"'; then ok "a ConductLoop window is on the display"; else bad "no ConductLoop window on the display"; fi +fi +if command -v import >/dev/null; then + import -window root "$SHOT" 2>/dev/null && echo " screenshot: $SHOT" || echo " (no screenshot)" +fi + +# 5. Closing the app stops its server. +pkill -TERM -f conductloop-desktop 2>/dev/null || true +kill "$APP_PID" 2>/dev/null || true +# The port closes before the process has exited: wait for both. +freed="" +for i in $(seq 1 15); do + if ! curl -s -m 1 -o /dev/null "$BASE/api/health" && ! pgrep -f conductloop-server >/dev/null; then freed=$i; break; fi + sleep 1 +done +if [[ -n "$freed" ]]; then + ok "closing the app stopped its server (${freed} s)" +else + bad "the server kept running 15 s after the app was closed" + ps -o pid,ppid,stat,etime,args -p "$(pgrep -d, -f conductloop-server)" 2>/dev/null || true + curl -s -m 1 -o /dev/null -w ' port 7777: HTTP %{http_code}\n' "$BASE/api/health" || echo " port 7777: closed" +fi +APP_PID="" + +if (( failures )); then + echo "--- last lines of the app's output" + tail -40 "$LOG" || true + exit 1 +fi diff --git a/tests/test_linux_bundles_workflow.py b/tests/test_linux_bundles_workflow.py index 615dff8..ac1bf69 100644 --- a/tests/test_linux_bundles_workflow.py +++ b/tests/test_linux_bundles_workflow.py @@ -1,8 +1,9 @@ """The Linux bundles workflow (.github/workflows/linux-bundles.yml). -It builds the AppImage and .deb and uploads them as short-lived artifacts. It -must not publish anything, take a secret, or run anywhere but a GitHub-hosted -runner. (tests/test_workflows_guard.py checks pinning, permissions and runner +It builds the AppImage and .deb, uploads them as short-lived artifacts and +smoke-tests both (scripts/linux-smoke.sh) on the build image and on +ubuntu-latest. It must not publish anything, take a secret, or run anywhere but +a GitHub-hosted runner. (tests/test_workflows_guard.py checks pinning, permissions and runner labels for every workflow; these are the checks specific to this one.) """ from __future__ import annotations @@ -18,11 +19,13 @@ TEXT = PATH.read_text(encoding="utf-8") DOC = yaml.safe_load(TEXT) JOB = DOC["jobs"]["linux-bundles"] +SMOKE_JOB = DOC["jobs"]["linux-smoke"] +SMOKE = ROOT / "scripts" / "linux-smoke.sh" TRIGGERS = DOC.get(True) or DOC.get("on") # PyYAML reads the key `on` as True def test_it_runs_on_ubuntu_22_04_for_an_older_glibc_and_nowhere_else(): - assert list(DOC["jobs"]) == ["linux-bundles"] + assert list(DOC["jobs"]) == ["linux-bundles", "linux-smoke"] assert JOB["runs-on"] == "ubuntu-22.04" # older glibc: the AppImage runs on older distributions assert JOB["timeout-minutes"] <= 90 @@ -30,7 +33,7 @@ def test_it_runs_on_ubuntu_22_04_for_an_older_glibc_and_nowhere_else(): def test_triggers_are_manual_and_pull_requests_that_touch_the_desktop_app_or_the_script(): assert set(TRIGGERS) == {"workflow_dispatch", "pull_request"} paths = TRIGGERS["pull_request"]["paths"] - assert "desktop/**" in paths and "scripts/build-linux.sh" in paths + assert "desktop/**" in paths and "scripts/build-linux.sh" in paths and "scripts/linux-smoke.sh" in paths # Not on push: bundles of main are not wanted on every merge. assert "push" not in TRIGGERS and "schedule" not in TRIGGERS @@ -41,14 +44,71 @@ def test_it_builds_with_the_script_and_installs_webkitgtk_4_1(): assert "libwebkit2gtk-4.1-dev" in steps and "libgtk-3-dev" in steps +def _uploads(job): + return [s for s in job["steps"] if str(s.get("uses", "")).startswith("actions/upload-artifact@")] + + def test_both_bundles_are_uploaded_for_three_days(): - uploads = [s for s in JOB["steps"] if str(s.get("uses", "")).startswith("actions/upload-artifact@")] - assert len(uploads) == 2 - for step in uploads: - assert step["with"]["retention-days"] == 3 + bundles = [s for s in _uploads(JOB) if s["with"]["name"] in ("conductloop-linux-appimage", "conductloop-linux-deb")] + assert len(bundles) == 2 + for step in bundles: assert step["with"]["if-no-files-found"] == "error" - paths = " ".join(s["with"]["path"] for s in uploads) + paths = " ".join(s["with"]["path"] for s in bundles) assert ".AppImage" in paths and ".deb" in paths + for step in _uploads(JOB) + _uploads(SMOKE_JOB): + assert step["with"]["retention-days"] == 3 + + +def _runs(job): + return [str(s.get("run", "")) for s in job["steps"]] + + +def test_both_bundles_are_smoke_tested_on_the_build_image_after_they_are_uploaded(): + names = [s.get("name", "") for s in JOB["steps"]] + last_upload = max(i for i, s in enumerate(JOB["steps"]) + if s.get("with", {}).get("name") in ("conductloop-linux-appimage", "conductloop-linux-deb")) + smoke = [i for i, r in enumerate(_runs(JOB)) if "scripts/linux-smoke.sh" in r] + assert len(smoke) == 2 and min(smoke) > last_upload, names + runs = "\n".join(_runs(JOB)) + assert "linux-smoke.sh appimage" in runs and "linux-smoke.sh deb" in runs + assert "xvfb" in runs and "x11-utils" in runs and "imagemagick" in runs + + +def test_and_again_on_ubuntu_latest_from_the_uploaded_artifacts(): + assert SMOKE_JOB["needs"] == "linux-bundles" + assert SMOKE_JOB["runs-on"] == "ubuntu-latest" + assert SMOKE_JOB["timeout-minutes"] <= 30 + downloads = [s["with"]["name"] for s in SMOKE_JOB["steps"] + if str(s.get("uses", "")).startswith("actions/download-artifact@")] + assert downloads == ["conductloop-linux-appimage", "conductloop-linux-deb"] + runs = "\n".join(_runs(SMOKE_JOB)) + assert "linux-smoke.sh appimage bundles/appimage/" in runs and "linux-smoke.sh deb bundles/deb/" in runs + # A desktop has libEGL/libGL; the AppImage relies on them like every AppImage. + assert "libegl1" in runs and "libgl1" in runs + + +def test_a_failed_check_still_runs_the_other_and_keeps_the_screenshots(): + for job in (JOB, SMOKE_JOB): + steps = job["steps"] + deb = next(s for s in steps if "linux-smoke.sh deb" in str(s.get("run", ""))) + assert deb.get("if") == "${{ !cancelled() }}" + shots = [s for s in _uploads(job) if s["with"]["name"].startswith("conductloop-linux-smoke-")] + assert len(shots) == 1 and shots[0].get("if") == "${{ !cancelled() }}" + + +def test_the_smoke_script_only_reads_the_local_server_and_keeps_to_a_temporary_home(): + code = "\n".join(re.sub(r"(^|\s)#.*$", "", line) for line in SMOKE.read_text(encoding="utf-8").splitlines()) + hosts = set(re.findall(r"https?://([^/\s\"']+)", code)) + assert hosts == {"127.0.0.1:7777"}, hosts + for word in ("-X ", "--data", "-d ", "gh ", "git "): + assert word not in code, word + assert 'HOME="$TMP/home"' in code and "env -i" in code + assert "--appimage-extract-and-run" in code # no FUSE needed + + +@pytest.mark.posix_perms +def test_the_smoke_script_exists_and_is_executable(): + assert SMOKE.is_file() and SMOKE.stat().st_mode & 0o111 def test_it_publishes_nothing_and_takes_no_secret():