#!/usr/bin/env bash
# Integration test for the development environment.
#
# It drives `frogue-dev`, the same entry point `nix run .#dev` runs, with the
# same config and the same OTLP environment; drives a request through the
# server; and then asserts that the trace and the log records actually reached
# OpenObserve. Nothing here re-implements the environment, so it covers the
# topology *and* the way the app starts process-compose -- an app that binds
# something fixed fails here rather than only on whoever's machine happens to
# have that port taken.
#
# Run it with `nix run .#dev-test`. It needs podman, it really starts the
# container, and it uses the development ports, so nothing else may be holding
# 6565 or 5080 while it runs. `FROGUE_DEV_OBSERVE_URL` and
# `FROGUE_DEV_OBSERVE_AUTH` are exported by the wrapper, from the same bindings
# that configure the stack.
set -euo pipefail
if ! command -v frogue-dev >/dev/null; then
echo "dev-test: frogue-dev is not on PATH" >&2
echo "dev-test: run me through \`nix run .#dev-test\`" >&2
exit 1
fi
root=${FROGUE_DEV_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}
cd "$root"
# A connection attempt is the cheapest protocol-agnostic liveness probe, and
# unlike curl it is not fooled by a listener that speaks something other than
# HTTP -- which is exactly what the OTLP port is.
port_open() {
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
}
busy=""
for port in 6565 5080; do
if port_open "$port"; then
busy="$busy $port"
fi
done
if [ -n "$busy" ]; then
echo "dev-test: something is already listening on$busy; stop it first" >&2
exit 1
fi
log_dir=${TMPDIR:-/tmp}/frogue-dev-test
mkdir -p "$log_dir"
log=$log_dir/process-compose.log
# Kept apart from the stack's console output: pointing both at one file would
# leave two writers truncating each other.
console=$log_dir/console.log
echo "dev-test: starting the development stack (logs: $log_dir)"
frogue-dev --tui=false --log-file "$log" >"$console" 2>&1 &
pc_pid=$!
cleanup() {
kill "$pc_pid" 2>/dev/null || true
wait "$pc_pid" 2>/dev/null || true
# process-compose stops what it started, but if it was killed outright the
# container can outlive it and hold the ports.
podman rm --force frogue-dev-openobserve >/dev/null 2>&1 || true
}
trap cleanup EXIT
wait_for_http() {
local what=$1 url=$2 timeout=$3
local deadline=$((SECONDS + timeout))
while [ "$SECONDS" -lt "$deadline" ]; do
if curl --silent --fail --output /dev/null "$url"; then
echo "dev-test: $what is up"
return 0
fi
sleep 1
done
echo "dev-test: $what did not answer at $url within ${timeout}s" >&2
echo "dev-test: last lines of $console:" >&2
tail --lines=20 "$console" >&2 || true
return 1
}
# /healthz is unauthenticated, which is why it is the probe; the API behind it
# is not.
wait_for_http openobserve "${FROGUE_DEV_OBSERVE_URL}/healthz" 60
# Generous: the watcher compiles the whole workspace before it can serve
# anything, and that is minutes from cold.
wait_for_http frogue "http://127.0.0.1:6565/" 900
echo "dev-test: driving a request through the server"
curl --silent --output /dev/null "http://127.0.0.1:6565/"
# OpenObserve keeps no per-service index to list, so the assertion is a query
# against the stream instead. OTLP spans and log records land in the stream
# named after the org's default, and the emitting service is the `service_name`
# column rather than the stream name. The window is rebuilt on every attempt so
# that the query follows the data as the batch exporters deliver it.
search() {
local signal=$1 sql=$2 now from body
now=$(date +%s%6N)
from=$((now - 3600000000))
body=$(jq --null-input \
--arg sql "$sql" \
--argjson from "$from" \
--argjson to "$now" \
'{query: {sql: $sql, start_time: $from, end_time: $to, from: 0, size: 10}}')
curl --silent \
--header "Authorization: ${FROGUE_DEV_OBSERVE_AUTH}" \
--header 'Content-Type: application/json' \
--data "$body" \
"${FROGUE_DEV_OBSERVE_URL}/api/default/_search?type=${signal}"
}
# The export is batched, with a five second schedule delay, so this is expected
# to take a few seconds rather than being immediate.
wait_for_signal() {
local what=$1 signal=$2 sql=$3 check=$4 response=""
local deadline=$((SECONDS + 60))
while [ "$SECONDS" -lt "$deadline" ]; do
response=$(search "$signal" "$sql" || true)
if printf '%s' "$response" | jq --exit-status "$check" >/dev/null 2>&1; then
echo "dev-test: openobserve has frogue's $what"
return 0
fi
sleep 1
done
echo "dev-test: frogue's $what never reached openobserve; the search returned: $response" >&2
echo "dev-test: last lines of $console:" >&2
tail --lines=20 "$console" >&2 || true
return 1
}
wait_for_signal traces traces \
'SELECT service_name, count() AS spans FROM "default" GROUP BY service_name' \
'any(.hits[]; .service_name == "frogue")'
wait_for_signal logs logs \
'SELECT count(*) AS records FROM "default"' \
'.hits[0].records > 0'
# Auto-reload is the point of the watcher, so it is asserted rather than
# assumed: a change under either watched directory has to end in the server
# being restarted. The files are only touched, never modified -- only the mtime
# matters, so this leaves no trace in the working copy -- and the restart is
# seen through cargo, which prints its build line on every run. Watching that
# rather than the server's own startup line keeps the assertion independent of
# RUST_LOG, which the environment deliberately leaves to the caller.
expect_restart() {
local path=$1
# The dots stand in for the backticks cargo puts around `dev`: spelling
# them out would trip shellcheck's "expressions don't expand in single
# quotes" warning.
local pattern='Finished .dev. profile'
local before
before=$(grep --extended-regexp --count "$pattern" "$console" || true)
touch "$path"
local deadline=$((SECONDS + 300))
while [ "$SECONDS" -lt "$deadline" ]; do
if [ "$(grep --extended-regexp --count "$pattern" "$console" || true)" -gt "$before" ]; then
echo "dev-test: touching $path restarted the server"
return 0
fi
sleep 1
done
echo "dev-test: touching $path did not restart the server" >&2
echo "dev-test: last lines of $console:" >&2
tail --lines=20 "$console" >&2 || true
return 1
}
expect_restart frogue/src/main.rs
expect_restart frogue/src/lib.rs
wait_for_http "frogue after reloading" "http://127.0.0.1:6565/" 120
cleanup
trap - EXIT
# A leak here would be invisible in the happy path but would break the next
# run, so it is part of the test.
for _ in $(seq 1 20); do
busy=""
for port in 6565 5080; do
if port_open "$port"; then
busy="$busy $port"
fi
done
[ -z "$busy" ] && break
sleep 0.5
done
if [ -n "$busy" ]; then
echo "dev-test: ports still bound after shutdown:$busy" >&2
exit 1
fi
echo "dev-test: OK, traces and logs flow to openobserve and the stack shuts down cleanly"