diff --git a/.github/workflows/linux_and_macos.yml b/.github/workflows/linux_and_macos.yml index 65e9b82..0ccfd0f 100644 --- a/.github/workflows/linux_and_macos.yml +++ b/.github/workflows/linux_and_macos.yml @@ -64,6 +64,18 @@ jobs: libncurses-dev \ pkg-config + - name: Install build dependencies + if: "${{ runner.os == 'macOS' }}" + run: |- + brew tap homebrew/cask-fonts + brew install \ + agg \ + asciinema \ + coreutils \ + fontconfig \ + font-liberation \ + imagemagick + - name: Install build dependency Clang ${{ matrix.clang_major_version }} if: "${{ runner.os == 'Linux' && contains(matrix.cxx, 'clang') }}" run: |- @@ -99,6 +111,51 @@ jobs: make uninstall DESTDIR="${PWD}"/ROOT/ [[ "$(find ROOT/ -not -type d | tee /dev/stderr)" == '' ]] # i.e. fail CI if leftover files + - name: 'Run UI tests' + if: "${{ runner.os == 'macOS' }}" + run: |- + ./recordings/record.sh + + - name: 'Upload UI test renderings for inspection' + if: "${{ runner.os == 'macOS' }}" + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: ttyplot_ui_test_${{ matrix.runs-on }}_${{ github.sha }} + path: recordings/actual* + if-no-files-found: error + + - name: 'Evaluate UI test results' + if: "${{ runner.os == 'macOS' }}" + run: |- + set -o pipefail + + assert_images_equal_enough() { + local a="${1}" + local b="${2}" + local diff_output="${3}" + local dissimilarity="$(compare -metric DSSIM "${a}" "${b}" "${diff_output}" 2>&1)" + if ! python3 <<<"import sys; sys.exit(int(${dissimilarity} > 0.01))"; then + echo "Image \"${a}\" is not close enough of a match to image \"${b}\", dissimilarity is ${dissimilarity}." >&2 + return 1 + fi + true + } + + cd recordings/ + + error=0 + + for expected in expected*.png; do + actual=${expected/expected/actual} + diff=${expected/expected/diff} + + assert_images_equal_enough ${actual} ${expected} ${diff} || error=1 + done + + rm -fv actual*.* diff*.* + + exit ${error} + - name: 'Clean' run: |- set -x -o pipefail diff --git a/recordings/.gitignore b/recordings/.gitignore new file mode 100644 index 0000000..2d2ebc5 --- /dev/null +++ b/recordings/.gitignore @@ -0,0 +1,2 @@ +/actual* +/diff*.png diff --git a/recordings/expected-0.png b/recordings/expected-0.png new file mode 100644 index 0000000..f2bea8c Binary files /dev/null and b/recordings/expected-0.png differ diff --git a/recordings/expected-1.png b/recordings/expected-1.png new file mode 100644 index 0000000..f9486d9 Binary files /dev/null and b/recordings/expected-1.png differ diff --git a/recordings/expected-2.png b/recordings/expected-2.png new file mode 100644 index 0000000..ab95f9a Binary files /dev/null and b/recordings/expected-2.png differ diff --git a/recordings/expected-3.png b/recordings/expected-3.png new file mode 100644 index 0000000..1e018aa Binary files /dev/null and b/recordings/expected-3.png differ diff --git a/recordings/headless.py b/recordings/headless.py new file mode 100755 index 0000000..0491380 --- /dev/null +++ b/recordings/headless.py @@ -0,0 +1,44 @@ +#! /usr/bin/env python3 +# Copyright (c) 2015 by pyte authors and contributors +# Copyright (c) 2023 by Sebastian Pipping +# +# Licensed under LGPL v3, see pyte's LICENSE file for more details. +# +# Based on pyte's example "capture.py" +# https://raw.githubusercontent.com/selectel/pyte/master/examples/capture.py + +import os +import pty +import signal +import select +import sys + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit(0) + + p_pid, master_fd = pty.fork() + if p_pid == 0: # Child. + env = os.environ.copy() + env['TERM'] = 'xterm-256color' + os.execvpe(sys.argv[1], sys.argv[1:], env=env) + assert False # never gets here + + # Parent. + while True: + try: + [_master_fd], _w, _x = select.select([master_fd], [], []) + except (KeyboardInterrupt, # Stop right now! + ValueError): # Nothing to read. + break + + try: + data = os.read(master_fd, 1024) + except OSError: + break + + if not data: + break + + os.kill(p_pid, signal.SIGTERM) diff --git a/recordings/record.sh b/recordings/record.sh new file mode 100755 index 0000000..851c877 --- /dev/null +++ b/recordings/record.sh @@ -0,0 +1,45 @@ +#! /usr/bin/env bash +## +## Copyright (c) 2023 by Sebastian Pipping +## Apache License 2.0 +## + +set -e -u + +self_dir="$(dirname "$(realpath "$(which "$0")")")" +ttyplot_bin_dir="${self_dir}/.." # i.e. the local build +agg_bin_dir="${HOME}/.cargo/bin" # if a local build + +export PATH="${ttyplot_bin_dir}:${PATH}:${agg_bin_dir}" + +# Consistent clock display for reproducibility +export FAKETIME=yesplease + + +cd "${self_dir}" + +# Check and report on runtime requirements +which agg asciinema convert realpath timeout ttyplot + +# Enforce a diff on failure +rm -f actual*.* + +asciinema_args=( + --cols 90 + --rows 20 + -c 'timeout -s INT 3s sh -c "{ sleep 0.5; echo \"1 2 3 4\"; sleep 0.5; } | ttyplot -2 -c X"' + -t 'ttyplot waiting, drawing, and shutting down' +) + +./headless.py asciinema rec "${asciinema_args[@]}" actual.cast + +agg_args=( + --fps-cap 2 + --font-family 'Liberation Mono' +) + +agg "${agg_args[@]}" actual.cast actual.gif + +convert -coalesce actual.gif PNG8:actual.png + +ls -lh actual*.*