Files
davidandClaude Opus 5.5 bd82063bbb Offer to send the log to us when an install or update fails
When update or install.sh fails and there's a terminal, ask whether to
send its log to the CoreDesktop team, with the option of reading exactly
what would be sent first. Nothing is sent without a yes, and nothing is
asked without a terminal. The new report command sends a log later
(report, report FILE) or shows what would be sent (report --view).

Before sending, the log is cleaned of terminal codes, and the home
folder, user name, host name, email addresses, IPv4 addresses and
Tailscale names are replaced. CoreDesktop's version, the kernel's, the
failed steps and sibling versions go along with it. The request format
the receiver on federated.sh implements is described at the top of
report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:28:53 -07:00

240 lines
6.9 KiB
Bash

#!/usr/bin/env bats
load test_helper
# Each test runs the real update script from a fake CoreDesktop checkout in the
# test's HOME, with fake hooks in updates.d/ that log to $RAN when they run.
setup() {
# A checkout with nothing but the update script and the files it installs
common_setup
CD="$HOME/.local/share/coredesktop"
mkdir -p "$CD/updates.d" "$HOME/.local/bin"
cp "$REPO/update" "$CD/update"
for script in refresh sibling version configure audit report; do
echo '#!/bin/bash' > "$CD/$script"
done
export RAN="$BATS_TEST_TMPDIR/ran"
unset UPDATE_REEXECED UPDATE_PULL_FAILED UPDATE_FORCE UPDATE_LOG
stub clear
stub git 'exit "${GIT_STATUS:-0}"'
stub sudo 'exit "${SUDO_STATUS:-0}"'
stub df 'printf "Avail\n%s\n" "${DF_AVAIL:-104857600}"'
# Let go of bats' output pipes so the sudo keepalive's leftover sleep
# doesn't keep the test waiting
stub sleep 'exec > /dev/null 2>&1 3>&-; exec /usr/bin/sleep "$@"'
}
# hook NAME BODY: add updates.d/NAME.sh with BODY as its run_update
hook() {
printf '#!/bin/bash\nrun_update() {\n%s\n}\n' "$2" > "$CD/updates.d/$1.sh"
}
# plain_hook NAME BODY: a hook that does its work as it's sourced, with no
# run_update, like updates.d/12-migrations.sh
plain_hook() {
printf '#!/bin/bash\n%s\n' "$2" > "$CD/updates.d/$1.sh"
}
ran() {
[ "$(cat "$RAN" 2> /dev/null)" = "$(printf '%s\n' "$@")" ]
}
@test "update pulls, installs its scripts, and runs every hook in order" {
hook 20-b 'echo b >> "$RAN"'
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
[[ "$output" == *"Done."* ]]
called git -C "$CD" pull --ff-only
ran a b
for script in update refresh sibling version configure audit report; do
[ -x "$HOME/.local/bin/$script" ]
done
}
@test "update pulls only once, before re-running its new copy" {
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
[ "$(grep -c '^git ' "$STUB_LOG")" -eq 1 ]
ran a
}
@test "a failing hook doesn't stop the hooks after it" {
hook 10-a 'echo a >> "$RAN"'
hook 20-b 'echo b >> "$RAN"; return 1'
hook 30-c 'echo c >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"Done, with errors in: 20-b.sh"* ]]
ran a b c
}
@test "a failing hook without run_update fails the update too" {
plain_hook 10-a 'echo a >> "$RAN"; false'
plain_hook 20-b 'echo b >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"Done, with errors in: 10-a.sh"* ]]
ran a b
}
@test "a hook without run_update that succeeds doesn't fail the update" {
plain_hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
[[ "$output" == *"Done."* ]]
ran a
}
@test "a hook that exits doesn't end the update" {
hook 10-a 'echo a >> "$RAN"; exit 0'
hook 20-b 'echo b >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
ran a b
}
@test "a hook using set -e fails at its first failing command" {
hook 10-a 'set -e; echo a >> "$RAN"; false; echo after >> "$RAN"'
hook 20-b 'echo b >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"errors in: 10-a.sh"* ]]
ran a b
}
@test "a hook's is_installed and functions don't leak into the next hook" {
printf '%s\n' '#!/bin/bash' 'is_installed() { false; }' \
'run_update() { echo a >> "$RAN"; }' > "$CD/updates.d/10-a.sh"
# 10-a's is_installed must not stop 20-b, and its run_update must not run
# again for 30-c, which has none
printf '%s\n' '#!/bin/bash' 'run_update() { echo b >> "$RAN"; }' \
> "$CD/updates.d/20-b.sh"
printf '%s\n' '#!/bin/bash' 'true' > "$CD/updates.d/30-c.sh"
run "$CD/update"
[ "$status" -eq 0 ]
ran b
}
@test "update runs the hooks on disk when git pull fails, and reports it" {
export GIT_STATUS=1
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"could not pull"* ]]
[[ "$output" == *"errors in: git pull"* ]]
ran a
}
@test "update stops before any hook when / is low on space" {
export DF_AVAIL=1048576
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"needs at least 10 GiB"* ]]
[ ! -e "$RAN" ]
}
@test "UPDATE_FORCE skips the free space check" {
export DF_AVAIL=1048576 UPDATE_FORCE=1
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
ran a
}
@test "update stops when sudo fails" {
export SUDO_STATUS=1
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[ ! -e "$RAN" ]
}
@test "update logs its output to one file, across re-running its new copy" {
hook 10-a 'echo "hook a ran"'
run "$CD/update"
[ "$status" -eq 0 ]
logs=("$HOME"/.local/state/coredesktop/logs/update-*.log)
[ "${#logs[@]}" -eq 1 ]
grep -q "Updating CoreDesktop" "${logs[0]}"
grep -q "hook a ran" "${logs[0]}"
grep -q "^Done\.$" "${logs[0]}"
[ "$(stat -c %a "$HOME/.local/state/coredesktop/logs")" = 700 ]
}
@test "when a hook fails, update says where its log is" {
hook 10-a 'return 1'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"The full output is in $HOME/.local/state/coredesktop/logs/update-"*".log"* ]]
}
@test "when a step fails, update offers to send its log, naming what failed" {
printf '#!/bin/bash\nprintf "%%s\\n" "$@" > "$BATS_TEST_TMPDIR/report-args"\n' > "$CD/report"
hook 10-a 'return 1'
hook 20-b 'return 1'
run "$CD/update"
[ "$status" -eq 1 ]
log=("$HOME"/.local/state/coredesktop/logs/update-*.log)
[ "$(cat "$BATS_TEST_TMPDIR/report-args")" = "$(printf '%s\n' --offer "${log[0]}" 10-a.sh 20-b.sh)" ]
}
@test "update doesn't offer to send its log when nothing failed" {
printf '#!/bin/bash\ntouch "$BATS_TEST_TMPDIR/report-ran"\n' > "$CD/report"
hook 10-a 'true'
run "$CD/update"
[ "$status" -eq 0 ]
[ ! -e "$BATS_TEST_TMPDIR/report-ran" ]
}
@test "clean_log leaves only the text of a terminal session's log" {
eval "$(sed -n '/^clean_log() {/,/^}/p' "$REPO/update")"
log="$BATS_TEST_TMPDIR/session.log"
printf '%s\n' 'Script started on 2026-11-08 10:00:00-07:00 [COMMAND="update"]' > "$log"
printf '\033]0;title\007\033[1;32mgreen\033[0m Ünïcode █\r\n' >> "$log"
printf '10%%\r50%%\r100%%\r\n' >> "$log"
printf '\033[H\033[2J\033[3Jdone\r\n' >> "$log"
printf '%s\n' 'Script done on 2026-11-08 10:01:00-07:00 [COMMAND_EXIT_CODE="0"]' >> "$log"
clean_log "$log"
[ "$(cat "$log")" = "$(printf 'green Ünïcode █\n100%%\ndone')" ]
}
@test "an update from before 2026-10-19 that copies this one over itself hands over to it" {
# 013acac's update: sudo cp overwrites the file bash is still reading, and
# bash reads on in the new one from where it was
[ "$(/usr/bin/git -C "$REPO" rev-parse --is-shallow-repository)" = false ] \
|| skip "needs update's history"
/usr/bin/git -C "$REPO" show 013acac:update > "$HOME/.local/bin/update"
chmod +x "$HOME/.local/bin/update"
stub sudo '[ "$1" = cp ] && exec "$@"; exit 0'
hook 10-a 'echo a >> "$RAN"'
run "$HOME/.local/bin/update"
[ "$status" -eq 0 ]
[[ "$output" != *"command not found"* ]]
[[ "$output" == *"Done."* ]]
ran a
}