Skip to content

Commit 8acc535

Browse files
committed
Smoke-test the bash and fish hooks, not just zsh
Two of the three shipped hooks had no test. hook-zsh.sh becomes hook.sh <shell> and covers all three, checking both halves of the hook: a session directory means preexec fired, its done marker means postexec did. fish needs a pty. It raises fish_preexec only for a command typed at a terminal, so fed from a pipe it runs the command with no event and the hook looks broken when nothing is wrong with it. script(1) gives it one. The hook itself turned out to be fine, so the README no longer calls it untested. Installing the shells has to happen before make test, since each smoke test skips itself when its shell is missing and would otherwise pass while testing nothing.
1 parent bf01429 commit 8acc535

6 files changed

Lines changed: 122 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@ jobs:
2323
run: go vet ./...
2424
- name: build
2525
run: make
26+
# before the tests, not after: make test runs the hook smoke tests and
27+
# each one skips itself when its shell is missing, so installing these
28+
# late would have looked green while testing nothing
29+
- name: shells for the hook tests
30+
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh fish
2631
- name: unit and end-to-end tests
2732
run: make test
28-
- name: zsh hook smoke test
29-
run: |
30-
sudo apt-get update -qq && sudo apt-get install -y -qq zsh
31-
./test/hook-zsh.sh
3233

3334
musl:
3435
# glibc-isms in the shim would surface here; informational for now

CONTRIBUTING.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ make test # unit tests + end-to-end suite
99

1010
The e2e suite (`test/e2e.sh`) arms the shim the same way the shell hooks
1111
do and exercises real deletions, overwrites, renames, undo, redo, and
12-
gc against a temp directory. `test/hook-zsh.sh` smoke-tests the zsh hook
13-
in an isolated interactive shell.
12+
gc against a temp directory. `test/hook.sh <shell>` smoke-tests a real
13+
hook in an isolated interactive shell, for zsh, bash and fish; it skips
14+
itself when that shell is not installed. fish is driven through a pty,
15+
since it raises `fish_preexec` only for a command typed at a terminal.
1416

1517
Anything touching the shim (`shim/undo_shim.c`) should come with an e2e
1618
case. The shim runs inside every process a user launches: no output on

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ bin/undo: $(GO_SRC) go.mod
1717
test: all
1818
go test ./...
1919
./test/e2e.sh
20+
@# each skips itself when that shell is not installed
21+
./test/hook.sh zsh
22+
./test/hook.sh bash
23+
./test/hook.sh fish
2024

2125
install: all
2226
install -Dm755 bin/undo $(PREFIX)/bin/undo

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,9 @@ backups.
320320
Arch, openSUSE, ...). WSL2 counts, it is Linux.
321321
- **Alpine / musl**: build from source (`make install`, CI-tested); the
322322
prebuilt shim in releases targets glibc.
323-
- **Shells**: zsh, bash 5+, fish 3.4+ for the automatic hook. Any shell
324-
works with `undo run`. The fish hook is currently untested.
323+
- **Shells**: zsh, bash 5+, fish 3.4+ for the automatic hook, all three
324+
smoke-tested in CI against a real interactive shell. Any shell works
325+
with `undo run`.
325326
- **macOS**: not supported. SIP blocks library injection into system
326327
binaries, so a port could not cover `rm` and friends.
327328
- **Windows**: use it inside WSL2.

test/hook-zsh.sh

Lines changed: 0 additions & 30 deletions
This file was deleted.

