mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-01 11:22:28 +00:00
ci: long-press and right-click for app tests, safer ui arguments, desktop Kotlin 4 GB, desktop session limits from claude-scope
Message menus open only on long-press (iOS) or right-click (desktop), so Claude sessions couldn't reach Moderate, Info or Delete. Ported from f/directory-search unchanged, with its hardening: the guards refuse unquoted glob, brace and tilde expansion, and desktop ui checks numbers and sends one key per call so a key argument can't run an xdotool command. The desktop app job's Kotlin compiler gets 4 GB, as f/directory-search needed after merging master. Desktop sessions take max-turns and max-budget-usd from claude-scope like iOS (60 and 3 when unset, as before), and the Claude job may run 120 minutes. Run: 001-channel-signing-moderators, slice 5
This commit is contained in:
1 parent
b0aba71547
commit
13287fd9d8
9 files changed
+73
-16
No files matched your search
@@ -242,12 +242,13 @@ jobs:
|
||||
distribution: corretto
|
||||
java-version: ${{ env.JAVA_VER }}
|
||||
|
||||
# the Kotlin compiler daemon otherwise gets gradle.properties' 2 GB, which compiling :common runs out of
|
||||
- name: Build app
|
||||
id: build
|
||||
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
|
||||
working-directory: apps/multiplatform
|
||||
run: |
|
||||
./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable
|
||||
./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.daemon.jvmargs=-Xmx4g :desktop:createDistributable
|
||||
app=release/main/app/simplex
|
||||
ls -la "$app/bin" "$app/lib/app/resources"
|
||||
du -sh "$app"
|
||||
@@ -271,7 +272,7 @@ jobs:
|
||||
- name: Run desktop tests
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
working-directory: apps/multiplatform
|
||||
run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :common:desktopTest
|
||||
run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.daemon.jvmargs=-Xmx4g :common:desktopTest
|
||||
|
||||
- name: Report desktop tests
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
@@ -329,7 +330,7 @@ jobs:
|
||||
needs: [plan, app]
|
||||
if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 120
|
||||
environment:
|
||||
name: app-sim-claude
|
||||
deployment: false
|
||||
|
||||
@@ -23,10 +23,10 @@ Queueing for a runner adds from under a minute to 20 minutes. Results are artifa
|
||||
|
||||
## Scripts
|
||||
|
||||
- `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers.
|
||||
- `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, right-click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers.
|
||||
- `smoke-test.sh <app dir> <out dir> [start|onboarding|setup|backend]`: the scripted cases; the default is onboarding and both layouts. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts.
|
||||
- `backend.sh start <tar.gz> <dir>` / `stop <dir>`: the test backend, below.
|
||||
- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend), `claude-guard.py`, `claude-usage.py`: the Claude job.
|
||||
- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend, and optionally the session's turns and budget), `claude-guard.py`, `claude-usage.py`: the Claude job.
|
||||
- `build-core.sh`, `install-core.sh`, `build-backend.sh`, `earlier-run.sh`, `release-app.sh`, `test-summary.py`: builds, reuse and reports.
|
||||
|
||||
## The test backend
|
||||
@@ -41,7 +41,7 @@ It writes the directory's address to `<dir>/directory-link` and the SMP server's
|
||||
|
||||
## The Claude job
|
||||
|
||||
It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 60`, `--max-budget-usd 3` and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`). Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`.
|
||||
It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with the turns and budget `claude-scope` sets (`--max-turns 60` and `--max-budget-usd 3` unless it sets `max-turns:` or `max-budget-usd:`) and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`); the job's time limit is 120 minutes. Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`.
|
||||
|
||||
## Cost
|
||||
|
||||
|
||||
@@ -11,12 +11,18 @@ import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# nothing that chains, pipes, redirects, substitutes, expands or escapes
|
||||
# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes
|
||||
UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?")
|
||||
# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into
|
||||
# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes
|
||||
UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]")
|
||||
QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'")
|
||||
|
||||
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)
|
||||
allowed = call.get("tool_name") != "Bash" or (
|
||||
UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", 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")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# What the Claude job needs for the session claude-test.md describes.
|
||||
# backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too.
|
||||
# max-turns and max-budget-usd (optional) bound the session; claude-test.sh uses 60 and 3 when they're absent.
|
||||
backend: no
|
||||
@@ -23,6 +23,13 @@ settings=$(mktemp)
|
||||
export CLAUDE_GUARD_LOG=$transcript.guard.log
|
||||
: > "$CLAUDE_GUARD_LOG"
|
||||
|
||||
scope() {
|
||||
sed -n "s/^$1: *//p" "$here/claude-scope"
|
||||
}
|
||||
|
||||
max_turns=$(scope max-turns)
|
||||
max_budget=$(scope max-budget-usd)
|
||||
|
||||
{
|
||||
cat "$prompt"
|
||||
echo
|
||||
@@ -47,8 +54,8 @@ CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \
|
||||
--permission-mode dontAsk \
|
||||
--permission-prompts none \
|
||||
--strict-mcp-config \
|
||||
--max-turns 60 \
|
||||
--max-budget-usd 3 \
|
||||
--max-turns "${max_turns:-60}" \
|
||||
--max-budget-usd "${max_budget:-3}" \
|
||||
--autocompact 200k \
|
||||
--no-session-persistence \
|
||||
--settings "$settings" \
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
# ui ink <text> print the height in pixels of the text's ink, to compare text sizes
|
||||
# ui wait <text> [secs] wait up to secs (30) until the text is on screen
|
||||
# ui click <x> <y> click at x,y
|
||||
# ui right-click <x> <y> right-click at x,y, e.g. to open a list row's menu
|
||||
# ui click-text <text> [secs] wait up to secs (20) for the text and click its centre
|
||||
# ui scroll <x> <y> <up|down> [clicks]
|
||||
# turn the mouse wheel over x,y, 3 clicks unless given
|
||||
@@ -155,8 +156,17 @@ shot() {
|
||||
log "shot $(basename "$f").png"
|
||||
}
|
||||
|
||||
number() {
|
||||
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: \"$1\" is not a number"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
click() {
|
||||
xdotool mousemove --sync "$1" "$2" click 1
|
||||
number "$1"
|
||||
number "$2"
|
||||
xdotool mousemove --sync "$1" "$2" click "${3:-1}"
|
||||
sleep 1
|
||||
}
|
||||
|
||||
@@ -171,6 +181,9 @@ scroll() {
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
number "$1"
|
||||
number "$2"
|
||||
number "$4"
|
||||
xdotool mousemove --sync "$1" "$2" click --repeat "$4" --delay 100 "$button"
|
||||
sleep 1
|
||||
}
|
||||
@@ -223,6 +236,7 @@ case "${1:-}" in
|
||||
;;
|
||||
wait)
|
||||
secs=${3:-30}
|
||||
number "$secs"
|
||||
if ! pos=$(wait_for "$2" "$secs"); then
|
||||
log "wait \"$2\": not on screen after $secs s"
|
||||
exit 1
|
||||
@@ -234,8 +248,13 @@ case "${1:-}" in
|
||||
click "$2" "$3"
|
||||
log "click $2,$3"
|
||||
;;
|
||||
right-click)
|
||||
click "$2" "$3" 3
|
||||
log "right-click $2,$3"
|
||||
;;
|
||||
click-text)
|
||||
secs=${3:-20}
|
||||
number "$secs"
|
||||
if ! pos=$(wait_for "$2" "$secs"); then
|
||||
log "click-text \"$2\": not on screen after $secs s"
|
||||
exit 1
|
||||
@@ -255,7 +274,16 @@ case "${1:-}" in
|
||||
;;
|
||||
key)
|
||||
shift
|
||||
xdotool key --delay 100 -- "$@"
|
||||
for k in "$@"; do
|
||||
if ! [[ "$k" =~ ^[A-Za-z0-9_]+(\+[A-Za-z0-9_]+)*$ ]]; then
|
||||
echo "error: \"$k\" is not a key name"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# one key per call: xdotool key runs any later argument that names one of its commands, such as exec
|
||||
for k in "$@"; do
|
||||
xdotool key --delay 100 -- "$k"
|
||||
done
|
||||
log "key $*"
|
||||
;;
|
||||
do)
|
||||
|
||||
@@ -36,7 +36,7 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents;
|
||||
|
||||
## 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.
|
||||
`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, long-press, 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
|
||||
|
||||
|
||||
@@ -11,12 +11,18 @@ import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# nothing that chains, pipes, redirects, substitutes, expands or escapes
|
||||
# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes
|
||||
UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?")
|
||||
# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into
|
||||
# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes
|
||||
UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]")
|
||||
QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'")
|
||||
|
||||
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)
|
||||
allowed = call.get("tool_name") != "Bash" or (
|
||||
UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", 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")
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# 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 long-press <x> <y> touch this point for 1.5 s, e.g. to open a context menu
|
||||
# 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
|
||||
@@ -70,6 +71,7 @@ point() {
|
||||
|
||||
wait_for() {
|
||||
local text=$1 secs=${2:-30}
|
||||
number "$secs"
|
||||
local deadline=$((SECONDS + secs)) t
|
||||
until t=$(tree 2> /dev/null) && grep -qF -- "$text" <<< "$t"; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
@@ -212,6 +214,12 @@ case "${1:-}" in
|
||||
number "$3"
|
||||
axe tap -x "$2" -y "$3" --post-delay 2 --udid "$SIM_UDID"
|
||||
;;
|
||||
long-press)
|
||||
number "$2"
|
||||
number "$3"
|
||||
axe touch -x "$2" -y "$3" --down --up --delay 1.5 --udid "$SIM_UDID"
|
||||
sleep 2
|
||||
;;
|
||||
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"
|
||||
@@ -231,7 +239,7 @@ case "${1:-}" in
|
||||
listings) backend listings ;;
|
||||
requests) requests ;;
|
||||
*)
|
||||
sed -n '3,26s/^# \{0,1\}//p' "$0"
|
||||
sed -n '3,27s/^# \{0,1\}//p' "$0"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in new issue
Block a user