Files
httrack/tests/91_webhttrack-directory.test
Xavier Roche 83079b788d Teardown must not decide a test's verdict (#792)
Under `set -e` a failing command in an EXIT trap becomes the script's exit status, so a hiccup while tearing down fixtures fails a test whose assertions all passed. That is what turned `57_local-proxy-connect.test` red on the Windows x64 leg of #765: five OK lines, no FAIL, exit 1. Every EXIT trap in the suite now runs teardown with errexit off, and the signal traps keep their own `trap` line, since sharing `set +e` with HUP/INT/QUIT/PIPE/TERM would leave errexit off for the rest of a signalled run and let a torn-down test still report success.

A `|| true` on the `rm` would have been smaller, but it throws away the only diagnostic, and the evidence does not say which teardown command failed: a blocked `rm -rf` on the Windows runner exits 1 and prints "Device or resource busy", while the log shows exit 1 and nothing at all. The sharing violation in the issue is the plausible mechanism rather than a confirmed one, so whatever it really is now prints its own error.

`99_teardown-status.test` pins the semantics both ways and scans the suite so a new test cannot reintroduce the shape, the leaky combined trap included. The `return 0` that three `cleanup()` bodies ended with never protected anything, since errexit fires at the failing command before it is reached.

Closes #773

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:24:23 +02:00

183 lines
5.7 KiB
Bash