test/hook.sh

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env bash
2+
# Smoke test for a shell hook: source it in an isolated interactive shell,
3+
# delete a file, and check a session was recorded and is undoable.
4+
#
5+
# test/hook.sh zsh|bash|fish
6+
#
7+
# This runs the hook the way a user gets it, through the shell's own
8+
# preexec/postexec machinery, which is the part e2e.sh cannot reach: it
9+
# arms the shim itself and never loads a hook at all.
10+
set -euo pipefail
11+
12+
sh=${1:?usage: hook.sh <zsh|bash|fish>}
13+
ROOT=$(cd "$(dirname "$0")/.." && pwd)
14+
WORK=$(mktemp -d)
15+
trap 'rm -rf "$WORK"' EXIT
16+
17+
mkdir -p "$WORK/store" "$WORK/play"
18+
echo "precious data" >"$WORK/play/file.txt"
19+
20+
command -v "$sh" >/dev/null || { echo "$sh not installed, skipped"; exit 0; }
21+
22+
# A developer running this already has undo in their own shell, and every
23+
# UNDO_* it exports would be inherited and tested instead of the repo.
24+
clean_env=(env -u LD_PRELOAD -u UNDO_SESSION -u UNDO_LIB -u UNDO_DATA_DIR
25+
-u UNDO_HOOK -u UNDO_IGNORE -u UNDO_KEEP)
26+
27+
# posix shells and fish disagree on everything about assignment, so the rc
28+
# is written per shell rather than shared
29+
case $sh in
30+
zsh | bash)
31+
mkdir -p "$WORK/rcdir"
32+
# zsh only reads $ZDOTDIR/.zshrc, bash takes any name via --rcfile
33+
cat >"$WORK/rcdir/.zshrc" <<EOF
34+
export UNDO_DATA_DIR=$WORK/store
35+
export UNDO_LIB=$ROOT/build/libundo.so
36+
export PATH=$ROOT/bin:\$PATH
37+
source $ROOT/shell/undo.$sh
38+
EOF
39+
;;
40+
fish)
41+
mkdir -p "$WORK/rcdir/fish"
42+
cat >"$WORK/rcdir/fish/config.fish" <<EOF
43+
set -gx UNDO_DATA_DIR $WORK/store
44+
set -gx UNDO_LIB $ROOT/build/libundo.so
45+
set -gx PATH $ROOT/bin \$PATH
46+
source $ROOT/shell/undo.fish
47+
EOF
48+
;;
49+
*)
50+
echo "unknown shell: $sh" >&2
51+
exit 2
52+
;;
53+
esac
54+
55+
cmds=$(printf 'rm %s/play/file.txt\nundo -y\ncat %s/play/file.txt\nexit\n' \
56+
"$WORK" "$WORK")
57+
58+
# The exit status is the last command's, not a verdict on the hook, and
59+
# fish hands back non-zero where the others do not. Judge the store and the
60+
# output below instead of dying here with nothing to show.
61+
case $sh in
62+
zsh)
63+
out=$(printf '%s\n' "$cmds" |
64+
"${clean_env[@]}" ZDOTDIR="$WORK/rcdir" zsh -i 2>&1 || true) ;;
65+
bash)
66+
out=$(printf '%s\n' "$cmds" |
67+
"${clean_env[@]}" bash --rcfile "$WORK/rcdir/.zshrc" -i 2>&1 || true) ;;
68+
fish)
69+
# fish only raises fish_preexec for a command typed at a terminal. Fed
70+
# from a pipe it runs the command and the event never fires, so the
71+
# hook looks broken when it is the test that is. script(1) gives it a
72+
# pty and forwards our stdin into it, which is close enough to typing.
73+
command -v script >/dev/null ||
74+
{ echo "script(1) not installed, skipped"; exit 0; }
75+
out=$(printf '%s\n' "$cmds" |
76+
"${clean_env[@]}" XDG_CONFIG_HOME="$WORK/rcdir" \
77+
script -qec "fish -i" /dev/null 2>&1 || true) ;;
78+
esac
79+
80+
fail() {
81+
echo "FAIL ($sh hook): $*" >&2
82+
echo "--- shell output ---" >&2
83+
echo "$out" >&2
84+
exit 1
85+
}
86+
87+
grep -q "precious data" <<<"$out" || fail "file not restored"
88+
89+
# The store proves which half of the hook ran. A session directory means
90+
# preexec fired; the done marker means postexec did, which is what puts
91+
# LD_PRELOAD back. Checking $LD_PRELOAD from a command cannot see this:
92+
# preexec has already armed it again by the time the command runs.
93+
shopt -s nullglob
94+
sessions=("$WORK"/store/sessions/*/)
95+
((${#sessions[@]} > 0)) || fail "no session recorded, preexec never fired"
96+
97+
# The session for the last command stays open by design: the shell exits
98+
# out from under it and postexec never gets to run. Any earlier one closing
99+
# is enough to show the second half of the hook works.
100+
closed=0
101+
for s in "${sessions[@]}"; do
102+
[[ -f "$s/done" ]] && closed=$((closed + 1))
103+
done
104+
((closed > 0)) || fail "no session got a done marker, postexec never fired"
105+
106+
echo "$sh hook smoke test passed ($closed/${#sessions[@]} sessions closed)"

0 commit comments

Comments
 (0)