Files
simplex-chat/scripts/ci/desktop-linux/backend.sh
T
spacedandy-agents-simplex 0176a83299 ci: restore the desktop backend's search request probe
A fresh client sends the directory the search the app sends and follows the cursor to the last page: each result must arrive once with its whole image, over at least two pages. The message search stays.
2026-10-16 21:01:29 +00:00

398 lines
16 KiB
Bash
Executable File

#!/bin/bash
# Test backend on the app runner: a chat relay and a directory service on the preset servers, with one group
# and one channel registered and approved by a CLI peer, and another CLI peer that must find both by sending the
# directory the search text, and a third that must find both with a search request, as the app does.
# Both have profile images as large as the apps make them, so their search results don't fit in one service
# response, and the search request must page through them.
# For SimpleX names, an SMP server on 127.0.0.1 resolves .testing names through a local resolver, and a CLI peer
# with an address on it claims the name seeded.testing.
# Usage:
# scripts/ci/desktop-linux/backend.sh start <backend .tar.gz> <work dir> writes the directory's short address to <work dir>/directory-link
# and the names server's address to <work dir>/names-server
# scripts/ci/desktop-linux/backend.sh stop <work dir> stops the servers, prints their logs
# The relay, the directory, the SMP server and the resolver keep running between start and stop.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
directory_name="SimpleX Directory"
admin=ci_admin
search_text="ci seed"
# the longest profile image data URI the apps make and the core accepts (maxProfileImageSize)
max_image_size=12500
# the largest service response body, compressed if it has to be (maxServiceBodyLength)
max_service_body=10737
name_label=seeded
resolver_port=8090
# Runs one chat command in a CLI client that stays online for <secs> after it; the output is in $work/out.
chat() {
local db=$1 secs=$2 cmd=$3
shift 3
local status=0
"$bin/simplex-chat" -d "$work/$db" -e "$cmd" -t "$secs" --execute-log all "$@" < /dev/null > "$work/out" 2>&1 || status=$?
cat "$work/out" >> "$work/$db-cli.log"
# commands with images are thousands of characters long
echo "::group::$db: ${cmd:0:200}"
cat "$work/out"
echo "::endgroup::"
return "$status"
}
admin() {
chat "$admin" "$1" "$2" --user-display-name "$admin"
}
expect_out() {
if ! grep -qE "$1" "$work/out"; then
echo "error: \"$1\" not in the output"
return 1
fi
}
# Each run keeps the client online for a few seconds, which also lets the handshakes it waits for progress.
until_shown() {
local client=$1 pattern=$2 secs=$3 cmd=$4
local deadline=$((SECONDS + secs))
until "$client" 5 "$cmd" && grep -qE "$pattern" "$work/out"; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "error: \"$pattern\" not shown by \"$cmd\" after $secs s"
return 1
fi
done
}
admin_until() {
until_shown admin "$@"
}
db_query() {
sqlite3 "$work/$1_chat.db" "$2"
}
# Prints a profile image data URI made as the apps make one: JPEG at quality 85, scaled down by the square root
# of its excess, at most halved per step, until it is at most max_image_size characters. The picture fills the
# whole square, a gradient between two shades of one colour with a large white letter; noise keeps JPEG from
# compressing it much, so the image ends up near the limit.
avatar() {
local top=$1 bottom=$2 letter=$3 source=$work/avatar-source.png file=$work/avatar.jpg size=512 uri font
font=$(fc-match -f '%{file}' 'DejaVu Sans:bold')
convert -size 512x512 gradient:"$top-$bottom" -seed 1 -attenuate 0.5 +noise Gaussian \
-font "$font" -pointsize 320 -fill white -gravity center -annotate +0+0 "$letter" "$source"
while :; do
convert "$source" -resize "${size}x${size}" -quality 85 "$file"
uri="data:image/jpg;base64,$(base64 -w 0 "$file")"
[ "${#uri}" -gt "$max_image_size" ] || break
size=$(awk -v s="$size" -v n="${#uri}" -v m="$max_image_size" 'BEGIN { r = sqrt(n / m); if (r > 2) r = 2; print int(s / r) }')
done
echo "avatar \"$letter\" over $top to $bottom: ${size}x${size} px, ${#uri} characters, mean colour $(convert "$file" -resize '1x1!' -format '%[pixel:p{0,0}]' info:)" >&2
echo "$uri"
}
start_relay() {
nohup "$bin/simplex-chat" -d "$work/relay" --relay --headless --user-display-name ci_relay < /dev/null > "$work/relay.log" 2>&1 &
echo $! > "$work/relay.pid"
local deadline=$((SECONDS + 120))
until grep -qE '^https://[^ ]+/r#' "$work/relay.log"; do
if ! kill -0 "$(cat "$work/relay.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then
echo "error: the relay did not create its address"
cat "$work/relay.log"
return 1
fi
sleep 1
done
relay_link=$(grep -m 1 -oE '^https://[^ ]+/r#[^ ]+' "$work/relay.log")
echo "relay address: $relay_link"
}
# The address is created here, like the directory tests do, with auto-accept on before the service subscribes to it.
create_directory() {
chat directory 1 "/ad pq_ratchet=on" --create-bot-display-name "$directory_name"
directory_link=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out")
case "$directory_link" in
https://*/a#*) ;;
*)
echo "error: no short directory address in the output"
return 1
;;
esac
chat directory 1 "/auto_accept on"
expect_out "^auto_accept on"
echo "$directory_link" > "$work/directory-link"
echo "directory address: $directory_link"
}
start_directory() {
# -y: the service adds its own tables to the database the CLI created, and would otherwise ask on stdin
nohup "$bin/simplex-directory-service" -d "$work/directory" -y --super-users "$1" < /dev/null >> "$work/directory.log" 2>&1 &
echo $! > "$work/directory.pid"
sleep 5
if ! kill -0 "$(cat "$work/directory.pid")" 2> /dev/null; then
echo "error: the directory service exited"
cat "$work/directory.log"
return 1
fi
}
stop_directory() {
local pid deadline
pid=$(cat "$work/directory.pid")
kill -INT "$pid" 2> /dev/null || true
deadline=$((SECONDS + 30))
while kill -0 "$pid" 2> /dev/null; do
if [ "$SECONDS" -ge "$deadline" ]; then
kill -KILL "$pid" 2> /dev/null || true
fi
sleep 1
done
}
# A super user is known by contact ID, which exists only after the admin connects, so the service starts
# with nobody and is restarted with the admin.
connect_admin() {
admin 10 "/c $directory_link"
expect_out "connection request sent!"
admin_until "Welcome to $directory_name" 180 "/tail @'$directory_name' 10"
local id
id=$(db_query directory "SELECT contact_id FROM contacts WHERE local_display_name = '$admin'")
stop_directory
start_directory "$id:$admin"
echo "directory restarted with super user $id:$admin"
}
# The directory sends each registration to super users as a message with the exact /approve command.
approve() {
local name=$1 reply=$2
admin_until "/approve [0-9]+:$name [0-9]+" 300 "/tail @'$directory_name' 20"
local cmd
cmd=$(grep -oE "/approve [0-9]+:$name [0-9]+" "$work/out" | tail -n 1)
admin 5 "@'$directory_name' $cmd"
admin_until "$reply" 120 "/tail @'$directory_name' 20"
}
# The CLI's group commands can't set an image, so groups are created with the API commands, with the profiles
# that "/g" and "/public group" make plus the image.
group_json() {
local name=$1 descr=$2 image=$3 prefs=$4
echo "{\"displayName\":\"$name\",\"fullName\":\"\",\"shortDescr\":\"$descr\",\"image\":\"$image\",\"groupPreferences\":{\"directMessages\":{\"enable\":\"on\"},\"history\":{\"enable\":\"on\"}$prefs}}"
}
seed_group() {
local user_id
user_id=$(db_query "$admin" "SELECT user_id FROM users WHERE local_display_name = '$admin'")
group_image=$(avatar "rgb(235,80,70)" "rgb(150,25,30)" G)
admin 1 "/_group $user_id $(group_json ci_group "CI seeded group" "$group_image" "")"
expect_out "group #ci_group \(CI seeded group\) is created"
admin 5 "/a ci_group '$directory_name' admin"
expect_out "invitation to join the group #ci_group sent to"
approve ci_group "Group approved!"
}
seed_channel() {
admin 1 "/relays name=ci_relay $relay_link"
expect_out "^ok$"
local user_id relay_id
user_id=$(db_query "$admin" "SELECT user_id FROM users WHERE local_display_name = '$admin'")
relay_id=$(db_query "$admin" "SELECT chat_relay_id FROM chat_relays WHERE display_name = 'ci_relay' AND deleted = 0")
channel_image=$(avatar "rgb(80,200,90)" "rgb(25,120,45)" C)
admin 10 "/_public group $user_id $relay_id $(group_json ci_channel "CI seeded channel" "$channel_image" ",\"support\":{\"enable\":\"off\"}")"
expect_out "group #ci_channel \(CI seeded channel\) is created"
local group_id
group_id=$(db_query "$admin" "SELECT group_id FROM groups WHERE local_display_name = 'ci_channel'")
admin_until "relay id [0-9]+: active" 180 "/_get relays #$group_id"
admin 5 "/share chat #ci_channel @'$directory_name'"
expect_out "link to join channel #ci_channel"
approve ci_channel "Channel approved!"
}
# An SMP server on 127.0.0.1 whose [NAMES] section asks the local resolver, which answers from names.json.
start_names_server() {
local cfg=$work/smp/etc logs=$work/smp/var ini deadline
echo '{}' > "$work/names.json"
nohup python3 "$here/names-resolver.py" "$resolver_port" "$work/names.json" < /dev/null > "$work/resolver.log" 2>&1 &
echo $! > "$work/resolver.pid"
SMP_SERVER_CFG_PATH=$cfg SMP_SERVER_LOG_PATH=$logs "$bin/smp-server" init -y --ip 127.0.0.1 --no-password --disable-web
ini=$cfg/smp-server.ini
# only 5223: 443 needs root
sed -i -e 's/^port = 5223,443$/port = 5223/' \
-e '/^\[NAMES\]/,/^\[/{s/^enable: off$/enable: on/;s|^# resolver_endpoint: http://127.0.0.1:8000$|resolver_endpoint: http://127.0.0.1:'"$resolver_port"'|}' "$ini"
if ! grep -qx 'port = 5223' "$ini" ||
! sed -n '/^\[NAMES\]/,/^\[/p' "$ini" | grep -qx 'enable: on' ||
! sed -n '/^\[NAMES\]/,/^\[/p' "$ini" | grep -qx "resolver_endpoint: http://127.0.0.1:$resolver_port"; then
echo "error: $ini has a different port or [NAMES] than expected:"
grep -E '^port|^\[NAMES\]|enable|resolver' "$ini"
return 1
fi
SMP_SERVER_CFG_PATH=$cfg SMP_SERVER_LOG_PATH=$logs nohup "$bin/smp-server" start < /dev/null > "$work/smp-server.log" 2>&1 &
echo $! > "$work/smp-server.pid"
deadline=$((SECONDS + 60))
until (exec 3<> /dev/tcp/127.0.0.1/5223) 2> /dev/null; do
if ! kill -0 "$(cat "$work/smp-server.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then
echo "error: the SMP server is not listening on 5223"
cat "$work/smp-server.log"
return 1
fi
sleep 1
done
names_server="smp://$(cat "$cfg/fingerprint")@127.0.0.1"
echo "$names_server" > "$work/names-server"
echo "names server: $names_server"
}
# A CLI client started with -s uses only that server and, once its user exists, gives it every role, names included.
names_client() {
local db=$1
shift
chat "$db" "$@" --user-display-name "$db" -s "$names_server"
}
# ci_contact's address is on the names server; the resolver maps seeded.testing to it, and ci_contact claims the name.
seed_name() {
local image link
image=$(avatar "rgb(70,130,240)" "rgb(30,50,170)" N)
names_client ci_contact 1 "/_profile 1 {\"displayName\":\"ci_contact\",\"fullName\":\"\",\"shortDescr\":\"CI seeded contact\",\"image\":\"$image\"}"
names_client ci_contact 3 "/ad"
link=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out")
case "$link" in
https://*/a#*) ;;
*)
echo "error: no short address for ci_contact in the output"
return 1
;;
esac
jq -n --arg n "$name_label" --arg l "$link" '{($n): {simplexContact: [$l]}}' > "$work/names.json"
names_client ci_contact 5 "/_set domain 1 $name_label.testing"
# what the app does on the Search key, from a client that knows nothing but the names server; it finds the
# address only if ci_contact's claim is in the address data
names_client name_probe 1 "/user"
names_client name_probe 5 "/_connect plan 1 @$name_label.testing"
expect_out "contact address: ok to connect"
echo "@$name_label.testing resolves to ci_contact's address"
}
searcher() {
chat searcher "$1" "$2" --user-display-name ci_searcher
}
# A new user connects to the directory and sends the search text; it replies with the count, then one message per
# group or channel found, with its image.
search_by_message() {
searcher 10 "/c $directory_link"
expect_out "connection request sent!"
until_shown searcher "Welcome to $directory_name" 180 "/tail @'$directory_name' 10"
searcher 5 "@'$directory_name' $search_text"
until_shown searcher "Found 2 group\(s\)" 120 "/tail @'$directory_name' 10"
until_shown searcher "ci_group" 60 "/tail @'$directory_name' 10"
until_shown searcher "ci_channel" 60 "/tail @'$directory_name' 10"
echo "search for \"$search_text\" by message found ci_group and ci_channel"
}
page_summary() {
local resp=$1 compressed=""
if command -v zstd > /dev/null; then
compressed=", $(printf '%s' "$resp" | zstd -3 -q -c | wc -c) bytes zstd-compressed"
fi
jq -r '[.entries[] | "\(.displayName) (image: \(.image // "" | length) characters)"] | join(", ") | "entries: " + .' <<< "$resp"
echo "body: ${#resp} bytes$compressed, limit $max_service_body"
}
# What the app will send, from a client that never connected to the directory, following the cursor to the last page.
# The images make the results too large for one response, so the directory must split them, and each must arrive
# once and with its whole image.
probe_search() {
local cursor="" pages=0 started resp req
: > "$work/probe-entries"
while :; do
pages=$((pages + 1))
if [ "$pages" -gt 10 ]; then
echo "error: the search is still returning pages after 10"
return 1
fi
req="{\"type\":\"search\",\"searchText\":\"$search_text\"${cursor:+,\"searchCursor\":$cursor}}"
started=$SECONDS
chat probe 1 "/_service_request 1 $directory_link timeout=30 $req" --user-display-name ci_probe
resp=$(sed -n 's/^service response: //p' "$work/out")
if [ -z "$resp" ]; then
echo "error: no service response for page $pages"
return 1
fi
echo "page $pages, in $((SECONDS - started)) s:"
page_summary "$resp"
jq -r '.entries[] | "\(.displayName) \(.image // "" | length)"' <<< "$resp" >> "$work/probe-entries"
cursor=$(jq -c '.searchCursor // empty' <<< "$resp")
[ -n "$cursor" ] || break
done
local expected="ci_channel ${#channel_image}
ci_group ${#group_image}"
if [ "$(sort "$work/probe-entries")" != "$expected" ]; then
echo "error: expected each result once with its whole image (name, image length):"
echo "$expected"
echo "got:"
sort "$work/probe-entries"
return 1
fi
if [ "$pages" -lt 2 ]; then
echo "error: the results fit in one response, so the search no longer tests paging"
return 1
fi
echo "search for \"$search_text\" found ci_group and ci_channel with their images, in $pages pages"
}
dump_directory() {
echo "::group::directory registrations"
db_query directory "SELECT group_id, group_reg_status FROM sx_directory_group_regs" || true
echo "::endgroup::"
}
start() {
bin=$work/bin
mkdir -p "$bin"
tar -C "$bin" -xzf "$1"
"$bin/simplex-chat" --version
"$bin/simplex-directory-service" --version
start_relay
create_directory
start_directory "0:nobody"
trap dump_directory EXIT
connect_admin
seed_group
seed_channel
search_by_message
probe_search
start_names_server
seed_name
}
stop() {
local p
for p in directory relay smp-server resolver; do
if [ -f "$work/$p.pid" ]; then
kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true
fi
done
sleep 3
for p in directory relay smp-server resolver; do
echo "::group::$p log"
cat "$work/$p.log" 2> /dev/null || true
echo "::endgroup::"
done
}
case "${1:-}" in
start)
work=$3
mkdir -p "$work"
start "$2"
;;
stop)
work=$2
stop
;;
*)
echo "usage: $0 start <backend .tar.gz> <work dir> | stop <work dir>"
exit 1
;;
esac