ci: test the iOS app in the simulator on request, with an optional Claude session (#7737)

This commit is contained in:
spaced4ndy
2026-10-16 17:33:50 +00:00
committed by GitHub
parent aaa388e570
commit 7c5b6bb3d7
19 changed files with 1929 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
# iOS app in the simulator on GitHub's runners
`.github/workflows/ios-sim.yml` builds the core library and the iOS app from the branch's code on a macOS runner, starts the app in an iPhone 17 simulator, and drives it through onboarding to the chat list with a scripted smoke test. On request it also runs a Claude session that follows a device test plan, and a test backend (relay, directory, names server, contacts) for tests that need servers and peers.
## Running it
The workflow runs only when asked, on any branch:
- **Dispatch** it from the Actions tab (workflow "ios-sim", "Run workflow"), with the inputs `claude` (also run the Claude session) and `backend` (also start the test backend), both off by default.
- **Push** a commit whose message carries a tag: `[app-ci]` runs the scripted tests of both apps (iOS and desktop), `[app-ci:ios]` only this one; `[claude-test]` (both apps) or `[claude-test:ios]` runs the scripted test and then the Claude session; add `[ci-backend]` to build and start the test backend. Only the pushed head commit counts, and a tag matches anywhere in its message, body included, so don't quote a tag in a message that shouldn't run it.
A push without a tag starts nothing and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Pushes to master and stable never start it, because a squash merge's message lists the merged commits' subjects with their tags; dispatch it there instead. Which branches may run the Claude session is decided only by the `app-sim-claude` environment's branch rule in the repository settings: on a branch it doesn't allow, a run that asks for Claude fails at the environment check, and because the app job holds the environment, its scripted test fails with it. Ask for Claude only on branches the rule allows.
Results: the job log, the job summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view <id> --log` and `gh run download <id> -n <artifact>`.
## How long it takes, what it costs
- `plan` (ubuntu, 15 s) names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change.
- `core` (macOS, 35–45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`).
- `backend` (macOS, 20–30 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`.
- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min, the backend start about 10 min when asked, Claude as long as its plan needs.
A run with reused builds and no extras takes about 26 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. Caches are evicted within hours (the repository is over its cache limit); the artifacts are what make runs fast.
## The Claude session
It runs after the smoke test, in the same job, on the simulator the smoke test leaves running, once per layout listed in `claude-scope`, with Claude Code installed from npm at a pinned version. The job then references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; jobs without Claude reference no environment. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it later holds the Claude token.
- `claude-scope`: the model, the layouts, the cases per layout, optional entries per layout, `max-turns` and `max-budget-usd` per session.
- `device-test-plan.md`: the plan (`| ID | Do | Expect |` tables). On master it is a three-case smoke plan; a feature branch replaces it with its own and names its cases in `claude-scope`.
- `claude-prompt.md`: what any session needs to know about the app, the `ui` helper and the test backend; `claude-test.sh` adds the `ui` help, the session's cases, layout, start state and limits, the seeded backend and the plan.
- `claude-guard.py`: a PreToolUse hook that lets through only one plain `ui` command per Bash call, since read-only commands run without asking in every permission mode and Bash inherits the token. Read, Write and Edit are allowed only in the session's own directory, so a session can't change `ui` or the scripts it runs. After each session `claude-test.sh` deletes the session's files if the token appears in them.
- `claude-usage.sh`: each session's turns, duration, tokens and cost "estimated at API prices" (Claude Code's own estimate, not a bill) to the job summary and `claude-usage.json`.
Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; a 50-case feature plan took 300–450 turns, 30–60 minutes and $1.1–1.7 per session, estimated at API prices. Every run spends the subscription's usage limits, which interactive sessions share.
## Driving the app: `ui`
`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, swipe, type, paste, keys, wait for text, find, screenshots with trees, switch the layout (one-hand UI or top bar), relaunch, text size, and the backend's directory (`ui` with no arguments lists them). It needs `SIM_UDID` and `UI_OUT`, which the smoke test exports to the job. Lessons kept in its comments and in the smoke test: text fields have no label (tap them by value), the keyboard capitalizes and autocorrects (compare typed text case-insensitively), the closed user picker is always in the tree, "Cancel" clears the search field and its "Close" image doesn't, the keyboard's keys are below the screen.
## The test backend
`backend.sh start <backend zip> <work dir>` (with `[ci-backend]` or the `backend` input) starts, on the runner, against the preset SMP and XFTP servers:
- a chat relay for channels, and a SimpleX Directory whose address it writes to `<work dir>/directory-link`;
- a CLI peer `ci_admin` that creates, registers and gets approved groups and channels with the profile JSON the app sends: `ci_group` and `ci_channel` ("ci seed"), `ci_cards`, six entries with app-size avatars made by `avatar.swift` ("ci avatar"), a long name, markdown in a description, and `cinews`, `cinews_digest`, `cinews_weekly`;
- a local SMP server with a `[NAMES]` section and `names-resolver.py`, the names `cinews.testing`, `ciboth.testing`, `cialice.testing` and `cibobby.testing` claimed by their owners, and the contacts cialice and Robert, online and accepting connections; its address goes to `<work dir>/names-server`;
- checks before the app starts: a search sent to the directory as a message must list `ci_group` and `ci_channel`, and `/_connect plan` must resolve each name from a fresh peer.
Not yet verified on master: the backend ran on `f/directory-search`'s code, and its checks were rewritten for master's (the directory answers searches sent as messages there; service requests need the feature branch).
It writes what it seeded to `<work dir>/seed.md` (the Claude sessions get it) and logs to `<work dir>/*.log`; `stop`, `directory-stop`, `directory-start` and `listings` are its other commands. The app reaches it through the simulator, which shares the runner's network; pointing the app at the names server is a few steps in Settings, described in `claude-prompt.md`.
## Other files
- `use-macos26-sdk.sh`: runner images from 20261013 add Command Line Tools with a macOS 27 SDK that Xcode 26.6's linker can't read; every macOS job points the default SDK back at macOS 26.5.
- `earlier-run.sh <artifact name>`: finds the newest run of this branch in this repository with that artifact; artifacts from forks and other branches are never used.
+87
View File
@@ -0,0 +1,87 @@
// Makes a group avatar the way the app makes profile images (resizeImageToStrSize in apps/ios/SimpleXChat/ImageUtils.swift):
// a JPEG at quality 0.85 drawn at scale 1, shrunk by sqrt(size / 12500), at most halved per step,
// until its data URI is at most 12500 characters. The picture is synthetic and photo-like: a gradient,
// discs and noise, with more noise for a higher detail.
// Usage: avatar <seed> <detail 0...1> <output file> [side in pixels, default 1024]
import CoreGraphics
import Foundation
import ImageIO
let maxDataSize = 12500
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
struct Random {
var state: UInt64
mutating func next() -> Double {
state = state &* 6364136223846793005 &+ 1442695040888963407
return Double(state >> 11) / Double(UInt64(1) << 53)
}
mutating func color(alpha: Double = 1) -> CGColor {
let components: [CGFloat] = [next(), next(), next(), alpha]
return CGColor(colorSpace: colorSpace, components: components)!
}
}
func context(_ width: Int, _ height: Int) -> CGContext {
CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
}
func picture(side: Int, detail: Double, random: inout Random) -> CGImage {
let ctx = context(side, side)
let gradient = CGGradient(colorsSpace: colorSpace, colors: [random.color(), random.color()] as CFArray, locations: [0, 1])!
ctx.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: 0, y: side), options: [])
for _ in 0..<24 {
let r = Double(side) * (0.05 + 0.25 * random.next())
let alpha = 0.4 + 0.5 * random.next()
ctx.setFillColor(random.color(alpha: alpha))
ctx.fillEllipse(in: CGRect(x: Double(side) * random.next() - r, y: Double(side) * random.next() - r, width: 2 * r, height: 2 * r))
}
let pixels = ctx.data!.bindMemory(to: UInt8.self, capacity: ctx.bytesPerRow * side)
let amplitude = 96 * detail
for y in 0..<side {
for x in 0..<side {
let i = y * ctx.bytesPerRow + x * 4
for c in 0..<3 {
let v = Double(pixels[i + c]) + amplitude * (random.next() - 0.5)
pixels[i + c] = UInt8(max(0, min(255, v)))
}
}
}
return ctx.makeImage()!
}
func jpegDataURI(_ image: CGImage) -> String {
let data = NSMutableData()
let destination = CGImageDestinationCreateWithData(data as CFMutableData, "public.jpeg" as CFString, 1, nil)!
CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.85] as CFDictionary)
CGImageDestinationFinalize(destination)
return "data:image/jpg;base64," + (data as Data).base64EncodedString()
}
func reduced(_ image: CGImage, ratio: Double) -> CGImage {
let width = Int(floor(Double(image.width) / ratio))
let height = Int(floor(Double(image.height) / ratio))
let ctx = context(width, height)
ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return ctx.makeImage()!
}
let args = CommandLine.arguments
guard args.count == 4 || args.count == 5, let seed = UInt64(args[1]), let detail = Double(args[2]), let side = Int(args.count == 5 ? args[4] : "1024") else {
print("usage: avatar <seed> <detail 0...1> <output file> [side in pixels, default 1024]")
exit(1)
}
var generator = Random(state: seed)
var image = picture(side: side, detail: detail, random: &generator)
var uri = jpegDataURI(image)
var steps = 0
while uri.count > maxDataSize {
image = reduced(image, ratio: min((Double(uri.count) / Double(maxDataSize)).squareRoot(), 2))
uri = jpegDataURI(image)
steps += 1
}
try uri.write(toFile: args[3], atomically: true, encoding: .utf8)
print("\(args[3]): \(image.width)x\(image.height) px after \(steps) steps, \(uri.count) characters")
+477
View File
@@ -0,0 +1,477 @@
#!/bin/bash
# Test backend on the app runner: a chat relay and a directory service on the preset servers, with groups and
# channels registered and approved by a CLI peer, and a search that must find them; and for SimpleX names,
# a local SMP server with a name resolver, names claimed by channels and by contacts that accept connections.
# Usage:
# scripts/ci/ios-sim/backend.sh start <backend zip> <work dir> writes the directory's short address to <work dir>/directory-link,
# the names server's address to <work dir>/names-server
# and what was seeded to <work dir>/seed.md
# scripts/ci/ios-sim/backend.sh stop <work dir> stops everything it started, prints the logs
# scripts/ci/ios-sim/backend.sh directory-stop <work dir> stops the directory only
# scripts/ci/ios-sim/backend.sh directory-start <work dir> starts it again
# scripts/ci/ios-sim/backend.sh listings <work dir> prints the directory's listed groups and channels
# Everything it starts keeps running between start and stop.
set -euo pipefail
directory_name="SimpleX Directory"
admin=ci_admin
search_text="ci seed"
avatar_search_text="ci avatar"
long_name=ci_long_name_entry_for_layout_truncation_checks
names_resolver_port=18080
# 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"
echo "::group::$db: $(cut -c1-200 <<< "$cmd")"
cat "$work/out"
echo "::endgroup::"
return "$status"
}
admin() {
chat "$admin" "$1" "$2" --user-display-name "$admin"
}
# A command that resolves a SimpleX name: given -s, the client uses that server alone, with every role, names included.
# -s also saves it among the user's servers, and later runs would put queues on it that the directory can't reach
# (it routes to unknown servers through a preset proxy), so it is then kept for names only.
named() {
local db=$1 user=$2 secs=$3 cmd=$4
local status=0
chat "$db" "$secs" "$cmd" --user-display-name "$user" -s "$names_server" || status=$?
db_query "$db" "UPDATE protocol_servers SET role_storage = 0, role_proxy = 0, role_names = 1 WHERE host = 'localhost'"
return "$status"
}
expect_out() {
if ! grep -qE "$1" "$work/out"; then
echo "error: \"$1\" not in the output"
return 1
fi
}
expect_no_error() {
if grep -qiE "error" "$work/out"; then
echo "error: the command failed"
return 1
fi
}
# Each run keeps the client online for a few seconds, which also lets the handshakes it waits for progress.
admin_until() {
local pattern=$1 secs=$2 cmd=$3
local deadline=$((SECONDS + secs))
until admin 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
}
db_query() {
sqlite3 "$work/$1_chat.db" "$2"
}
listings_query="SELECT gp.display_name AS name, gp.short_descr AS description, g.summary_current_members_count AS members, length(gp.image) AS image_characters
FROM sx_directory_group_regs r
JOIN groups g ON g.group_id = r.group_id
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
WHERE r.group_reg_status = 'active'
ORDER BY g.summary_current_members_count DESC, r.group_id"
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=$(new_address)
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"
echo "$1" > "$work/super-user"
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,
# and tells the owner "The group ID N (name) is approved".
approve() {
local name=$1
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 "\($name\) is approved" 120 "/tail @'$directory_name' 20"
}
# The profile the app sends to create a group or a channel (AddGroupView, AddChannelView), with an optional image file.
group_profile() {
local name=$1 descr=$2 prefs=$3 image_file=${4:-}
if [ -n "$image_file" ]; then
jq -cn --arg n "$name" --arg d "$descr" --argjson p "$prefs" --rawfile i "$image_file" '{displayName: $n, fullName: "", shortDescr: $d, image: $i, groupPreferences: $p}'
else
jq -cn --arg n "$name" --arg d "$descr" --argjson p "$prefs" '{displayName: $n, fullName: "", shortDescr: $d, groupPreferences: $p}'
fi
}
seed_group() {
local name=$1 descr=$2 image_file=${3:-}
admin 1 "/_group 1 $(group_profile "$name" "$descr" '{"history":{"enable":"on"}}' "$image_file")"
expect_out "group #$name .*is created"
admin 5 "/a $name '$directory_name' admin"
expect_out "invitation to join the group #$name sent to"
approve "$name"
}
setup_relay() {
admin 1 "/relays name=ci_relay $relay_link"
expect_out "^ok$"
relay_id=$(db_query "$admin" "SELECT chat_relay_id FROM chat_relays WHERE display_name = 'ci_relay' AND deleted = 0")
}
create_channel() {
local name=$1 descr=$2 image_file=${3:-}
admin 10 "/_public group 1 $relay_id $(group_profile "$name" "$descr" '{"history":{"enable":"on"},"support":{"enable":"off"}}' "$image_file")"
expect_out "group #$name .*is created"
local group_id
group_id=$(db_query "$admin" "SELECT group_id FROM groups WHERE local_display_name = '$name'")
admin_until "relay id [0-9]+: active" 180 "/_get relays #$group_id"
}
list_channel() {
local name=$1
admin 5 "/share chat #$name @'$directory_name'"
expect_out "link to join channel #$name"
approve "$name"
}
seed_channel() {
create_channel "$@"
list_channel "$1"
}
channel_link() {
admin 1 "/show link #$1" >&2
grep -m 1 -oE 'https://[^ ]+/c#[^ ]+' "$work/out"
}
# The short address that /ad printed.
new_address() {
local address
address=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out")
case "$address" in
https://*/a#*) echo "$address" ;;
*)
echo "error: no short address in the output" >&2
return 1
;;
esac
}
# Avatars at the size the app makes them (avatar.swift), with different amounts of detail;
# and small ones for ci_group, ci_channel and ci_cards, so that several fit in one size-limited search response.
make_avatars() {
xcrun swiftc -O -o "$work/avatar" "$(dirname "$0")/avatar.swift"
local i
for i in 1 2 3 4 5 6; do
"$work/avatar" "$i" "0.$((i + 2))" "$work/avatar-$i.txt"
done
for i in 7 8 9; do
"$work/avatar" "$i" 0 "$work/avatar-$i.txt" 64
done
}
seed_avatars() {
local i
for i in 1 2 3; do
seed_group "ci_avatar_g$i" "CI avatar group $i" "$work/avatar-$i.txt"
seed_channel "ci_avatar_c$i" "CI avatar channel $i" "$work/avatar-$((i + 3)).txt"
done
}
# A local SMP server whose [NAMES] section points at names-resolver.py, which answers from names.json.
start_names() {
echo "{}" > "$work/names.json"
nohup python3 "$(dirname "$0")/names-resolver.py" "$names_resolver_port" "$work/names.json" < /dev/null > "$work/names-resolver.log" 2>&1 &
echo $! > "$work/names-resolver.pid"
export SMP_SERVER_CFG_PATH=$work/smp-server/etc SMP_SERVER_LOG_PATH=$work/smp-server/log
"$bin/smp-server" init -y --disable-store-log --no-password --fqdn localhost --disable-web > "$work/out" 2>&1 || {
cat "$work/out"
return 1
}
cat "$work/out"
names_server=$(grep -m 1 -oE 'smp://[^ ]+@localhost' "$work/out")
sed -i '' \
-e 's/^enable: off$/enable: on/' \
-e "s|^# resolver_endpoint: http://127.0.0.1:8000\$|resolver_endpoint: http://127.0.0.1:$names_resolver_port|" \
-e 's/^port = 5223,443$/port = 5223/' \
"$SMP_SERVER_CFG_PATH/smp-server.ini"
grep -E '^(enable: on|resolver_endpoint|port = )' "$SMP_SERVER_CFG_PATH/smp-server.ini"
nohup "$bin/smp-server" start < /dev/null > "$work/smp-server.log" 2>&1 &
echo $! > "$work/smp-server.pid"
local deadline=$((SECONDS + 60))
until nc -z 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 names server did not start"
cat "$work/smp-server.log" "$work/names-resolver.log"
return 1
fi
sleep 1
done
echo "$names_server" > "$work/names-server"
echo "names server: $names_server"
}
register_name() {
local name=$1 contact=$2 channel=$3
jq --arg n "$name" --arg ct "$contact" --arg ch "$channel" \
'.[$n] = {simplexContact: ([$ct] | map(select(. != ""))), simplexChannel: ([$ch] | map(select(. != "")))}' \
"$work/names.json" > "$work/names.json.new"
mv "$work/names.json.new" "$work/names.json"
}
# A contact that claims a name and accepts every connection; it keeps running, so the app can connect to it.
start_contact() {
local db=$1 display_name=$2 name=$3
chat "$db" 1 "/ad" --user-display-name "$display_name"
local address
address=$(new_address)
chat "$db" 1 "/auto_accept on" --user-display-name "$display_name"
expect_out "^auto_accept on"
register_name "$name" "$address" ""
named "$db" "$display_name" 5 "/_set domain 1 $name"
expect_no_error
# --headless is for relays only; a command with a long -t keeps the client online, accepting contacts, for 6 hours
nohup "$bin/simplex-chat" -d "$work/$db" -e "/auto_accept on" -t 21600 --user-display-name "$display_name" < /dev/null > "$work/$db.log" 2>&1 &
echo $! > "$work/$db.pid"
}
# A name is claimed before its channel is listed: a profile change after approval hides it until approved again.
seed_names() {
local address link
create_channel cinews "CI news channel"
link=$(channel_link cinews)
register_name cinews.testing "" "$link"
named "$admin" "$admin" 5 "/public group access #cinews domain=cinews.testing"
expect_out "updated public group access: domain=cinews.testing"
list_channel cinews
seed_group cinews_digest "CI news digest"
seed_group cinews_weekly "CI news weekly"
create_channel ciboth "CI both channel"
admin 1 "/ad"
address=$(new_address)
link=$(channel_link ciboth)
register_name ciboth.testing "$address" "$link"
named "$admin" "$admin" 5 "/_set domain 1 ciboth.testing"
expect_no_error
named "$admin" "$admin" 5 "/public group access #ciboth domain=ciboth.testing"
expect_out "updated public group access: domain=ciboth.testing"
start_contact ci_alice cialice cialice.testing
start_contact ci_robert Robert cibobby.testing
}
# What the app will resolve, from a client that knows none of the names' owners.
probe_names() {
local target pattern
for target in "#cinews.testing|ok to connect via relays" "@cialice.testing|contact address: ok to connect" "@cibobby.testing|contact address: ok to connect" "ciboth.testing|You can also connect to @ciboth"; do
pattern=${target#*|}
target=${target%%|*}
named probe ci_probe 5 "/_connect plan 1 $target"
expect_out "$pattern"
done
named probe ci_probe 5 "/_connect plan 1 #cinone.testing" || true
echo "names resolve: cinews, cialice, cibobby, ciboth; cinone is not registered"
}
# The directory's reply to a search starts with "Found N group(s)"; earlier messages name the same groups.
in_search_reply() {
local reply
reply=$(sed -n '/Found [0-9]* group(s)/,$p' "$work/out")
grep -qF "$1" <<< "$reply"
}
# A search sent as a message, which any client can send.
probe_search() {
admin 5 "@'$directory_name' $search_text"
local deadline=$((SECONDS + 120))
until admin 5 "/tail @'$directory_name' 10" && in_search_reply "ci_group (CI seed group)" && in_search_reply "ci_channel (CI seed channel)"; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "error: the directory's reply to \"$search_text\" does not list ci_group and ci_channel"
return 1
fi
done
echo "search for \"$search_text\" found ci_group and ci_channel"
}
write_seed() {
{
echo "# Test directory"
echo
echo "Directory address: $directory_link"
echo
echo "## Listed groups and channels"
echo
echo "| Search with | Entries | Seeded for |"
echo "|---|---|---|"
echo "| \`$search_text\` | ci_group (group), ci_channel (channel), each with a small avatar (64 px) | a group and a channel to find |"
echo "| \`ci cards\` | ci_cards (channel, with a small avatar) | a channel to join, so that the profile has a conversation |"
echo "| \`$avatar_search_text\` | ci_avatar_g1…3 (groups), ci_avatar_c1…3 (channels), each with an avatar the size the app makes | images at the size limit |"
echo "| \`ci long\` | $long_name (group): a long name and a two-line description | truncation |"
echo "| \`ci markdown\` | ci_markdown (group): markdown in the description | markdown shown literally |"
echo "| \`cinews\` | cinews (channel, with the name cinews.testing), cinews_digest and cinews_weekly (groups) | a search that matches a SimpleX name and listed entries |"
echo
sqlite3 -markdown "$work/directory_chat.db" "$listings_query"
echo
echo "## SimpleX names"
echo
echo "Names server, to add in the app for resolving names: \`$names_server\`"
echo
echo "| Name | Resolves to |"
echo "|---|---|"
echo "| cinews.testing | the channel cinews, listed in the directory (\"CI news channel\") |"
echo "| ciboth.testing | a contact address (ci_admin) and the channel ciboth, not listed |"
echo "| cialice.testing | the contact cialice, online and accepting connections |"
echo "| cibobby.testing | the contact Robert (display name without the name), online and accepting connections |"
echo "| any other, e.g. cinone.testing | not registered |"
echo
} > "$work/seed.md"
}
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"
unzip -o -q "$1" -d "$bin"
"$bin/simplex-chat" --version
"$bin/simplex-directory-service" --version
"$bin/smp-server" --version
make_avatars
start_names
start_relay
create_directory
start_directory "0:nobody"
trap dump_directory EXIT
connect_admin
seed_group ci_group "CI seed group" "$work/avatar-7.txt"
setup_relay
seed_channel ci_channel "CI seed channel" "$work/avatar-8.txt"
seed_channel ci_cards "CI cards channel" "$work/avatar-9.txt"
seed_avatars
seed_group "$long_name" "CI long entry: a description long enough to take two lines in the directory results, to check how the row truncates"
seed_group ci_markdown "CI markdown *bold* _italic_ [x](https://example.com)"
seed_names
probe_search
probe_names
write_seed
}
stop() {
local p processes="directory relay ci_alice ci_robert smp-server names-resolver"
for p in $processes; do
if [ -f "$work/$p.pid" ]; then
kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true
fi
done
sleep 3
for p in $processes; 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
;;
directory-stop)
work=$2
stop_directory
;;
directory-start)
work=$2
bin=$work/bin
start_directory "$(cat "$work/super-user")"
;;
listings)
work=$2
sqlite3 -markdown "$work/directory_chat.db" "$listings_query"
;;
*)
echo "usage: $0 start <backend zip> <work dir> | stop <work dir> | directory-stop <work dir> | directory-start <work dir> | listings <work dir>"
exit 1
;;
esac
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Builds the CLI, the directory service and the SMP server (for SimpleX names) that the app job runs as its test backend.
# Usage: scripts/ci/ios-sim/build-backend.sh <output zip>
# They need the server-capable configuration (no client_library), as build.yml's macOS job builds the CLI.
set -euo pipefail
mkdir -p "$(dirname "$1")"
out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
openssl_dir=$(brew --prefix openssl@3)
ghc --version
cabal --version
du -sh ~/.cabal/store dist-newstyle || true
cat > cabal.project.local <<EOF
ignore-project: False
package simplexmq
extra-include-dirs: $openssl_dir/include
extra-lib-dirs: $openssl_dir/lib
package direct-sqlcipher
flags: +openssl
extra-include-dirs: $openssl_dir/include
extra-lib-dirs: $openssl_dir/lib
EOF
targets="exe:simplex-chat exe:simplex-directory-service"
cabal build -j $targets
# simplexmq's sdist leaves out apps/common/Web/static/.well-known, which smp-server embeds, so it is built from a clone
simplexmq=$(mktemp -d)/simplexmq
git clone --quiet --filter=blob:none https://github.com/simplex-chat/simplexmq.git "$simplexmq"
git -C "$simplexmq" checkout --quiet "$(awk '/simplexmq.git/ {getline; print $2; exit}' cabal.project)"
git -C "$simplexmq" submodule update --init --quiet
git -C "$simplexmq" log -1 --format='simplexmq %H %s'
git -C "$simplexmq" submodule status
cp cabal.project.local "$simplexmq/"
(cd "$simplexmq" && cabal build -j exe:smp-server)
pkg=$(mktemp -d)
for target in $targets; do
cp "$(cabal list-bin "$target")" "$pkg"
done
# in the clone, list-bin first prints git's output from syncing the source packages
cp "$(cd "$simplexmq" && cabal list-bin exe:smp-server | tail -n 1)" "$pkg"
otool -L "$pkg"/*
rm -f "$out"
(cd "$pkg" && zip -9 "$out" *)
ls -la "$out"
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# Builds the iOS core library package with cabal, as flake.nix builds aarch64-darwin-ios:lib:simplex-chat.
# Usage: scripts/ci/ios-sim/build-core.sh <output zip>
# The zip has the same files as the Hydra package that scripts/ios/prepare.sh expects.
set -euo pipefail
mkdir -p "$(dirname "$1")"
out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
ghc_ver=$(ghc --numeric-version)
version=$(sed -n 's/^version: *//p' simplex-chat.cabal)
openssl_dir=$(brew --prefix openssl@3)
ffi_dir=$(brew --prefix libffi)
gmp_dir=$(brew --prefix gmp)
ghc --version
cabal --version
echo "rts extra-libraries: $(ghc-pkg field rts extra-libraries --simple-output)"
echo "ghc-bignum extra-libraries: $(ghc-pkg field ghc-bignum extra-libraries --simple-output)"
find "$(ghc --print-libdir)" -name 'lib*ffi*.a' -o -name 'libgmp*.a' -o -name 'libHSrts*_thr.a'
du -sh ~/.cabal/store dist-newstyle || true
cat > cabal.project.local <<EOF
ignore-project: False
package simplex-chat
flags: +swift +client_library
static: True
shared: False
ghc-options: -threaded -DIOS
package simplexmq
flags: +swift +client_library +commoncrypto
extra-include-dirs: $openssl_dir/include
extra-lib-dirs: $openssl_dir/lib
package direct-sqlcipher
flags: +commoncrypto
package entropy
flags: +DoNotGetEntropy
EOF
cabal build -j lib:simplex-chat
build_dir=dist-newstyle/build/aarch64-osx/ghc-$ghc_ver/simplex-chat-$version/build
ls -la "$build_dir"/libHSsimplex-chat-*
ghc_lib=$(ls "$build_dir"/libHSsimplex-chat-*-ghc"$ghc_ver".a)
echo "Selected symbols defined in $ghc_lib:"
{ nm -gU "$ghc_lib" 2>/dev/null || true; } \
| awk '$NF ~ /^_(ffi_call|__gmpz_init|hs_init_with_rtsopts|chat_migrate_init_key|EVP_CIPHER_CTX_new|sqlite3_key)$/ {print " " $NF}' \
| sort -u
pkg=$(mktemp -d)
cp "$build_dir"/libHSsimplex-chat-*.a "$ffi_dir"/lib/libffi.a "$gmp_dir"/lib/libgmp.a "$gmp_dir"/lib/libgmpxx.a "$pkg"
chmod u+w "$pkg"/*.a
for f in "$pkg"/libHSsimplex-chat-*.a; do
strip -x "$f"
done
ls -la "$pkg"
rm -f "$out"
(cd "$pkg" && zip -9 "$out" *.a)
ls -la "$out"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
# PreToolUse hook for Claude on the runner: lets through only Bash commands that run the ui helper, with plain
# arguments, so the session has no general shell. Claude Code runs read-only commands such as cat, echo and env without
# asking in every permission mode, and Bash inherits CLAUDE_CODE_OAUTH_TOKEN, so permission rules alone are not enough.
# Exits 2 for anything else; Claude sees the message as the reason. Each decision is appended to CLAUDE_GUARD_LOG
# when it is set, as evidence that the hook ran.
import json
import os
import re
import sys
# nothing that chains, pipes, redirects, substitutes, expands or escapes
UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?")
call = json.load(sys.stdin)
command = (call.get("tool_input") or {}).get("command", "").strip()
allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command)
if os.environ.get("CLAUDE_GUARD_LOG"):
with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log:
log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n")
if not allowed:
print(f"Only the ui helper runs here, as one command: ui <command> [args]. Refused: {command[:200]}", file=sys.stderr)
sys.exit(2)
+59
View File
@@ -0,0 +1,59 @@
# Device test session: SimpleX Chat iOS
You are testing the SimpleX Chat iOS app in an iPhone 17 simulator (iOS 26.5, screen 402 x 874 points) on a GitHub Actions runner. You run the cases of the device test plan (at the end of this prompt) that the section "This session" names, in the layout it names, and write a report. You cannot change the app or the runner; anything wrong you find is a finding for the report.
## Tools
- **Bash runs only the `ui` helper**, one command per call: `ui tap "Create profile"`, `ui find Settings`. Any other program, a pipe, a redirection, `;` or `&&` is denied. Its commands are listed under "The ui commands" below.
- **Read, Write and Edit work only in the current directory.** Screenshots go to `shots/` (look at the `-view.jpg` files, they are smaller), and you write `report.md` here. This whole prompt, with the plan, is also `prompt.md` here: read it again when you are unsure, and after the conversation has been compacted.
## What you need to know about the app and the helper
- **Layouts.** With the one-hand UI (reachable toolbar, the default) the chat list is drawn from the bottom up: the toolbar (your profile image and the "Compose" pencil) is at the bottom, the search field just above it (y about 754), the list tags ("Add list" when there are no lists) above the field, and chats stack upwards from there. With the top bar the toolbar and the search field are at the top. A profile without conversations sees onboarding pages ("Talk to someone") in place of the list, without the search field. Stay in the layout the session names; the app starts in it, and `ui relaunch` keeps it.
- **Positions.** "Above" means a smaller y. Judge order and position from the tree's coordinates, and look at a screenshot when you judge layout (truncation, overlap, images).
- **The search field** has no label; tap it with `ui tap-value "Search or paste SimpleX link"` when empty, or by its text. To clear it, tap the field so it is focused, then "Cancel" next to it ("Cancel" exists only while the field is focused; it also hides the keyboard); tapping the "Close" image inside the field did not clear it in an earlier run. Type text as one quoted argument (`ui type "ci seed"`). The keyboard capitalizes the first letter and autocorrects words (an earlier run saw "ci" become "Ci" and a typed name become another word when a button was tapped), so check the field's value in the tree after typing, and before typing check that the field shows its placeholder. The keyboard's Search key is `ui key return`; the keyboard's keys are below the screen and not listed in the tree, and the screenshots do not show the keyboard.
- **Pasting**: `ui clipboard "<link>"`, tap the empty field once to focus it and once more to open its menu, then tap its "Paste" item (two elements carry that label: use `ui tap-xy` on the one `ui find Paste` shows in the menu).
- **Disabled elements** end with "disabled" in the tree.
- **Ambiguous labels.** When `ui tap` says several elements match a label (seen for "Paste", "Channels", "Account"), tap the one you mean with `ui tap-xy` at its centre, taken from `ui find`.
- **Hidden elements.** The tree always contains the closed user picker: the profile name, "Create SimpleX address", "Chat preferences", "Your chat profiles", "Use from desktop", "Settings", at coordinates on the screen even while it is hidden. Do not tap them unless you opened the picker by tapping your profile image in the toolbar (`Image "Account"`, 32 x 32; tap its centre with `ui tap-xy`).
- **Settings → Reset all hints:** open the picker, "Settings", scroll to the bottom, the app version row ("v…"), "Developer", "Reset all hints". Go back with the navigation back buttons and close Settings by swiping it down.
- **New chat** is the "Compose" button (the pencil) in the toolbar. With the top bar the toolbar is hidden while the search field is focused.
- **Joining** (the app's normal flow, from `NewChatView.swift`; an earlier session took its first step for a failure). Tapping a group or channel the profile does not know yet, from a link or a list, shows an alert with its name (and subscriber count for a channel) and "Cancel" / "Open channel" or "Open group": that is the start of joining, not a sign that the entry is known. "Open …" opens the chat, not yet joined, with "Tap Join channel" in it and a "Join channel" / "Join group" bar at the bottom; tapping that bar joins (a channel then shows "you are subscriber"; a group may stay "connecting"). A contact works the same way: "Open new chat", then the chat's connect button. An entry already opened, joined or connecting shows the same kind of alert, plus your role ("You are a subscriber", "You are a member") once joined. So each case that joins uses the entry named for it under "This session", which no earlier case has touched. Opened and joined chats appear in the chat list, and the profile is no longer "without conversations".
- **A second profile:** open the picker, "Your chat profiles", add a profile; a new profile has no conversations. Switch back to the first profile when done.
- **Text size:** `ui text-size accessibility-large`, and back to `ui text-size medium` afterwards. Only one phone size is available.
- **If the app gets stuck**, `ui relaunch` restarts it (search text is cleared, everything else stays).
- Waiting: prefer `ui wait <text> <secs>` over repeated trees; `ui wait` returns at once when the text is already shown.
## The test backend
When "This session" gives the test directory's address, the runner also runs a test backend: a chat relay, a SimpleX Directory with the groups and channels listed under "Test directory" below, a server that resolves SimpleX names, and contacts that accept connections. `ui directory stop|start` stops and starts the directory (after `ui directory start`, wait about 15 s before using it), and `ui listings` prints what it lists.
**SimpleX names**: the names server's address is under "This session"; the names it knows are under "Test directory" → "SimpleX names" (all end in `.testing`; a bare word of 5 or more letters is completed with `.testing`, so `cinews` is `cinews.testing`). Set the app up for it once, before the first case that needs names (if an earlier session in this run did it, check it instead: the server is listed under "Your servers"):
1. Open the picker (your profile image), "Settings", "Network & servers".
2. Under "Preset servers", open each operator ("SimpleX Chat", "Flux"), turn "To resolve names" off, and go back. Otherwise the app may ask the real servers, which do not know the test names.
3. "Your servers" → "Add server" → "Enter server manually". Put the names server's address in the address field by pasting it (typing it would let the keyboard capitalize it), check it in the tree, and go back.
4. In "Your servers", open the new server and set "To receive" to "no", "For private routing" to "no" and "To resolve names" to "yes" (each is a picker), then go back.
5. On "Network & servers", tap "Save servers" (and "Save" if asked). Close Settings.
## How to run the cases
- Run each case of the session's list, in the plan's order unless a case needs another first.
- The plan's tables (ID, Do, Expect) are at the end of `prompt.md` in the current directory: read the row before running a case and do exactly what it says. An earlier session did not find them and filed other actions under some IDs.
- For each case, do what the plan says, decide pass or fail against the plan's "Expect" column, and take at least one `ui shot` at the moment that shows the result, named after the case and layout without a number, for example `ui shot S1-reachable` (the helper numbers the files).
- Pass only what you saw: quote the evidence (element labels, values and coordinates from the tree, or what a screenshot shows). If an expectation cannot be checked here, say which part and why, and mark the case passed for the parts you checked only if those parts are the substance of the case; otherwise skipped.
- **Skip** any case that needs something this runner lacks, saying exactly what is missing. Do not skip a case only because it takes several steps.
- Other app bugs you notice are findings: write what you did, what you saw, what you expected, and the screenshot.
- Mind the session's turn limit under "This session": every `ui` command and every file edit is a turn.
## The report
Write `report.md` in the current directory as you go, so a session that is cut short still leaves its results. **Before the first case, create it with the Results table already holding one row for every case of the session's list, Result `todo`**, and fill a row in as soon as its case is done. Before each case, and always after the conversation has been compacted, read `report.md` to see which cases are left (and `prompt.md` for anything else you need). The session is finished only when no row says `todo`. Its sections:
1. `# Device test report: <layout>` and one line on the app state at the start.
2. `## Results`: a table with one row per case of the session's list: `| ID | Result | Evidence | Screenshots |`, Result being pass, fail or skipped (with the reason).
3. Any section the plan asks for.
4. `## Findings`: app bugs, each with steps, what you saw, what you expected, screenshots.
5. `## Harness notes`: anything about `ui` or the runner that got in the way.
End the session by making sure `report.md` is complete and no row says `todo`; your final message is a three-line summary: passed, failed and skipped counts, and the most important finding.
+8
View File
@@ -0,0 +1,8 @@
# What claude-test.sh runs: one Claude session per layout (reachable, top), each with its cases, and each session's bounds.
# A case runs in both layouts only when the layouts render it differently; the rest run in one.
# entries-<layout> (optional) tells that session which directory entries and names its cases use, so none meets an entry already known.
model: claude-haiku-5-5
layouts: reachable
cases-reachable: S1-S3
max-turns: 60
max-budget-usd: 3
+143
View File
@@ -0,0 +1,143 @@
#!/bin/bash
# Runs the device test plan with Claude Code on this runner after the smoke test, one session per layout,
# as claude-scope says. A session drives the app only through the ui helper, and reads and writes only its
# own directory <output dir>/<layout>: prompt.md, report.md, shots/, transcript.jsonl (stream-json).
# Usage: scripts/ci/ios-sim/claude-test.sh <output dir>
# Needs CLAUDE_CODE_OAUTH_TOKEN, SIM_UDID (the booted simulator with the app) and, when the test backend runs, BACKEND_DIR.
set -euo pipefail
token=${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set}
# only the claude processes get the token, nothing else this script starts
unset CLAUDE_CODE_OAUTH_TOKEN
mkdir -p "$1"
out=$(cd "$1" && pwd)
dir=$(cd "$(dirname "$0")" && pwd)
: "${SIM_UDID:?SIM_UDID must be the simulator UDID}"
BACKEND_DIR=${BACKEND_DIR:-}
scope() {
sed -n "s/^$1: *//p" "$dir/claude-scope"
}
model=$(scope model)
layouts=$(scope layouts)
max_turns=$(scope max-turns)
max_budget=$(scope max-budget-usd)
export PATH="$dir:$PATH" BACKEND_DIR APP_LOG=$out/app.log CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
if [ -n "$BACKEND_DIR" ]; then
cp "$BACKEND_DIR/seed.md" "$out/seed.md"
fi
settings=$out/settings.json
cat > "$settings" << EOF
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "$dir/claude-guard.py"}]}]}}
EOF
xcrun simctl spawn "$SIM_UDID" log stream --level debug --style compact --predicate 'process == "SimpleX"' > "$APP_LOG" 2>&1 &
log_pid=$!
trap 'kill "$log_pid" 2> /dev/null || true' EXIT
layout_name() {
case "$1" in
reachable) echo "the one-hand UI (reachable toolbar)" ;;
top) echo "the top bar" ;;
esac
}
write_prompt() {
local layout=$1 previous=$2
cat "$dir/claude-prompt.md"
echo
echo "## The ui commands"
echo
echo '```'
ui || true
echo '```'
echo
echo "## This session"
echo
echo "- Layout: $(layout_name "$layout")."
echo "- Cases: $(scope "cases-$layout")."
local entries
entries=$(scope "entries-$layout")
if [ -n "$entries" ]; then
echo "- Entries to use: $entries."
fi
if [ -n "$BACKEND_DIR" ]; then
echo "- The test directory's address: $(cat "$BACKEND_DIR/directory-link")."
echo "- Names server to add in the app: $(cat "$BACKEND_DIR/names-server")."
else
echo "- No test backend runs in this session."
fi
if [ -z "$previous" ]; then
echo "- At the start: the smoke test onboarded a profile (typed as \"citester\"; the keyboard may have autocorrected the name) and checked the chat list in both layouts; the profile has no conversations."
else
echo "- At the start: an earlier session ran the cases in $(layout_name "$previous") on this app, so the profile may have chats, other profiles may exist and hints may be changed; look at the state first."
fi
echo "- Limits: $max_turns turns and \$$max_budget (estimated at API prices). Keep report.md current as you go."
echo
if [ -n "$BACKEND_DIR" ]; then
cat "$out/seed.md"
echo
fi
cat "$dir/device-test-plan.md"
}
status=0
previous=""
for layout in $layouts; do
session=$out/$layout
mkdir -p "$session/shots"
ui layout "$layout"
write_prompt "$layout" "$previous" > "$session/prompt.md"
echo "::group::Claude session: $layout, $model, $max_turns turns, \$$max_budget"
session_status=0
# outside the session's directory, so the session cannot change it
export CLAUDE_GUARD_LOG=$out/$layout-guard.log
: > "$CLAUDE_GUARD_LOG"
(
cd "$session"
UI_OUT=$session/shots CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \
--model "$model" \
--effort high \
--restricted \
--tools Bash,Read,Write,Edit \
--allowedTools "Bash(ui)" "Bash(ui *)" "Read(/$session/**)" "Edit(/$session/**)" \
--permission-mode dontAsk \
--permission-prompts none \
--strict-mcp-config \
--max-turns "$max_turns" \
--max-budget-usd "$max_budget" \
--autocompact 200k \
--no-session-persistence \
--settings "$settings" \
--output-format stream-json \
--verbose \
< prompt.md > transcript.jsonl 2> stderr.log
) || session_status=$?
echo "claude exited with $session_status"
# the token must never be printed or uploaded, whatever the session did; grep reads it from a pipe, not from its arguments
if grep -rqF -f <(printf '%s\n' "$token") "$session" "$CLAUDE_GUARD_LOG"; then
rm -rf "$session" "$CLAUDE_GUARD_LOG"
echo "::error::the $layout session's files contained the token, so they were deleted before the upload"
session_status=1
else
tail -n 20 "$session/stderr.log"
if [ -f "$session/report.md" ]; then
cat "$session/report.md"
else
echo "no report.md"
fi
echo "guard hook: $(grep -c '^allow' "$CLAUDE_GUARD_LOG" || true) allowed, $(grep -c '^refuse' "$CLAUDE_GUARD_LOG" || true) refused"
grep '^refuse' "$CLAUDE_GUARD_LOG" || true
fi
echo "::endgroup::"
if [ "$session_status" -ne 0 ]; then
status=$session_status
fi
previous=$layout
done
exit "$status"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Reports what each Claude session of claude-test.sh spent, from the final result event of its transcript,
# to the job summary and to <output dir>/claude-usage.json. total_cost_usd is Claude Code's own estimate
# at API prices, not a bill.
# Usage: scripts/ci/ios-sim/claude-usage.sh <output dir>
set -euo pipefail
out=$1
summary=${GITHUB_STEP_SUMMARY:-/dev/stdout}
sessions=0
mkdir -p "$out"
{
echo "## Claude sessions"
echo
echo "| Session | Result | Turns | Duration | Input | Output | Cache read | Cache write | Per model, estimated at API prices | Total, estimated at API prices | Permission denials |"
echo "|---|---|---|---|---|---|---|---|---|---|---|"
} >> "$summary"
for transcript in "$out"/*/transcript.jsonl; do
[ -f "$transcript" ] || continue
session=$(basename "$(dirname "$transcript")")
sessions=$((sessions + 1))
result=$(jq -c -R 'fromjson? | select(.type == "result")' "$transcript" | tail -n 1)
if [ -z "$result" ]; then
echo "| $session | no result event: the session timed out or crashed | | | | | | | | | |" >> "$summary"
jq -n --arg s "$session" '{session: $s, result: "missing"}' > "$out/$session/usage.json"
continue
fi
jq --arg s "$session" '{session: $s, subtype, is_error, num_turns, duration_ms, usage: (.usage | {input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}), modelUsage, total_cost_usd, permission_denials: (.permission_denials // [] | length)}' <<< "$result" > "$out/$session/usage.json"
jq -r --arg s "$session" '"| \($s) | \(.subtype)\(if .is_error then " (error)" else "" end) | \(.num_turns) | \(.duration_ms / 60000 | floor) min | \(.usage.input_tokens) | \(.usage.output_tokens) | \(.usage.cache_read_input_tokens) | \(.usage.cache_creation_input_tokens) | \([.modelUsage // {} | to_entries[] | "\(.key) $\(.value.costUSD // 0 | . * 100 | round / 100)"] | join(", ")) | $\(.total_cost_usd // 0 | . * 100 | round / 100) | \(.permission_denials // [] | length) |"' <<< "$result" >> "$summary"
done
if [ "$sessions" -eq 0 ]; then
echo "No Claude session ran." >> "$summary"
echo "[]" > "$out/claude-usage.json"
else
jq -s '.' "$out"/*/usage.json > "$out/claude-usage.json"
fi
cat "$out/claude-usage.json"
+9
View File
@@ -0,0 +1,9 @@
# Device test plan: smoke test
The app as the scripted smoke test leaves it: a profile created through onboarding, without conversations. A branch that tests a feature replaces this plan with its own (same table format) and names its cases in `claude-scope`.
| ID | Do | Expect |
|----|----|--------|
| S1 | Look at the screen: `ui tree`, then `ui shot S1-<layout>` | The chat list of a profile without conversations: the heading "Talk to someone", the cards "Let someone connect to you" and "Connect via link or QR code", and the toolbar with your profile image (`Image "Account"`, 32 x 32) and "Compose": at the bottom of the screen with the one-hand UI, at the top with the top bar |
| S2 | Tap "Let someone connect to you", then "Back" | The page "Create your link" with the cards "Invite someone privately" and "Create your public address" and a "Back" button; after "Back", "Talk to someone" again |
| S3 | Open the profile picker by tapping the toolbar's profile image, tap "Settings", then close Settings by swiping it down | The picker shows your profile name and "Settings"; Settings opens with "Your settings" and "Network & servers"; after closing, "Talk to someone" again |
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Finds the newest run of this branch in this repository that uploaded an unexpired artifact with this name,
# and writes its id as run-id to GITHUB_OUTPUT. Needs GH_TOKEN with actions: read.
# Usage: scripts/ci/ios-sim/earlier-run.sh <artifact name>
# Artifacts from forks and other branches are not used: their workflows could upload anything under this name.
set -euo pipefail
name=$1
run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" --jq "
[.artifacts[]
| select((.expired | not) and .workflow_run.head_branch == \"$GITHUB_REF_NAME\" and .workflow_run.head_repository_id == .workflow_run.repository_id)]
| sort_by(.created_at) | last | .workflow_run.id // empty")
if [ -n "$run_id" ]; then
echo "$name: from run $run_id"
echo "run-id=$run_id" >> "$GITHUB_OUTPUT"
else
echo "$name: not built yet"
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Installs a pinned AXe release the way its Homebrew formula (cameroncooke/homebrew-axe) does, rather than the tap's
# latest version, because it runs in the job that later holds the Claude token. Puts axe on the job's PATH.
set -euo pipefail
version=1.8.0
sha256=87eb09cd49dfc74fa8f7f5a46a03dfe2f14518b01d819f6a4094eab0d344ea7f
dir=$RUNNER_TEMP/axe
mkdir -p "$dir/release"
curl --proto '=https' --tlsv1.2 -sSfL -o "$dir/axe.tar.gz" "https://github.com/cameroncooke/AXe/releases/download/v$version/AXe-macOS-homebrew-v$version.tar.gz"
echo "$sha256 $dir/axe.tar.gz" | shasum -a 256 -c
tar -xzf "$dir/axe.tar.gz" -C "$dir/release"
axe=$(find "$dir/release" -type f -name axe -print -quit)
for framework in "$(dirname "$axe")"/Frameworks/*.framework; do
codesign --force --sign - --timestamp=none "$framework"
done
codesign --force --sign - --timestamp=none "$axe"
"$axe" --version
dirname "$axe" >> "$GITHUB_PATH"
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
# The name resolver that the test smp-server's [NAMES] section points at, as simplexmq's Server/Names/HttpResolver.hs
# calls it: GET /v2/resolve/<query> answers a NameResponse for the query "[<keccak-256 of the lowercased label, hex>]<.tld>"
# (or the plain name, which older clients send), GET /health answers {}. Registered names come from a JSON file, read on every request, so names can be added while it runs:
# {"cinews.testing": {"simplexChannel": ["https://..."], "simplexContact": []}, ...}
# Every other name is answered as available.
# Usage: names-resolver.py <port> <names json>
import json
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROUND_CONSTANTS = [
0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008,
]
ROTATIONS = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
MASK = (1 << 64) - 1
def rol(v, n):
return ((v << n) | (v >> (64 - n))) & MASK if n else v
def keccak_f(a):
for rc in ROUND_CONSTANTS:
c = [a[x][0] ^ a[x][1] ^ a[x][2] ^ a[x][3] ^ a[x][4] for x in range(5)]
d = [c[(x - 1) % 5] ^ rol(c[(x + 1) % 5], 1) for x in range(5)]
a = [[a[x][y] ^ d[x] for y in range(5)] for x in range(5)]
b = [[0] * 5 for _ in range(5)]
for x in range(5):
for y in range(5):
b[y][(2 * x + 3 * y) % 5] = rol(a[x][y], ROTATIONS[x][y])
a = [[b[x][y] ^ (~b[(x + 1) % 5][y] & b[(x + 2) % 5][y]) for y in range(5)] for x in range(5)]
a[0][0] ^= rc
return a
# Keccak-256 as Ethereum uses it (padding 0x01, not SHA3's 0x06), which hashlib does not have
def keccak256(data):
rate = 136
padded = bytearray(data) + b"\x01" + bytes(-(len(data) + 1) % rate)
padded[-1] |= 0x80
a = [[0] * 5 for _ in range(5)]
for offset in range(0, len(padded), rate):
for i in range(rate // 8):
a[i % 5][i // 5] ^= int.from_bytes(padded[offset + 8 * i:offset + 8 * i + 8], "little")
a = keccak_f(a)
return b"".join(a[i % 5][i // 5].to_bytes(8, "little") for i in range(4))
def name_query(name):
label, tld = name.lower().split(".", 1)
return "[" + keccak256(label.encode()).hex() + "]." + tld
def name_response(names_file, query):
with open(names_file) as f:
names = json.load(f)
for name, links in names.items():
# hashed from SMP v22 (NQHash), the plain name before (NQDomain)
if query.lower() in (name_query(name), name.lower()):
record = {
"name": name,
"nickname": "",
"website": "",
"location": "",
"simplexContact": links.get("simplexContact", []),
"simplexChannel": links.get("simplexChannel", []),
"eth": None,
"btc": None,
"xmr": None,
"dot": None,
"owner": "",
"resolver": "",
}
return name, {"registration": {"type": "registered", "nameRecord": record}}
return None, {"registration": {"type": "available", "pricing": {"registrationPrices": {}, "basePrice": 1000, "minLabelLength": 1}}}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = urllib.parse.unquote(self.path)
if path == "/health":
self.reply(200, {})
elif path.startswith("/v2/resolve/"):
query = path[len("/v2/resolve/"):]
name, response = name_response(self.server.names_file, query)
print(f"resolve {query}: {name or 'not registered'}", flush=True)
self.reply(200, response)
else:
self.reply(404, {})
def reply(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, format, *args):
pass
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler)
server.names_file = sys.argv[2]
print(f"names resolver on 127.0.0.1:{sys.argv[1]}, names from {sys.argv[2]}", flush=True)
server.serve_forever()
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# Builds mac2ios, runs scripts/ios/prepare.sh on ~/Downloads/pkg-ios-aarch64-swift-json.zip,
# and fails unless every object in apps/ios/Libraries/sim is marked for the iOS simulator.
set -euo pipefail
mac2ios_rev=4dcb77d5ea896d749381806dfab5358851b08951
tools=$(mktemp -d)
for f in mac2ios.c ar.h macho.h; do
curl --proto '=https' --tlsv1.2 -sSfL -o "$tools/$f" "https://raw.githubusercontent.com/zw3rk/mobile-core-tools/$mac2ios_rev/$f"
done
cc -O2 -o "$tools/mac2ios" "$tools/mac2ios.c"
PATH="$tools:$PATH" scripts/ios/prepare.sh
for f in apps/ios/Libraries/sim/*.a; do
echo "$f: $(du -h "$f" | cut -f1), $(lipo -info "$f")"
platforms=$({ otool -l "$f" || true; } | awk '$1 == "platform" {print $2}' | sort | uniq -c)
echo "${platforms:- no LC_BUILD_VERSION found}"
if echo "$platforms" | awk 'NF {print $2}' | grep -vE '^(7|IOSSIMULATOR)$' > /dev/null; then
echo "error: $f has objects for a platform other than the iOS simulator"
exit 1
fi
done
+124
View File
@@ -0,0 +1,124 @@
#!/bin/bash
# Installs the app in a new iPhone simulator and runs the scripted UI cases with the ui helper: onboarding to the
# chat list with the one-hand UI (the default), then the chat list again with the top bar. It leaves the app in the one-hand UI.
# Usage: scripts/ci/ios-sim/smoke-test.sh <path to SimpleX.app> <output dir>
# Each step saves a screenshot and an accessibility tree to the output dir and prints the tree to the log.
# Later steps of the job get SIM_UDID and UI_OUT, with the simulator still running.
set -euo pipefail
app=$1
export UI_OUT=$2
bundle_id=chat.simplex.app
ui=$(dirname "$0")/ui
mkdir -p "$UI_OUT"
axe --version
runtime=com.apple.CoreSimulator.SimRuntime.iOS-$(xcrun --sdk iphonesimulator --show-sdk-version | tr . -)
SIM_UDID=$(xcrun simctl create ci-iphone com.apple.CoreSimulator.SimDeviceType.iPhone-17 "$runtime")
export SIM_UDID
echo "simulator $SIM_UDID, runtime $runtime"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "SIM_UDID=$SIM_UDID" >> "$GITHUB_ENV"
echo "UI_OUT=$UI_OUT" >> "$GITHUB_ENV"
fi
xcrun simctl boot "$SIM_UDID"
xcrun simctl bootstatus "$SIM_UDID" -b
open -a Simulator --args -CurrentDeviceUDID "$SIM_UDID"
xcrun simctl spawn "$SIM_UDID" log stream --level debug --style compact --predicate 'process == "SimpleX"' > "$UI_OUT/app.log" 2>&1 &
log_pid=$!
finish() {
local status=$?
"$ui" shot final || true
kill "$log_pid" || true
echo "::group::core start in the app log"
grep -E 'chatMigrateInit|startChat' "$UI_OUT/app.log" || true
echo "::endgroup::"
echo "::group::app log, last 300 lines"
tail -n 300 "$UI_OUT/app.log"
echo "::endgroup::"
find ~/Library/Logs/DiagnosticReports -name 'SimpleX*' -newer "$app" 2>/dev/null | while read -r report; do
echo "::group::crash report $report"
head -n 200 "$report"
echo "::endgroup::"
done || true
exit "$status"
}
trap finish EXIT
expect_in_tree() {
local tree
tree=$("$ui" tree)
if ! grep -q "$@" <<< "$tree"; then
echo "error: ${*: -1} is not in the accessibility tree"
exit 1
fi
}
# iOS asks once whether the app may send notifications. The tree sometimes shows the app under this alert rather than
# the alert (run 38895987656), so its Allow button is also looked for at its place.
allow_notifications() {
local deadline=$((SECONDS + 30)) element
while [ "$SECONDS" -lt "$deadline" ]; do
if "$ui" tap Allow 1; then
return
fi
element=$("$ui" point 275 518 || true)
if grep -qF '"Allow"' <<< "$element"; then
"$ui" tap-xy 275 518
return
fi
sleep 2
done
echo "no notifications alert to allow"
}
# a profile without conversations sees the onboarding pages in place of the list
expect_chat_list() {
expect_in_tree -F '"Talk to someone"'
expect_in_tree -F 'Button "Let someone connect to you"'
expect_in_tree -F 'Button "Connect via link or QR code"'
expect_in_tree -F 'Button "Compose"'
echo "in the $1 layout the chat list shows the onboarding pages and the toolbar"
}
xcrun simctl install "$SIM_UDID" "$app"
xcrun simctl launch "$SIM_UDID" "$bundle_id"
# onboarding to the chat list
"$ui" wait "Get started" 180
"$ui" shot first-screen
"$ui" tap "Get started"
"$ui" wait "Create profile" 30
"$ui" tap-value "Enter profile name..."
"$ui" type citester
"$ui" shot profile-name
"$ui" tap "Create profile"
"$ui" wait "Continue" 60
"$ui" shot your-network
"$ui" tap "Continue"
"$ui" wait "Accept" 30
"$ui" shot network-commitments
"$ui" tap "Accept"
sleep 5
"$ui" shot after-onboarding
allow_notifications
"$ui" shot notifications-allowed
"$ui" wait "Talk to someone" 60
"$ui" shot chat-list
expect_chat_list reachable
# ui layout checks that the toolbar moved
"$ui" layout top
"$ui" shot chat-list-top
expect_chat_list top
"$ui" layout reachable
echo "smoke test passed: onboarding reaches the chat list, which shows its onboarding pages and toolbar with the one-hand UI and with the top bar"
+237
View File
@@ -0,0 +1,237 @@
#!/bin/bash
# Drives the app in the simulator SIM_UDID with AXe, for the scripted cases and for an agent on the runner.
# Screenshots and accessibility trees go to UI_OUT.
#
# ui tap <label> [secs] tap the element with this exact accessibility label, waiting up to secs (10) for it
# ui tap-value <text> [secs] tap the element with this exact value: text fields have no label, only their placeholder or text
# ui tap-xy <x> <y> tap this point (points, as in the tree)
# ui swipe <x1> <y1> <x2> <y2> swipe from one point to the other, e.g. to scroll a list
# ui type <text> type into the focused field (US keyboard characters only)
# ui clipboard <text> put the text on the clipboard, to paste it with the text field's Paste menu
# ui key <return|delete> [count] press a keyboard key: return submits the field (the Search key), delete erases a character
# ui wait <text> [secs] wait up to secs (30) until a label or value contains the text (case-sensitive)
# ui find <text> print the elements whose label or value contains the text (any case)
# ui shot <name> save UI_OUT/NN-<name>.png, a smaller NN-<name>-view.jpg and NN-<name>.json, and print the tree
# ui tree print the tree, one element per line: type, label, value, top-left corner and size in points,
# and "disabled" for a disabled element
# ui point <x> <y> print the element at this point, also when it belongs to a system alert that the tree misses
# ui layout <reachable|top> restart the app with the one-hand UI (reachable toolbar) or the top bar
# ui relaunch restart the app
# ui text-size <category> set Dynamic Type: medium (the default), large, extra-large, extra-extra-large,
# extra-extra-extra-large, accessibility-medium ... accessibility-extra-extra-extra-large
# ui directory <stop|start> stop or start the test directory (searches fail while it is stopped)
# ui listings the groups and channels listed in the test directory
# ui requests how many directory searches the app sent and how many were answered (needs APP_LOG)
set -euo pipefail
# nothing this helper runs needs the token of the Claude session that calls it
unset CLAUDE_CODE_OAUTH_TOKEN
: "${SIM_UDID:?SIM_UDID must be the simulator UDID}"
bundle_id=chat.simplex.app
app_group=group.chat.simplex.app
# the toolbar's new chat button: shown with and without conversations, unlike the search field
toolbar_button=Compose
# Elements below the screen, such as the keyboard's keys, are counted rather than listed; the screen is the first
# element's frame, or the given height.
compact_tree() {
jq -r --argjson screen "${1:-null}" '($screen // .[0].frame.height // 100000) as $h
| [.. | objects | select(.AXLabel or .AXValue)]
| (.[] | select((.frame.y // 0) < $h)
| "\(.type) \(.AXLabel | tojson) value=\(.AXValue | tojson) at \(.frame.x // 0 | round),\(.frame.y // 0 | round) size \(.frame.width // 0 | round)x\(.frame.height // 0 | round)\(if .enabled == false then " disabled" else "" end)"),
(map(select((.frame.y // 0) >= $h)) | length | select(. > 0) | "(\(.) elements below the screen not listed, such as keyboard keys)")'
}
# describe-ui failed once while a system alert was appearing, and read the tree a few seconds later (run 38895987656)
describe() {
local attempt
for attempt in 1 2 3 4; do
if axe describe-ui --udid "$SIM_UDID" "$@"; then
return
fi
sleep 2
done
axe describe-ui --udid "$SIM_UDID" "$@"
}
tree() {
describe | compact_tree
}
point() {
number "$1"
number "$2"
describe --point "$1,$2" | compact_tree 100000
}
wait_for() {
local text=$1 secs=${2:-30}
local deadline=$((SECONDS + secs)) t
until t=$(tree 2> /dev/null) && grep -qF -- "$text" <<< "$t"; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "error: \"$text\" not shown after $secs s"
return 1
fi
sleep 2
done
}
find_text() {
local t
t=$(tree)
if ! grep -iF -- "$1" <<< "$t"; then
echo "no element contains \"$1\""
return 1
fi
}
shot() {
: "${UI_OUT:?UI_OUT must be the output directory}"
if ! [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]; then
echo "error: a shot name has only letters, digits, - and _"
return 1
fi
mkdir -p "$UI_OUT"
local n f
n=$(find "$UI_OUT" -maxdepth 1 -name '*.png' | wc -l)
f=$(printf '%s/%02d-%s' "$UI_OUT" $((n + 1)) "$1")
xcrun simctl io "$SIM_UDID" screenshot "$f.png" > /dev/null
sips -Z 1000 -s format jpeg -s formatOptions 70 "$f.png" --out "$f-view.jpg" > /dev/null
describe > "$f.json"
echo "::group::accessibility tree: $1"
echo "saved $f.png, and $f-view.jpg to look at"
compact_tree < "$f.json"
echo "::endgroup::"
}
number() {
if ! [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
echo "error: \"$1\" is not a number"
exit 1
fi
}
key() {
local code count=${2:-1} i
case "$1" in
return) code=40 ;;
delete) code=42 ;;
*)
echo "error: key is return or delete"
return 1
;;
esac
number "$count"
for ((i = 0; i < count; i++)); do
axe key "$code" --udid "$SIM_UDID"
done
sleep 1
}
launch() {
xcrun simctl terminate "$SIM_UDID" "$bundle_id" 2> /dev/null || true
"$@"
xcrun simctl launch "$SIM_UDID" "$bundle_id" > /dev/null
wait_for "$toolbar_button" 90
}
toolbar_y() {
tree | grep -F "Button \"$toolbar_button\" " | sed -E 's/.* at [0-9-]+,([0-9-]+) .*/\1/' | head -n 1
}
# The preference is set in the app group's defaults while the app is not running; Settings shows the same switch.
layout() {
local one_hand
case "$1" in
reachable) one_hand=true ;;
top) one_hand=false ;;
*)
echo "error: layout is reachable or top"
return 1
;;
esac
local prefs
prefs=$(xcrun simctl get_app_container "$SIM_UDID" "$bundle_id" "$app_group")/Library/Preferences/$app_group
launch xcrun simctl spawn "$SIM_UDID" defaults write "$prefs" oneHandUI -bool "$one_hand"
local y
y=$(toolbar_y)
if { [ "$one_hand" = true ] && [ "$y" -lt 437 ]; } || { [ "$one_hand" = false ] && [ "$y" -ge 437 ]; }; then
echo "error: the toolbar is at y=$y, not where the $1 layout puts it"
return 1
fi
echo "layout $1: the toolbar is at y=$y"
}
text_size() {
case "$1" in
medium | large | extra-large | extra-extra-large | extra-extra-extra-large | accessibility-medium | accessibility-large | accessibility-extra-large | accessibility-extra-extra-large | accessibility-extra-extra-extra-large) ;;
*)
echo "error: unknown text size \"$1\""
return 1
;;
esac
xcrun simctl ui "$SIM_UDID" content_size "$1"
sleep 2
}
backend() {
: "${BACKEND_DIR:?BACKEND_DIR must be the work dir of the test backend}"
"$(dirname "$0")/backend.sh" "$@" "$BACKEND_DIR"
}
directory() {
case "$1" in
stop) backend directory-stop ;;
start) backend directory-start ;;
*)
echo "error: directory stop or directory start"
return 1
;;
esac
echo "test directory: $1"
}
requests() {
: "${APP_LOG:?APP_LOG must be the app log}"
local sent answered results
sent=$(grep -c 'chatSendCmd apiSendServiceRequest$' "$APP_LOG" || true)
results=$(grep -c 'chatSendCmd apiSendServiceRequest: ' "$APP_LOG" || true)
answered=$(grep -c 'chatSendCmd apiSendServiceRequest: serviceResponse' "$APP_LOG" || true)
echo "directory searches sent: $sent, answered: $answered, failed: $((results - answered)), waiting: $((sent - results))"
}
case "${1:-}" in
tap) axe tap --label "$2" --wait-timeout "${3:-10}" --post-delay 2 --udid "$SIM_UDID" ;;
tap-value) axe tap --value "$2" --wait-timeout "${3:-10}" --post-delay 2 --udid "$SIM_UDID" ;;
tap-xy)
number "$2"
number "$3"
axe tap -x "$2" -y "$3" --post-delay 2 --udid "$SIM_UDID"
;;
swipe)
for n in "$2" "$3" "$4" "$5"; do number "$n"; done
axe swipe --start-x "$2" --start-y "$3" --end-x "$4" --end-y "$5" --duration 0.5 --post-delay 1 --udid "$SIM_UDID"
;;
type) axe type "$2" --udid "$SIM_UDID" ;;
clipboard) printf '%s' "$2" | xcrun simctl pbcopy "$SIM_UDID" ;;
key) key "$2" "${3:-1}" ;;
wait) wait_for "$2" "${3:-30}" ;;
find) find_text "$2" ;;
shot) shot "$2" ;;
tree) tree ;;
point) point "$2" "$3" ;;
layout) layout "$2" ;;
relaunch) launch true ;;
text-size) text_size "$2" ;;
directory) directory "$2" ;;
listings) backend listings ;;
requests) requests ;;
*)
sed -n '3,26s/^# \{0,1\}//p' "$0"
exit 1
;;
esac
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Runner images from 20261013 add Command Line Tools whose default SDK is macOS 27, which Xcode 26.6's linker cannot read
# ("tapi error: malformed file"); GHC's links reach it despite DEVELOPER_DIR, so the default goes back to macOS 26.5.
set -euo pipefail
sdks=/Library/Developer/CommandLineTools/SDKs
if [ -L "$sdks/MacOSX.sdk" ] && [ -d "$sdks/MacOSX26.5.sdk" ]; then
sudo ln -sfn MacOSX26.5.sdk "$sdks/MacOSX.sdk"
fi
ls -l "$sdks"