diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a96ce3e..c87ff42 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,13 @@ jobs: cs50/cli:amd64 cs50/cli:canary-amd64 + - name: Check out tests + uses: actions/checkout@v7 + + - name: Smoke-test linux/amd64 build + run: | + bash tests/smoke.sh cs50/cli:canary-amd64 + - name: Push linux/amd64 build to Docker Hub if: ${{ github.ref == 'refs/heads/main' }} run: | @@ -68,6 +75,13 @@ jobs: cs50/cli:arm64 cs50/cli:canary-arm64 + - name: Check out tests + uses: actions/checkout@v7 + + - name: Smoke-test linux/arm64 build + run: | + bash tests/smoke.sh cs50/cli:canary-arm64 + - name: Push linux/arm64 build to Docker Hub if: ${{ github.ref == 'refs/heads/main' }} run: | diff --git a/Dockerfile b/Dockerfile index c7e01e6..3ff4866 100644 --- a/Dockerfile +++ b/Dockerfile @@ -188,6 +188,7 @@ RUN apt update && \ apt install --no-install-recommends --no-install-suggests --yes \ astyle \ bash-completion \ + bsdextrautils `# For col, for help50` \ build-essential `# dpkg-dev, libc, gcc, g++, make, etc.` \ ca-certificates \ clang \ diff --git a/Makefile b/Makefile index 2d9b392..f000d0e 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,10 @@ rebuild: docker build --build-arg VCS_REF=$(shell git rev-parse HEAD) --no-cache --tag $(IMAGE) . run: - docker run --env LANG=$(LANG) --env LOCAL_WORKSPACE_FOLDER="$(PWD)" --env WORKDIR=/mnt --interactive --publish-all --rm --security-opt seccomp=unconfined --tty --volume "$(PWD)":/mnt --volume /var/run/docker.sock:/var/run/docker-host.sock --workdir /mnt cs50/cli bash --login || true + docker run --env LANG=$(LANG) --env LOCAL_WORKSPACE_FOLDER="$(PWD)" --env WORKDIR=/mnt --interactive --publish-all --rm --security-opt seccomp=unconfined --tty --volume "$(PWD)":/mnt --volume /var/run/docker.sock:/var/run/docker-host.sock --workdir /mnt $(IMAGE) bash --login || true + +smoke: + bash tests/smoke.sh $(IMAGE) squash: depends docker-squash --tag $(IMAGE) $(IMAGE) diff --git a/etc/profile.d/cli.sh b/etc/profile.d/cli.sh index 6a8dd61..d4efd2b 100644 --- a/etc/profile.d/cli.sh +++ b/etc/profile.d/cli.sh @@ -62,8 +62,8 @@ if [ `id -u` -ne 0 ]; then # Valgrind export VALGRIND_OPTS="--memcheck:leak-check=full --memcheck:show-leak-kinds=all --memcheck:track-origins=yes" - # Start help50 if enabled - if help50 is-enabled > /dev/null; then + # Start help50 if enabled (only for interactive shells with a terminal, else `script` would hang, e.g., in `bash --login -c`) + if [[ $- == *i* && -t 0 ]] && help50 is-enabled > /dev/null; then help50 start fi fi diff --git a/etc/profile.d/help50.sh b/etc/profile.d/help50.sh index 04dca38..05cddd4 100644 --- a/etc/profile.d/help50.sh +++ b/etc/profile.d/help50.sh @@ -90,7 +90,9 @@ function _help50() { if [[ -n "$help" ]]; then # If helpful _helpful "$help" elif [[ $status -ne 0 ]]; then # If helpless - _helpless "$typescript" + + # Cap what's relayed downstream (e.g., ddb50 rejects > 10,000 characters), keeping the end, where errors tend to be + _helpless "$(echo "$typescript" | tail -c 8192)" fi else _helped @@ -113,7 +115,7 @@ if ! type _helpful >/dev/null 2>&1; then # Intercept accidental invocation of `yes` and `n`, which are actual programs for name in n no y yes; do - alias $name=_rhetocial + alias $name=_rhetorical done # Output help diff --git a/opt/cs50/bin/valgrind b/opt/cs50/bin/valgrind index dd19aa8..f318aab 100755 --- a/opt/cs50/bin/valgrind +++ b/opt/cs50/bin/valgrind @@ -1,8 +1,10 @@ #!/bin/bash +. /opt/cs50/lib/cli + # If run on Python program if [[ "$1" == "python" || "$1" == *.py ]]; then - echo "$(_help "Afraid \`valgrind\` does not support Python programs!")" + _alert "$(_ansi "Afraid \`valgrind\` does not support Python programs!")" exit 1 fi diff --git a/opt/cs50/lib/cli b/opt/cs50/lib/cli index 9db45f2..6df5353 100644 --- a/opt/cs50/lib/cli +++ b/opt/cs50/lib/cli @@ -4,8 +4,8 @@ function _alert() { function _ansi() { - # If command-line arguments - if [[ -t 0 ]]; then + # If command-line arguments (don't test -t 0, else arguments are ignored whenever stdin is redirected) + if [[ $# -gt 0 ]]; then input="$*" # If standard input @@ -48,8 +48,8 @@ function _find() { function _fold() { - # If command-line arguments - if [[ -t 0 ]]; then + # If command-line arguments (don't test -t 0, else arguments are ignored whenever stdin is redirected) + if [[ $# -gt 0 ]]; then input="$*" # If standard input @@ -57,8 +57,9 @@ function _fold() { input=$(cat) fi - # Wrap long lines - local cols=$(tput cols) + # Wrap long lines (default to 80 columns if no terminal, e.g., when piped) + local cols=$(tput cols 2> /dev/null) + [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 echo "$input" | fold --spaces --width=$cols } diff --git a/opt/cs50/lib/help50/python b/opt/cs50/lib/help50/python index 1d8b3fd..c47be5d 100755 --- a/opt/cs50/lib/help50/python +++ b/opt/cs50/lib/help50/python @@ -35,8 +35,8 @@ fi regex="python: can't open file '(.*\.py)': \[Errno 2\] No such file or directory" if [[ "$output" =~ $regex ]]; then - # Relative path from $PWD - path=$(realpath --relative-to=. "${BASH_REMATCH[1]}") + # Relative path from $PWD (file doesn't exist, so don't require it to) + path=$(realpath --canonicalize-missing --relative-to=. "${BASH_REMATCH[1]}") # If command was `python baz.py` (i.e., without a dirname) if [[ -n "$path" && "$path" == $(basename "$path") ]]; then @@ -51,11 +51,12 @@ if [[ "$output" =~ $regex ]]; then fi # If command was `python bar/baz.py` (i.e., with a dirname) - if [[ -n "$path" && "$path" == $(basename "$path") ]]; then - dir=$(_find -type f "$path") + if [[ -n "$path" ]]; then + base=$(basename "$path") + dir=$(_find -type f "$base") echo -n "There isn't a file called \`$path\` in your current directory." if [[ ! -z "$dir" ]]; then - echo " Did you mean to \`cd $dir\` first?" + echo " Did you mean to run \`python $dir/$base\` instead?" else echo fi diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..41ac041 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Smoke-tests a built cs50/cli image. Usage: tests/smoke.sh [IMAGE] +# Each check has a timeout so that a regression that hangs the shell fails loudly. + +set -o errexit -o nounset -o pipefail + +IMAGE="${1:-cs50/cli}" +run() { timeout 60 docker run --rm "$@"; } + +echo "Checking $IMAGE" + +echo "- non-interactive login shell exits (help50 must not start without a terminal)" +run "$IMAGE" bash --login -c 'echo ok' | grep -qx ok +echo true | run --interactive "$IMAGE" bash --login + +echo "- runtime dependencies of help50 are installed" +run "$IMAGE" bash --login -c 'for c in ansi2txt col file script fold; do command -v "$c" > /dev/null || { echo "missing $c" >&2; exit 1; }; done' + +echo "- help50 controller is the Bash version, on PATH, and enabled by default" +run "$IMAGE" bash --login -c 'test "$(type -P help50)" = /opt/cs50/bin/help50 && help50 is-enabled && test "$(help50 status)" = stopped' > /dev/null + +echo "- wrappers print their message even when stdin is redirected" +run "$IMAGE" bash --login -c 'valgrind python x.py < /dev/null; test $? -eq 1' 2>&1 | grep -q 'does not support Python' + +echo "- _fold wraps without a terminal" +run "$IMAGE" bash --login -c '. /opt/cs50/lib/cli; TERM= _fold "$(printf "a %.0s" {1..100})"' | head -n 1 | grep -qE '^.{1,80}$' + +echo "OK"