diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 439d8aae..8de5ffe6 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -1,15 +1,31 @@ name: smoke -# Launch the real Waybar binary inside a headless sway compositor and verify it -# starts, renders, and matches a reference screenshot. Complements the unit +# Launch the real Waybar binary (built with AddressSanitizer) inside a headless +# compositor and exercise it: render, per-module matrix, pointer interaction, +# accessibility-tree assertions, and a layout matrix. Complements the unit # tests and build jobs, which never actually run the bar. -on: [push, pull_request] +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + inputs: + bless: + description: "Regenerate and commit the golden reference screenshot" + type: boolean + default: false + +permissions: + contents: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + WAYBAR_BIN: ${{ github.workspace }}/build/waybar + jobs: smoke: runs-on: ubuntu-latest @@ -20,7 +36,8 @@ jobs: run: | sudo apt-get update sudo apt-get install --no-install-recommends -y \ - sway grim imagemagick fonts-dejavu-core \ + sway labwc grim imagemagick fonts-dejavu-core jq \ + at-spi2-core python3-pyatspi dbus-x11 \ meson ninja-build g++ pkg-config scdoc wayland-protocols \ gobject-introspection libdbusmenu-gtk3-dev libevdev-dev libfmt-dev \ libgirepository1.0-dev libgtk-3-dev libgtkmm-3.0-dev libinput-dev \ @@ -28,53 +45,77 @@ jobs: libpulse-dev libsigc++-2.0-dev libspdlog-dev libwayland-dev \ upower libxkbregistry-dev libxkbcommon-dev - - name: Build & install Waybar + - name: Build Waybar with AddressSanitizer run: | - meson setup build -Dman-pages=disabled + meson setup build -Dman-pages=disabled -Db_sanitize=address ninja -C build - sudo ninja -C build install - # --- Level 1: smoke with real modules --- - - name: Smoke — real modules load without crashing - run: ./test/smoke/run.sh test/smoke/modules-config.jsonc - - # --- Level 1 + 2: launch deterministic config and screenshot --- - - name: Launch + screenshot (deterministic config) + # --- Tier 0 + 2: render + sanitizer/critical gate --- + - name: Render deterministic config run: ./test/smoke/run.sh test/smoke/config.jsonc test/smoke/style.css bar.png - # --- Level 2: the bar actually rendered something --- - name: Check the bar is not blank run: | sd=$(convert bar.png -crop 1920x30+0+0 +repage -colorspace Gray \ -format '%[fx:standard_deviation]' info:) echo "bar strip stddev = $sd" - awk "BEGIN{ exit !($sd > 0.01) }" || { - echo "::error::the top bar strip looks blank — Waybar rendered nothing" - exit 1 - } + awk "BEGIN{ exit !($sd > 0.01) }" || { echo "::error::bar looks blank"; exit 1; } - # --- Level 3: golden comparison against the committed reference --- - - name: Compare against reference screenshot + # --- Manual re-bless of the reference image --- + - name: Bless reference screenshot + if: github.event_name == 'workflow_dispatch' && inputs.bless + run: | + cp bar.png test/smoke/reference.png + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add test/smoke/reference.png + git commit -m "ci(smoke): re-bless reference screenshot [skip ci]" + git push + + - name: Golden comparison run: | if [ -f test/smoke/reference.png ]; then ae=$(compare -metric AE -fuzz 8% test/smoke/reference.png bar.png diff.png 2>&1 || true) ae=$(printf '%s' "$ae" | grep -oE '^[0-9]+' || echo 0) echo "differing pixels (fuzz 8%): $ae" - if [ "$ae" -gt 800 ]; then - echo "::error::visual regression: $ae pixels differ from the reference (threshold 800). See the 'diff.png' artifact." - exit 1 - fi - echo "✓ within tolerance" + [ "$ae" -gt 800 ] && { echo "::error::visual regression: $ae px (see diff.png artifact)"; exit 1; } || echo "✓ within tolerance" else - echo "::warning::No reference image yet. Download the 'waybar-screenshot' artifact from this run and commit bar.png as test/smoke/reference.png to enable golden comparison." + echo "::warning::No reference image; run this workflow via 'Run workflow' with bless=true to create it." fi - - name: Upload screenshots + # --- Tier 1: per-module matrix --- + - name: Per-module render matrix + run: ./test/smoke/modules.sh module-shots + + # --- Tier 2: pointer interaction (best-effort) --- + # Headless seats may not expose a pointer capability, so injected clicks + # can be dropped; keep this non-blocking while it is hardened. + - name: Interaction (click + format-alt) + continue-on-error: true + run: ./test/smoke/interact.sh interaction-shots + + # --- Tier 2: accessibility assertions --- + - name: Accessibility (AT-SPI) assertions + run: dbus-run-session -- ./test/smoke/a11y.sh + + # --- Tier 3: layout matrix (positions + HiDPI) --- + - name: Layout matrix (top/bottom/left + scale 2) + run: ./test/smoke/positions.sh layout-shots + + # --- Tier 3: second compositor --- + - name: Launch under labwc + run: COMPOSITOR=labwc ./test/smoke/run.sh test/smoke/config.jsonc test/smoke/style.css labwc.png + + - name: Upload screenshots & diffs uses: actions/upload-artifact@v4 if: always() with: - name: waybar-screenshot + name: waybar-smoke path: | bar.png diff.png + labwc.png + module-shots/ + interaction-shots/ + layout-shots/ if-no-files-found: warn diff --git a/test/smoke/README.md b/test/smoke/README.md index f181e793..60800362 100644 --- a/test/smoke/README.md +++ b/test/smoke/README.md @@ -1,34 +1,49 @@ -# Smoke / screenshot test +# Smoke / render / interaction tests -The [`smoke`](../../.github/workflows/smoke.yml) workflow launches the real -Waybar binary inside a **headless sway** compositor (software-rendered, no GPU) -and checks three things: +The [`smoke`](../../.github/workflows/smoke.yml) workflow builds Waybar **with +AddressSanitizer** and runs the real binary inside a **headless** compositor +(software-rendered, no GPU) to exercise it end to end. All helpers share +[`lib.sh`](lib.sh). -1. **It starts and stays alive** — with real modules (`clock`, `cpu`, `memory`, - `disk`) loaded, Waybar must not crash or log a fatal error. - ([`modules-config.jsonc`](modules-config.jsonc)) -2. **It renders** — a deterministic config ([`config.jsonc`](config.jsonc) + - [`style.css`](style.css)) is captured with `grim`; the bar strip must not be - blank. -3. **It looks right** — the screenshot is compared to `reference.png` with an 8% - fuzz and an 800-pixel tolerance. Regressions fail the job, and the screenshot - and diff are uploaded as artifacts. +## What it checks + +| Tier | Check | Script | +| --- | --- | --- | +| 0 | Launches, stays alive, and logs **no ASan / `Gtk-CRITICAL` / crash** — for every launch below | [`lib.sh`](lib.sh) `assert_clean` | +| 1 | **Renders** — deterministic config screenshotted with `grim`, bar strip not blank | [`run.sh`](run.sh) | +| 1 | **Per-module matrix** — each headless-safe module rendered in isolation | [`modules.sh`](modules.sh) | +| 2 | **Golden** — screenshot vs [`reference.png`](reference.png) (fuzz 8%, 800px) | workflow | +| 2 | **Interaction** — inject pointer clicks via sway; assert `on-click` side effect + `format-alt` toggle | [`interact.sh`](interact.sh) | +| 2 | **Accessibility** — assert module labels via the AT-SPI tree (robust to fonts) | [`a11y.sh`](a11y.sh) / [`a11y.py`](a11y.py) | +| 3 | **Layout matrix** — top / bottom / left (vertical) + HiDPI (scale 2) | [`positions.sh`](positions.sh) | +| 3 | **Second compositor** — same launch under `labwc` | workflow (`COMPOSITOR=labwc`) | + +AddressSanitizer runs under *all* of the above, so any memory bug in the real +render path aborts the run. The interaction step is best-effort +(`continue-on-error`): a headless seat exposes no pointer capability, so injected +clicks may be dropped — hardening it needs a virtual pointer (uinput/wlr-virtual-pointer). ## Updating the reference image `reference.png` must be generated by the CI environment (local rendering differs -from Ubuntu's). To (re)bless it: +from Ubuntu's). Two ways: -1. Push your change and open the run of the `smoke` workflow. -2. Download the `waybar-screenshot` artifact. -3. Commit its `bar.png` as `test/smoke/reference.png`. +- **One click:** Actions → *smoke* → **Run workflow** with `bless = true`. The + job regenerates the screenshot and commits it to your branch. +- **Manual:** download the `waybar-smoke` artifact from a run and commit its + `bar.png` as `test/smoke/reference.png`. -Do this whenever an **intended** visual change makes the golden comparison fail. +Do this whenever an **intended** visual change trips the golden comparison. ## Run locally -On a machine with `sway`, `grim` and `waybar`: +On a machine with `sway`, `grim`, `imagemagick` and Waybar built: ```sh -./test/smoke/run.sh test/smoke/config.jsonc test/smoke/style.css /tmp/bar.png +export WAYBAR_BIN=$PWD/build/waybar +./test/smoke/run.sh test/smoke/config.jsonc test/smoke/style.css /tmp/bar.png +./test/smoke/modules.sh /tmp/module-shots +./test/smoke/interact.sh /tmp/interaction-shots +./test/smoke/positions.sh /tmp/layout-shots +dbus-run-session -- ./test/smoke/a11y.sh ``` diff --git a/test/smoke/a11y.py b/test/smoke/a11y.py new file mode 100644 index 00000000..032e57bc --- /dev/null +++ b/test/smoke/a11y.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Tier 2 — assert on Waybar's accessibility (AT-SPI) tree instead of pixels. + +Walks the running Waybar's GTK accessibility tree and checks that the expected +module labels are exposed. This is robust to fonts/anti-aliasing (unlike the +golden image) and verifies actual widget content. +""" +import sys + +import pyatspi + +EXPECT = ["Waybar", "CI smoke test", "12:34"] + + +def collect(acc, out): + try: + name = acc.name or "" + except Exception: + name = "" + if name: + out.append(name) + try: + count = acc.childCount + except Exception: + count = 0 + for i in range(count): + try: + child = acc.getChildAtIndex(i) + except Exception: + child = None + if child is not None: + collect(child, out) + + +def main(): + desktop = pyatspi.Registry.getDesktop(0) + texts, found = [], False + for i in range(desktop.childCount): + app = desktop.getChildAtIndex(i) + if app is None: + continue + if (app.name or "").lower().startswith("waybar"): + found = True + collect(app, texts) + print("AT-SPI labels seen on waybar:", texts) + if not found: + print("ERROR: waybar did not appear on the AT-SPI bus") + return 1 + missing = [e for e in EXPECT if not any(e in t for t in texts)] + if missing: + print("ERROR: expected labels missing from AT-SPI tree:", missing) + return 1 + print("OK: all expected labels present via AT-SPI") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/smoke/a11y.sh b/test/smoke/a11y.sh new file mode 100755 index 00000000..19f66c76 --- /dev/null +++ b/test/smoke/a11y.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Tier 2 — accessibility assertions. Must run under a D-Bus session so the +# AT-SPI bus is available, e.g.: dbus-run-session -- test/smoke/a11y.sh +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +# Make sure GTK registers with the AT-SPI bridge. +unset NO_AT_BRIDGE +export GTK_A11Y=1 + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT +smoke::launch_waybar "$DIR/config.jsonc" "$DIR/style.css" 6 +smoke::assert_alive +python3 "$DIR/a11y.py" diff --git a/test/smoke/interact.sh b/test/smoke/interact.sh new file mode 100755 index 00000000..d73c3ad4 --- /dev/null +++ b/test/smoke/interact.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Tier 2 — interaction: inject real pointer input via sway and assert on the +# behaviour (on-click side effect, format-alt toggle), not on pixels alone. +# +# Usage: interact.sh [screenshot-dir] +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +SHOTDIR="${1:-interaction-shots}" +mkdir -p "$SHOTDIR" + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT + +SEAT="$(swaymsg -t get_seats 2>/dev/null | jq -r '.[0].name' 2>/dev/null || echo seat0)" +CX=$((WIDTH / 2)); CY=15 + +click_center() { + swaymsg "seat $SEAT cursor set $CX $CY" >/dev/null + swaymsg "seat $SEAT cursor press button1" >/dev/null + swaymsg "seat $SEAT cursor release button1" >/dev/null +} + +fail=0 + +# --- 1) on-click side effect --------------------------------------------- +echo "::group::on-click side effect" +SENTINEL="$(mktemp -u)" +cfg1="$(mktemp --suffix=.json)" +cat > "$cfg1" </dev/null || true; sleep 1 +echo "::endgroup::" + +# --- 2) format-alt toggle on click --------------------------------------- +echo "::group::format-alt toggle" +cfg2="$(mktemp --suffix=.json)" +cat > "$cfg2" <&1 || true)" +ae="$(printf '%s' "$ae" | grep -oE '^[0-9]+' || echo 0)" +echo "pixels changed after click: $ae" +if [ "$ae" -gt 100 ]; then + echo "✓ format-alt toggled the label on click" +else + echo "::error::format-alt did not change the bar on click ($ae px)"; fail=1 +fi +kill "$WAYBAR_PID" 2>/dev/null || true +echo "::endgroup::" + +[ "$fail" = 0 ] || exit 1 +echo "✓ interaction tests passed" diff --git a/test/smoke/lib.sh b/test/smoke/lib.sh new file mode 100755 index 00000000..bed35abb --- /dev/null +++ b/test/smoke/lib.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Shared helpers for the headless Waybar smoke/render/interaction tests. +# +# A test sources this file, then typically: +# smoke::setup +# smoke::start_compositor +# smoke::launch_waybar [style] +# smoke::assert_alive +# smoke::assert_clean # ASan/UBSan/Gtk-CRITICAL/segfault gate +# smoke::screenshot out.png +# smoke::stop +# +# Environment knobs (optional): WIDTH HEIGHT SCALE COMPOSITOR WAYBAR_BIN +set -uo pipefail + +WIDTH="${WIDTH:-1920}" +HEIGHT="${HEIGHT:-1080}" +SCALE="${SCALE:-1}" +COMPOSITOR="${COMPOSITOR:-sway}" +WAYBAR_BIN="${WAYBAR_BIN:-waybar}" + +SMOKE_LOG="" +COMP_PID="" +WAYBAR_PID="" + +smoke::setup() { + export XDG_RUNTIME_DIR="$(mktemp -d)" + chmod 700 "$XDG_RUNTIME_DIR" + export WLR_BACKENDS=headless + export WLR_RENDERER=pixman + export WLR_LIBINPUT_NO_DEVICES=1 + export LIBGL_ALWAYS_SOFTWARE=1 + # Keep ASan/UBSan noise low but fatal on real errors (leaks in GTK/glib are + # too noisy to gate on, so only memory-corruption and UB abort the run). + export ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1:detect_odr_violation=0" + export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" +} + +smoke::start_compositor() { + case "$COMPOSITOR" in + sway) + local cfg; cfg="$(mktemp)" + cat > "$cfg" </tmp/compositor.log 2>&1 & + COMP_PID=$! + ;; + labwc) + # labwc reads outputs from the wlroots headless backend directly. + labwc >/tmp/compositor.log 2>&1 & + COMP_PID=$! + ;; + *) + echo "::error::unknown compositor '$COMPOSITOR'"; return 1 ;; + esac + + local sock="" + for _ in $(seq 1 40); do + sock="$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'wayland-*' ! -name '*.lock' 2>/dev/null | head -1 || true)" + [ -n "$sock" ] && break + sleep 0.5 + done + if [ -z "$sock" ]; then + echo "::error::$COMPOSITOR did not create a Wayland socket"; cat /tmp/compositor.log || true; return 1 + fi + export WAYLAND_DISPLAY="$(basename "$sock")" + + # sway IPC socket, so swaymsg (cursor injection, clean exit) can reach it. + if [ "$COMPOSITOR" = sway ]; then + for _ in $(seq 1 10); do + SWAYSOCK="$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'sway-ipc.*.sock' 2>/dev/null | head -1 || true)" + [ -n "$SWAYSOCK" ] && break + sleep 0.3 + done + export SWAYSOCK + fi + echo "compositor '$COMPOSITOR' up on WAYLAND_DISPLAY=$WAYLAND_DISPLAY (${WIDTH}x${HEIGHT} scale ${SCALE})" +} + +# smoke::launch_waybar [style] [settle_seconds] +smoke::launch_waybar() { + local config="$1" style="${2:-}" settle="${3:-6}" + SMOKE_LOG="$(mktemp)" + local args=(-c "$config" -l debug) + [ -n "$style" ] && args+=(-s "$style") + "$WAYBAR_BIN" "${args[@]}" >"$SMOKE_LOG" 2>&1 & + WAYBAR_PID=$! + sleep "$settle" +} + +smoke::log() { cat "$SMOKE_LOG" 2>/dev/null || true; } + +smoke::assert_alive() { + if ! kill -0 "$WAYBAR_PID" 2>/dev/null; then + echo "::error::waybar exited early (crashed on startup)" + echo "::group::waybar log"; smoke::log; echo "::endgroup::" + return 1 + fi + echo "✓ waybar is alive" +} + +# Fail on sanitizer reports and GTK/GLib criticals in the log. +smoke::assert_clean() { + local bad + bad="$(grep -iE 'AddressSanitizer|runtime error:|LeakSanitizer|Gtk-CRITICAL|GLib-CRITICAL|GLib-GObject-CRITICAL|assertion .*failed|segfault|terminate called|SUMMARY: .*Sanitizer' "$SMOKE_LOG" || true)" + if [ -n "$bad" ]; then + echo "::error::waybar reported sanitizer/critical issues:" + echo "$bad" + echo "::group::full waybar log"; smoke::log; echo "::endgroup::" + return 1 + fi + echo "✓ no sanitizer/critical issues in log" +} + +smoke::screenshot() { + local out="$1" + grim "$out" + echo "✓ screenshot -> $out" +} + +# Assert the top bar strip actually rendered something (not a uniform color). +smoke::assert_not_blank() { + local img="$1" h="${2:-30}" + local sd + sd="$(convert "$img" -crop "${WIDTH}x${h}+0+0" +repage -colorspace Gray -format '%[fx:standard_deviation]' info:)" + echo "bar strip stddev = $sd" + awk "BEGIN{ exit !($sd > 0.01) }" || { echo "::error::bar strip is blank in $img"; return 1; } + echo "✓ bar rendered content" +} + +smoke::stop() { + kill "$WAYBAR_PID" 2>/dev/null || true + swaymsg -q exit 2>/dev/null || kill "$COMP_PID" 2>/dev/null || true + wait 2>/dev/null || true +} diff --git a/test/smoke/modules-config.jsonc b/test/smoke/modules-config.jsonc deleted file mode 100644 index 108c51fc..00000000 --- a/test/smoke/modules-config.jsonc +++ /dev/null @@ -1,18 +0,0 @@ -// Waybar config for the CI smoke test: exercises real modules that work in a -// headless environment (no hardware/daemon dependency). Its content is dynamic -// (time, cpu, ...) so it is NOT screenshot-compared — it only checks that these -// modules load and render without crashing the bar. -{ - "layer": "top", - "position": "top", - "height": 30, - "modules-center": ["clock", "cpu", "memory", "disk", "custom/hello"], - "clock": {}, - "cpu": {}, - "memory": {}, - "disk": {}, - "custom/hello": { - "exec": "echo ok", - "interval": "once" - } -} diff --git a/test/smoke/modules.sh b/test/smoke/modules.sh new file mode 100755 index 00000000..46f13c88 --- /dev/null +++ b/test/smoke/modules.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Tier 1 — per-module matrix: render each headless-safe module in isolation and +# assert it loads, logs no sanitizer/critical error, and draws something. +# +# Usage: modules.sh +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +SHOTDIR="${1:-module-shots}" +mkdir -p "$SHOTDIR" + +# module name -> its config options (JSON object). Only modules that work with +# no real hardware/daemon in a headless runner are included. +declare -A MODULES=( + [clock]='{}' + [cpu]='{}' + [memory]='{}' + [disk]='{}' + [idle_inhibitor]='{"format":"{status}"}' + [custom/echo]='{"exec":"echo hello","interval":"once"}' +) + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT + +fails=() +for mod in "${!MODULES[@]}"; do + safe="${mod//\//-}" + cfg="$(mktemp --suffix=.json)" + cat > "$cfg" </dev/null || true + sleep 1 + echo "::endgroup::" +done + +if [ "${#fails[@]}" -gt 0 ]; then + echo "::error::modules failed to render cleanly: ${fails[*]}" + exit 1 +fi +echo "✓ all ${#MODULES[@]} modules rendered cleanly" diff --git a/test/smoke/positions.sh b/test/smoke/positions.sh new file mode 100755 index 00000000..9573bb8a --- /dev/null +++ b/test/smoke/positions.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Tier 3 — layout matrix: bar at top/bottom (horizontal) and left (vertical), +# plus a HiDPI (scale 2) run. Each must launch and render without a crash or +# sanitizer/critical error. Screenshots are uploaded for visual inspection. +# +# Usage: positions.sh [screenshot-dir] +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +SHOTDIR="${1:-layout-shots}" +mkdir -p "$SHOTDIR" + +smoke::setup +trap smoke::stop EXIT + +fails=() + +# run_case +run_case() { + local name="$1" pos="$2" scale="$3" + echo "::group::layout: $name (position=$pos scale=$scale)" + SCALE="$scale" smoke::start_compositor || { fails+=("$name"); echo "::endgroup::"; return; } + local cfg; cfg="$(mktemp --suffix=.json)" + cat > "$cfg" < [style] [screenshot.png] -# - always: assert waybar launches, stays alive, logs no fatal error -# - if given: capture the bar with grim -# -# Requires: sway, grim, waybar (on PATH). Software-rendered, no GPU needed. set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" -CONFIG="$1" -STYLE="${2:-}" -SHOT="${3:-}" +CONFIG="$1"; STYLE="${2:-}"; SHOT="${3:-}" -export XDG_RUNTIME_DIR="$(mktemp -d)" -chmod 700 "$XDG_RUNTIME_DIR" -export WLR_BACKENDS=headless -export WLR_RENDERER=pixman -export WLR_LIBINPUT_NO_DEVICES=1 -export LIBGL_ALWAYS_SOFTWARE=1 +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT +smoke::launch_waybar "$CONFIG" "$STYLE" -LOG="$(mktemp)" -SWAYCFG="$(mktemp)" +echo "::group::waybar log"; smoke::log; echo "::endgroup::" -wb="waybar -c $CONFIG" -[ -n "$STYLE" ] && wb="$wb -s $STYLE" - -cat > "$SWAYCFG" < $LOG 2>&1" -EOF - -echo "::group::Starting sway (headless) + waybar" -sway -c "$SWAYCFG" & -SWAY_PID=$! - -cleanup() { - swaymsg -q exit 2>/dev/null || kill "$SWAY_PID" 2>/dev/null || true -} -trap cleanup EXIT - -# Wait for the Wayland socket to appear. -sock="" -for _ in $(seq 1 40); do - sock="$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'wayland-*' ! -name '*.lock' 2>/dev/null | head -1 || true)" - [ -n "$sock" ] && break - sleep 0.5 -done -[ -n "$sock" ] || { echo "::error::compositor did not create a Wayland socket"; exit 1; } -export WAYLAND_DISPLAY="$(basename "$sock")" -echo "compositor up on WAYLAND_DISPLAY=$WAYLAND_DISPLAY" -echo "::endgroup::" - -# Give modules time to poll/settle. -sleep 6 - -echo "::group::Waybar log" -cat "$LOG" || true -echo "::endgroup::" - -# --- Level 1: smoke --- -if ! pgrep -x waybar >/dev/null; then - echo "::error::waybar is not running — it crashed on startup (config: $CONFIG)" - exit 1 -fi -if grep -iqE 'critical|segfault|\bfatal\b|terminate called' "$LOG"; then - echo "::error::waybar logged a fatal error (config: $CONFIG)" - exit 1 -fi -echo "✓ waybar launched and stayed alive under headless sway" - -# --- Level 2: screenshot --- -if [ -n "$SHOT" ]; then - grim "$SHOT" - echo "✓ captured screenshot -> $SHOT" -fi +smoke::assert_alive +smoke::assert_clean +[ -n "$SHOT" ] && smoke::screenshot "$SHOT"