#!/bin/bash
#
# fopen() succeeds on a directory on POSIX and reading one never reaches EOF; a
# FIFO blocks in fopen() instead. Either used to wedge the single-threaded server.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
distdir=${top_srcdir:-$(cd "${testdir}/.." && pwd)}
distdir=$(cd "${distdir}" && pwd)
fail() {
echo "FAIL: $*" >&2
exit 1
}
command -v htsserver >/dev/null || fail "no htsserver in PATH"
command -v python3 >/dev/null || {
echo "python3 not found; skipping" >&2
exit 77
}
srv=
log=$(mktemp)
base=$(mktemp -d)
cleanup() {
test -z "${srv}" || kill -9 "${srv}" 2>/dev/null || true
rm -f "${log}"
rm -rf "${base}"
}
trap 'set +e; cleanup' EXIT
trap cleanup HUP INT QUIT PIPE TERM
# First line only. A "| head -1" would close the pipe early and, under pipefail,
# SIGPIPE the producer into a spurious failure.
firstline() { echo "${1%%$'\n'*}"; }
freeport() {
python3 -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()'
}
# Echo the announced URL. Runs in a command substitution, so it is a
# subshell and cannot export the pid: the caller reads it back with srvpid.
start() {
local port url
port=$(freeport)
: >"${log}"
(
trap '' TERM TTOU
exec htsserver "${distdir}/" --port "${port}" >"${log}" 2>&1
) &
for _ in $(seq 1 40); do
url=$(sed -n 's/^URL=//p' "${log}" 2>/dev/null) && test -n "${url}" && break
sleep 0.25
done
test -n "${url:-}" || fail "htsserver did not come up: $(cat "${log}")"
echo "${url}"
}
# The server reports its own pid; the aliveness assertion below hangs off it.
srvpid() { firstline "$(sed -n 's/^PID=//p' "${log}")"; }
alive() { kill -0 "$1" 2>/dev/null; }
portof() { echo "${1##*:}" | tr -d /; }
# GET the path $2 from 127.0.0.1:$1, or POST the body $3 to / when $2 is empty.
# The socket timeout is what makes an unfixed tree fail rather than wedge CI.
request() {
python3 -c 'import socket, sys
port, path, body = int(sys.argv[1]), sys.argv[2], sys.argv[3]
if path:
req = "GET %s HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" % path
else:
req = ("POST / HTTP/1.0\r\nHost: 127.0.0.1\r\n"
"Content-type: application/x-www-form-urlencoded\r\n"
"Content-length: %d\r\n\r\n%s" % (len(body), body))
s = socket.create_connection(("127.0.0.1", port), 10)
s.settimeout(15)
s.sendall(req.encode())
out = b""
try:
while True:
b = s.recv(65536)
if not b:
break
out += b
except socket.timeout:
sys.stderr.write("timed out after %d bytes\n" % len(out))
sys.exit(9)
s.close()
sys.stdout.write(out.decode("latin-1"))' "$1" "$2" "$3"
}
# GET $2, or fail with $3 when the reply never comes.
get() {
local out
out=$(request "$1" "$2" "") || fail "$2 did not answer: $3"
echo "${out}"
}
post() { request "$1" "" "$2"; }
url=$(start)
port=$(portof "${url}")
srv=$(srvpid)
test -n "${srv}" || fail "htsserver did not report its pid"
# Needs no session id and no project: html/server is a directory of the install.
reply=$(get "${port}" /server/ "an unauthenticated request wedged the server")
case "${reply}" in
"HTTP/1.0 404 "*) ;;
*) fail "a GUI directory did not answer 404: $(firstline "${reply}")" ;;
esac
# Every request body is gated by the session id.
reply=$(get "${port}" /server/index.html "the GUI is not served")
sid=$(firstline "$(echo "${reply}" |
sed -n 's/.*name="sid" value="\([0-9a-f]*\)".*/\1/p')")
test "${#sid}" -eq 32 || fail "did not scrape a 32-hex sid (got '${sid}')"
mkdir -p "${base}/proj/sub"
echo "PROBEMARKER" >"${base}/proj/probe.txt"
# mkfifo and ln -s may not work on a Windows checkout, so both stay optional.
refuse=(/website/ /website/sub/)
if mkfifo "${base}/proj/fifo.txt" 2>/dev/null; then
refuse+=(/website/fifo.txt)
fi
ln -s probe.txt "${base}/proj/link.txt" 2>/dev/null || true
# step4's "save settings" flow registers the project without crawling, and that
# is what arms the /website/ root.
body="sid=${sid}&command=httrack&command_do=save&winprofile=x"
body="${body}&path=${base}&projname=proj"
post "${port}" "${body}" >/dev/null || fail "the save request did not answer"
test -f "${base}/proj/hts-cache/winprofile.ini" ||
fail "the project was not registered: $(cat "${log}")"
# Positive control: every assertion below would pass on a server serving nothing.
served() {
case "$(get "$1" "$2" "$3")" in
*PROBEMARKER*) ;;
*) fail "$3" ;;
esac
}
served "${port}" /website/probe.txt "the registered project's mirror is not served"
if test -L "${base}/proj/link.txt"; then
# The guard stats rather than lstats, so a symlinked mirror file still serves.
served "${port}" /website/link.txt "a symlink to a mirror file is not served"
fi
# /website/ is where the GUI's own "browse mirrored site" link points.
for path in "${refuse[@]}"; do
reply=$(get "${port}" "${path}" "the server wedged on ${path}")
case "${reply}" in
"HTTP/1.0 404 "*) ;;
*) fail "${path} did not answer 404: $(firstline "${reply}")" ;;
esac
done
# The accept loop is single-threaded: one spin denies every later request.
alive "${srv}" || fail "the server died: $(cat "${log}")"
served "${port}" /website/probe.txt "the server stopped answering after a directory request"
# This fopen() is reached before any guard, so its read loop must stop on ferror().
mkdir -p "${base}/proj2/hts-cache/winprofile.ini"
reply=$(post "${port}" "sid=${sid}&path=${base}&loadprojname=proj2") ||
fail "a directory winprofile.ini wedged the server"
case "${reply}" in
"HTTP/1.0 3"*) ;;
*) fail "loading a project did not redirect: $(firstline "${reply}")" ;;
esac
alive "${srv}" || fail "the server died: $(cat "${log}")"
served "${port}" /website/probe.txt "the server stopped answering after a project load"
echo "PASS"