mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 17:31:55 +00:00
ci: test the Android app in an emulator on GitHub's runners (#7786)
This commit is contained in:
1 parent
5834179be6
commit
9a1d3e4969
15 files changed
+1587
-27
No files matched your search
@@ -1,12 +1,10 @@
|
||||
name: android-emu
|
||||
|
||||
# A placeholder, so that the Android app test workflow can be started by hand on a branch that has its full
|
||||
# version: GitHub dispatches only workflows whose file is on the default branch. On master it does nothing.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
claude:
|
||||
description: Also run the Claude job
|
||||
description: Also run the Claude job, which spends the Claude subscription's usage limits
|
||||
type: boolean
|
||||
default: false
|
||||
backend:
|
||||
@@ -14,17 +12,450 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
release:
|
||||
description: Also test the latest release's onboarding
|
||||
description: Also test the latest released app, to tell a branch's failure from the runner's
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: android-emu-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GHC_VER: 9.6.3
|
||||
CABAL_VER: 3.10.2.0
|
||||
JAVA_VER: 17
|
||||
CORE_PKG: android-core-arm64-v8a.tar.gz
|
||||
BACKEND_PKG: desktop-linux-backend.tar.gz
|
||||
APK: android-foss-arm64-v8a-debug.apk
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
plan:
|
||||
name: choose jobs, find earlier builds
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
run-claude: ${{ steps.jobs.outputs.claude }}
|
||||
run-backend: ${{ steps.jobs.outputs.backend }}
|
||||
run-release: ${{ steps.jobs.outputs.release }}
|
||||
claude-backend: ${{ steps.scope.outputs.backend }}
|
||||
core: ${{ steps.names.outputs.core }}
|
||||
core-run: ${{ steps.core.outputs.run-id }}
|
||||
backend: ${{ steps.names.outputs.backend }}
|
||||
backend-run: ${{ steps.backend.outputs.run-id }}
|
||||
steps:
|
||||
- name: Nothing to run here
|
||||
run: echo "This branch has only the placeholder of android-emu; start it on a branch with the full workflow."
|
||||
- name: Choose jobs
|
||||
id: jobs
|
||||
env:
|
||||
CLAUDE: ${{ inputs.claude }}
|
||||
BACKEND: ${{ inputs.backend }}
|
||||
RELEASE: ${{ inputs.release }}
|
||||
run: |
|
||||
echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
|
||||
echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
|
||||
echo "release=$RELEASE" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Claude scope
|
||||
id: scope
|
||||
if: steps.jobs.outputs.claude == 'true'
|
||||
env:
|
||||
BACKEND: ${{ steps.jobs.outputs.backend }}
|
||||
run: |
|
||||
backend=$(sed -n 's/^backend: *//p' scripts/ci/android/claude-scope)
|
||||
case "$backend" in
|
||||
yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;;
|
||||
*)
|
||||
echo "::error::claude-scope must say backend: yes or backend: no"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then
|
||||
echo "::error::claude-scope says the Claude session uses the test backend: ask for it too"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded;
|
||||
# the backend has desktop-linux's name and sources, so each workflow reuses the other's
|
||||
- name: Artifact names
|
||||
id: names
|
||||
run: |
|
||||
echo "core=android-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'flake.nix', 'flake.lock', 'scripts/nix/**', 'scripts/ci/android/build-core.sh') }}" >> "$GITHUB_OUTPUT"
|
||||
echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find core library
|
||||
id: core
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.core }}"
|
||||
|
||||
- name: Find backend
|
||||
id: backend
|
||||
if: steps.jobs.outputs.backend == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.backend }}"
|
||||
|
||||
backend:
|
||||
name: test backend for the app
|
||||
needs: plan
|
||||
if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == ''
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Show runner
|
||||
run: |
|
||||
lsb_release -ds
|
||||
nproc
|
||||
free -h
|
||||
df -h /
|
||||
|
||||
- name: Setup Haskell
|
||||
uses: simplex-chat/setup-haskell-action@v2
|
||||
with:
|
||||
ghc-version: ${{ env.GHC_VER }}
|
||||
cabal-version: ${{ env.CABAL_VER }}
|
||||
|
||||
- name: Install libraries
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev
|
||||
|
||||
- name: Build backend
|
||||
run: scripts/ci/desktop-linux/build-backend.sh ~/backend/${{ env.BACKEND_PKG }}
|
||||
|
||||
- name: Upload backend
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.backend }}
|
||||
path: ~/backend/${{ env.BACKEND_PKG }}
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
core:
|
||||
name: core library for Android arm64
|
||||
needs: plan
|
||||
if: needs.plan.outputs.core-run == ''
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
filter: tree:0
|
||||
|
||||
- name: Show runner
|
||||
run: |
|
||||
lsb_release -ds
|
||||
nproc
|
||||
free -h
|
||||
df -h /
|
||||
|
||||
- name: Build core library
|
||||
run: scripts/ci/android/build-core.sh ~/core/${{ env.CORE_PKG }}
|
||||
|
||||
- name: Upload core library
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.core }}
|
||||
path: ~/core/${{ env.CORE_PKG }}
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
app:
|
||||
name: Android app in the emulator
|
||||
needs: [plan, core, backend]
|
||||
# core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it
|
||||
if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Show runner
|
||||
run: |
|
||||
lsb_release -ds
|
||||
nproc
|
||||
free -h
|
||||
df -h /
|
||||
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||
ls "$ANDROID_HOME/platforms" "$ANDROID_HOME/build-tools" "$ANDROID_HOME/ndk" || true
|
||||
|
||||
# the emulator's qemu needs libpulse0 and libegl1; the backend's avatars and checks need the rest
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core
|
||||
dpkg-query -W libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core
|
||||
|
||||
- name: Download core library
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.core }}
|
||||
path: ~/core
|
||||
run-id: ${{ needs.plan.outputs.core-run || github.run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Install core library
|
||||
id: core
|
||||
run: scripts/ci/android/install-core.sh ~/core/${{ env.CORE_PKG }}
|
||||
|
||||
- name: Download backend
|
||||
if: needs.plan.outputs.run-backend == 'true'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.backend }}
|
||||
path: ~/backend
|
||||
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Start test backend
|
||||
id: backend
|
||||
if: needs.plan.outputs.run-backend == 'true'
|
||||
timeout-minutes: 30
|
||||
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
|
||||
|
||||
# the app and its tests are still built when the backend fails
|
||||
- name: Setup Java
|
||||
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: corretto
|
||||
java-version: ${{ env.JAVA_VER }}
|
||||
|
||||
# the injected ABI builds only arm64-v8a, whose core the job has, and puts the APK under build/intermediates
|
||||
- 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 -Pandroid.injected.build.abi=arm64-v8a :android:assembleFossDebug
|
||||
mkdir -p "$RUNNER_TEMP/apk"
|
||||
cp "android/build/intermediates/apk/foss/debug/$APK" "$RUNNER_TEMP/apk"
|
||||
ls -la "$RUNNER_TEMP/apk"
|
||||
unzip -l "$RUNNER_TEMP/apk/$APK" | grep -E ' lib/'
|
||||
|
||||
- name: Upload app
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-app
|
||||
path: ${{ runner.temp }}/apk/${{ env.APK }}
|
||||
compression-level: 0
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install emulator and system image
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk"
|
||||
|
||||
- name: Boot emulator
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/smoke-test"
|
||||
|
||||
# the test SMP server's addresses have the runner's 127.0.0.1, which adb reverse forwards from the emulator's
|
||||
- name: Reach the test backend
|
||||
id: reach
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome == 'success' }}
|
||||
run: scripts/ci/android/emulator.sh reverse 5223
|
||||
|
||||
- name: Run smoke test
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' && steps.reach.outcome != 'failure' }}
|
||||
env:
|
||||
BACKEND: ${{ steps.reach.outcome }}
|
||||
run: |
|
||||
if [ "$BACKEND" = success ]; then
|
||||
export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server")
|
||||
cases=backend
|
||||
fi
|
||||
scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/apk/$APK" "$RUNNER_TEMP/smoke-test" ${cases:-}
|
||||
|
||||
- name: Stop test backend
|
||||
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
|
||||
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
|
||||
|
||||
- name: Upload smoke test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-smoke-test
|
||||
path: ${{ runner.temp }}/smoke-test
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload backend logs
|
||||
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-backend-logs
|
||||
path: ${{ runner.temp }}/backend/*.log
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
# Claude Code repeats the smoke test with the ui helper. Each run spends the Claude subscription's usage
|
||||
# limits, so it runs only when asked for. The app-sim-claude environment's branch rule decides which branches
|
||||
# may use the token.
|
||||
claude:
|
||||
name: Claude tests the Android app
|
||||
needs: [plan, app]
|
||||
if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
environment:
|
||||
name: app-sim-claude
|
||||
deployment: false
|
||||
concurrency:
|
||||
group: android-emu-claude-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# the same in all app workflows
|
||||
CLAUDE_CODE_VERSION: 2.1.300
|
||||
CLAUDE_MODEL: claude-haiku-5-5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core
|
||||
dpkg-query -W libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core
|
||||
|
||||
- name: Download app
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: android-app
|
||||
path: ${{ runner.temp }}/apk
|
||||
|
||||
- name: Download backend
|
||||
if: needs.plan.outputs.claude-backend == 'yes'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.backend }}
|
||||
path: ~/backend
|
||||
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Start test backend
|
||||
id: backend
|
||||
if: needs.plan.outputs.claude-backend == 'yes'
|
||||
timeout-minutes: 30
|
||||
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
|
||||
|
||||
- name: Install emulator and system image
|
||||
run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk"
|
||||
|
||||
- name: Boot emulator
|
||||
run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/claude-test/screens"
|
||||
|
||||
- name: Reach the test backend
|
||||
if: steps.backend.outcome == 'success'
|
||||
run: scripts/ci/android/emulator.sh reverse 5223
|
||||
|
||||
# ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds
|
||||
- name: Install Claude Code and the ui command
|
||||
run: |
|
||||
npm install --prefix "$RUNNER_TEMP/claude-code" "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"
|
||||
echo "$RUNNER_TEMP/claude-code/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
printf '#!/bin/sh\nexec %s/scripts/ci/android/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui
|
||||
sudo chmod +x /usr/local/bin/ui
|
||||
|
||||
- name: Start the app
|
||||
run: scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/apk/$APK" "$RUNNER_TEMP/claude-test/screens" start
|
||||
|
||||
# Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential
|
||||
# scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for.
|
||||
- name: Run Claude
|
||||
id: claude
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1
|
||||
run: scripts/ci/desktop-linux/claude-test.sh "$RUNNER_TEMP/claude-test" "$RUNNER_TEMP/claude-transcript.jsonl" scripts/ci/android/claude-test.md
|
||||
|
||||
- name: Report Claude usage
|
||||
if: ${{ !cancelled() && steps.claude.outcome != 'skipped' }}
|
||||
run: scripts/ci/desktop-linux/claude-usage.py "$RUNNER_TEMP/claude-transcript.jsonl" "$RUNNER_TEMP/claude-usage.json"
|
||||
|
||||
- name: Stop test backend
|
||||
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
|
||||
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
|
||||
|
||||
- name: Upload Claude test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-claude-test
|
||||
path: |
|
||||
${{ runner.temp }}/claude-test
|
||||
${{ runner.temp }}/claude-transcript.jsonl
|
||||
${{ runner.temp }}/claude-transcript.jsonl.stderr
|
||||
${{ runner.temp }}/claude-transcript.jsonl.guard.log
|
||||
${{ runner.temp }}/claude-usage.json
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload backend logs
|
||||
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-claude-backend-logs
|
||||
path: ${{ runner.temp }}/backend/*.log
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
# the latest release's onboarding, to tell a branch's own failure from the runner's
|
||||
release:
|
||||
name: released app in the emulator
|
||||
needs: plan
|
||||
if: needs.plan.outputs.run-release == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
filter: tree:0
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libpulse0 libegl1
|
||||
dpkg-query -W libpulse0 libegl1
|
||||
|
||||
- name: Download released app
|
||||
run: scripts/ci/android/release-apk.sh "$RUNNER_TEMP/release"
|
||||
|
||||
- name: Install emulator and system image
|
||||
run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk"
|
||||
|
||||
- name: Boot emulator
|
||||
run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/smoke-test"
|
||||
|
||||
- name: Run smoke test
|
||||
run: scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/release/simplex-aarch64.apk" "$RUNNER_TEMP/smoke-test" onboarding
|
||||
|
||||
- name: Upload smoke test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-release-smoke-test
|
||||
path: ${{ runner.temp }}/smoke-test
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
+15
-14
@@ -1,36 +1,37 @@
|
||||
# App tests on GitHub's runners
|
||||
|
||||
Two workflows build the apps from a branch's own code and test them, only when asked: `ios-sim` (the iOS app in a simulator on a macOS runner) and `desktop-linux` (the desktop app on a Linux runner). Each runs a scripted smoke test and, on request, a Claude session that follows a test plan, and a test backend (relay, directory, names server, peers). Details: [iOS](ios-sim/README.md), [desktop](desktop-linux/README.md).
|
||||
Three workflows build the apps from a branch's own code and test them, only when asked: `ios-sim` (the iOS app in a simulator on a macOS runner), `desktop-linux` (the desktop app on a Linux runner) and `android-emu` (the Android app in an emulator on a Linux runner). Each runs a scripted smoke test and, on request, a Claude session that follows a test plan, and a test backend (relay, directory, names server, peers). Details: [iOS](ios-sim/README.md), [desktop](desktop-linux/README.md), [Android](android/README.md).
|
||||
|
||||
## Starting a run
|
||||
|
||||
Pushes start nothing: a run is started by hand, on any branch, with inputs that are off by default and combine.
|
||||
|
||||
| Input | iOS | Desktop |
|
||||
|---|---|---|
|
||||
| no input | scripted test | scripted test |
|
||||
| `claude` | scripted test, then Claude | scripted test, then Claude |
|
||||
| `backend` | also the test backend | also the test backend |
|
||||
| `release` | — | also the latest release's onboarding, to tell a branch's failure from the runner's |
|
||||
| Input | iOS | Desktop | Android |
|
||||
|---|---|---|---|
|
||||
| no input | scripted test | scripted test | scripted test |
|
||||
| `claude` | scripted test, then Claude | scripted test, then Claude | scripted test, then Claude |
|
||||
| `backend` | also the test backend | also the test backend | also the test backend |
|
||||
| `release` | — | also the latest release's onboarding, to tell a branch's failure from the runner's | also the latest release's onboarding, to tell a branch's failure from the runner's |
|
||||
|
||||
In the Actions tab: `ios-sim` or `desktop-linux` → Run workflow, choose the branch and tick the inputs. With the GitHub CLI, each input is a field:
|
||||
In the Actions tab: `ios-sim`, `desktop-linux` or `android-emu` → Run workflow, choose the branch and tick the inputs. With the GitHub CLI, each input is a field:
|
||||
|
||||
```sh
|
||||
gh workflow run ios-sim.yml --ref <branch>
|
||||
gh workflow run ios-sim.yml --ref <branch> -f claude=true
|
||||
gh workflow run desktop-linux.yml --ref <branch> -f claude=true -f backend=true -f release=true
|
||||
gh workflow run android-emu.yml --ref <branch> -f claude=true
|
||||
```
|
||||
|
||||
A newer run of a workflow on the same branch cancels the older one.
|
||||
|
||||
## How long a run takes
|
||||
|
||||
| Run | iOS | Desktop |
|
||||
|---|---|---|
|
||||
| Scripted test, builds reused | about 25 min, plus the wait for a macOS runner, which the team's builds share (minutes to an hour) | about 10 min |
|
||||
| The core rebuilt, after Haskell changes | 30–45 min more | about 25 min more |
|
||||
| With the test backend | 20–45 min more when it is rebuilt, then about 10 min to start | about 30 min more when it is rebuilt, then a few minutes to start |
|
||||
| With Claude on the smoke plan | 10–15 min more | about 4 min more |
|
||||
| Run | iOS | Desktop | Android |
|
||||
|---|---|---|---|
|
||||
| Scripted test, builds reused | about 25 min, plus the wait for a macOS runner, which the team's builds share (minutes to an hour) | about 10 min | about 15 min |
|
||||
| The core rebuilt, after Haskell changes | 30–45 min more | about 25 min more | about 27 min more |
|
||||
| With the test backend | 20–45 min more when it is rebuilt, then about 10 min to start | about 30 min more when it is rebuilt, then a few minutes to start | as desktop, whose backend it shares |
|
||||
| With Claude on the smoke plan | 10–15 min more | about 4 min more | about 5 min more |
|
||||
|
||||
## The Claude job
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Android app CI in an emulator
|
||||
|
||||
`.github/workflows/android-emu.yml` builds the core library and a debug APK from the branch on a GitHub runner (Ubuntu 24.04), installs the app in an Android emulator, and drives it: onboarding to the chat list, then the chat list with the reachable toolbar (the default) and with the top bar. On request it also runs Claude Code driving the same path, a test backend, and the latest release as a baseline.
|
||||
|
||||
## Running it
|
||||
|
||||
The workflow runs only when started by hand, on any branch: in the Actions tab, `android-emu` → Run workflow, or `gh workflow run android-emu.yml --ref <branch>` with `-f claude=true`, `-f backend=true` or `-f release=true`. [App tests on GitHub's runners](../README.md) lists the inputs. A newer run on the same branch cancels the older one.
|
||||
|
||||
Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged, as an artifact of the same branch named after a hash of its sources (30 days): for the core `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md`, the Nix files (`flake.nix`, `flake.lock`, `scripts/nix/**`) and `build-core.sh`; the backend is desktop-linux's, under its name and hash, so a backend either workflow built is reused by both. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`.
|
||||
|
||||
## Jobs
|
||||
|
||||
| Job | Does | Time |
|
||||
|---|---|---|
|
||||
| `plan` | reads the inputs, finds earlier core and backend builds | 10 s |
|
||||
| `core` | `build-core.sh`: `lib:simplex-chat` and `lib:support` for aarch64-android with Nix, as `scripts/android/build-android.sh` builds them | skipped when reused, about 27 min not |
|
||||
| `app` | the debug APK (`:android:assembleFossDebug` for arm64-v8a only), the emulator, `smoke-test.sh` | about 13 min: APK 7–8, emulator 1.5, smoke test 3.5 (4 with the backend case, after 2.5 to start the backend) |
|
||||
| `claude` (opt-in) | after `app` passed: its APK in a new emulator at the first screen, then Claude Code drives it | about 5 min, Claude 2.5 |
|
||||
| `backend` (opt-in) | desktop-linux's `build-backend.sh` | about 30 min, skipped when reused |
|
||||
| `release` (opt-in) | the latest release's arm64 APK, its signer checked against SimpleX's key, onboarding only | about 4 min |
|
||||
|
||||
Results are artifacts: `android-smoke-test` (numbered screenshots, the accessibility tree and the text of each, `steps.log`, `checks.txt`, logcat as `app.log`, `emulator.log`), `android-release-smoke-test`, `android-claude-test`, and the backend's logs; `android-app`, the APK for the Claude job, is kept a day. The job summaries list the checks and the Claude usage.
|
||||
|
||||
## The emulator
|
||||
|
||||
A Google APIs x86_64 system image of API 34 (revision 14) with KVM, on the x86_64 runner: the arm64 runner has no KVM, and Google publishes no Linux arm64 emulator. The image's ARM translation (`libndk_translation`) runs the app's arm64-v8a code, the only ABI besides armeabi-v7a the core is built for. `install-sdk.sh` installs the emulator, platform-tools and the image at pinned revisions from Google's repository, checking each archive's SHA-1 and SHA-256, and writes the AVD itself; `emulator.sh` boots it without a window. Screenshots come from the emulator console (`adb emu screenrecord screenshot`): without a window, the guest's `screencap` returns an empty or black image.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `ui`: the UI driver, with desktop's command names: text and positions from uiautomator's accessibility tree, taps and keys with adb. `ui` alone prints its commands: start (with app settings), shot, text, tree, find, wait, click, click-text, scroll, type, key, layout (the `OneHandUI` preference and a restart), relaunch, text-size, do, stop. `screen-tree.py` reads the tree; `prefs.py` writes the settings, which only a debug build accepts (`run-as`).
|
||||
- `smoke-test.sh <apk> <out dir> [start|onboarding|layouts|backend]`: the scripted cases; the default is onboarding, a local group and both layouts. Onboarding on Android includes the app's instant notifications alert and the system's battery dialog. `backend` also adds the test SMP server under Your servers and checks that it passes Test servers.
|
||||
- `install-sdk.sh`, `emulator.sh`: the emulator, above.
|
||||
- `build-core.sh`, `install-core.sh`, `release-apk.sh`: builds and the release.
|
||||
- `claude-test.md` (the prompt) and `claude-scope` (whether it uses the test backend): the Claude job, which runs desktop-linux's `claude-test.sh`, `claude-guard.py` and `claude-usage.py`.
|
||||
|
||||
## The test backend
|
||||
|
||||
Desktop-linux's `backend.sh` starts it on the runner as it does for desktop: [its README](../desktop-linux/README.md#the-test-backend) describes what it seeds. Its SMP server listens on the runner's 127.0.0.1:5223, and its addresses, the links peers create on it included, have 127.0.0.1, which in the emulator is the emulator itself: `emulator.sh reverse 5223` forwards the emulator's 127.0.0.1:5223 to the runner's (`adb reverse`) and waits until the server answers. The emulator's alias for the runner, 10.0.2.2, was unreachable on this image, and couldn't cover the addresses in links anyway. The app gets private routing to unknown servers only (`NetworkSMPProxyMode=Unknown`), since the preset proxies can't reach 127.0.0.1. The scripted backend case checks that the app reaches the server; the directory and SimpleX names aren't checked here.
|
||||
|
||||
## 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 APK in a new emulator, 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; it never reaches the emulator. 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 other app workflows pin, and runs `claude-haiku-5-5` with desktop's harness: `--max-turns 60`, `--max-budget-usd 3`, Bash limited to single `ui` commands by `claude-guard.py`, file tools limited to the session's directory, and its output checked for the token before anything is printed or uploaded. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **The app runs under ARM translation**, Google's closed-source `libndk_translation`, not natively: it's slower than on a phone and can hit bugs that only translation has. In one of the debug build's first ten starts here, the app died at start with a SIGSEGV inside the translator (`ndk_translation_HandleNoExec`); the smoke test then fails at once and prints the crash. The slowed app also dropped the end of a long text typed at once, so `ui type` types a few characters at a time and checks each part on the field. Before calling a failure an app bug, run the workflow with `release` and check the released app in the same emulator.
|
||||
- **The core build relies on cache.zw3rk.com**, the zw3rk Hydra's cache, for the cross-compiling toolchain and the dependencies: with it only simplex-chat itself is compiled. A change of the flake's inputs (`flake.lock`, the haskell.nix pin, the index state) that the cache doesn't hold yet, or the cache being down, turns the build into compiling GHC and every dependency, 12 to 24 hours, longer than a job may run. The core job then times out; a branch whose Haskell and Nix sources are unchanged keeps reusing its last build for 30 days.
|
||||
- On a first start the app can show its What's new sheet over the first onboarding screen; the smoke test closes it and notes it in `checks.txt`.
|
||||
|
||||
## Cost
|
||||
|
||||
The runners are free for this public repository. A Claude run spends the subscription's usage limits, which interactive sessions share: Haiku 5.5 did this smoke path in 24 turns, 0.01 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription).
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Builds the arm64 Android core (libsimplex.so with the libraries it needs, and libsupport.so) with Nix, as
|
||||
# scripts/android/build-android.sh does, and packs them as scripts/android/download-libs.sh lays them out.
|
||||
# Usage: scripts/ci/android/build-core.sh <output .tar.gz>
|
||||
# Installs the Nix version that build-android.sh pins, in single-user mode, with the zw3rk Hydra's cache, which
|
||||
# builds these jobs for master: without it, the toolchain and every dependency would be compiled too.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
nix_ver=nix-2.22.0
|
||||
nix_hash=4fed7db867186c01ce2a2077da4a6950ed16232efbf78d0cd19700cff80559f9
|
||||
jobs=(".#hydraJobs.x86_64-linux.aarch64-android:lib:simplex-chat" ".#hydraJobs.x86_64-linux.aarch64-android:lib:support")
|
||||
|
||||
install_nix() {
|
||||
local tmp
|
||||
tmp=$(mktemp -d)
|
||||
sudo mkdir -p /nix
|
||||
sudo chown "$USER" /nix
|
||||
df -h /
|
||||
# the Nix sandbox needs user namespaces, which Ubuntu 24.04 restricts
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
curl --proto '=https' --tlsv1.2 -sSf -o "$tmp/install" "https://releases.nixos.org/nix/$nix_ver/install"
|
||||
echo "$nix_hash $tmp/install" | sha256sum -c
|
||||
mkdir -p ~/.config/nix
|
||||
cat > ~/.config/nix/nix.conf << EOF
|
||||
sandbox = true
|
||||
max-jobs = auto
|
||||
cores = 0
|
||||
experimental-features = nix-command flakes
|
||||
substituters = https://cache.nixos.org https://cache.zw3rk.com https://cache.iog.io
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= loony-tools:pr9m4BkM/5/eSTZlkQyRt57Jz7OMBxNSUiMC4FkcNfk= hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=
|
||||
EOF
|
||||
sh "$tmp/install" --no-daemon
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.nix-profile/etc/profile.d/nix.sh"
|
||||
nix --version
|
||||
}
|
||||
|
||||
mkdir -p "$(dirname "$1")"
|
||||
out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
install_nix
|
||||
pkg=$(mktemp -d)
|
||||
for job in "${jobs[@]}"; do
|
||||
echo "::group::nix build $job"
|
||||
started=$SECONDS
|
||||
nix build -L --out-link "$RUNNER_TEMP/result" "$job"
|
||||
echo "::endgroup::"
|
||||
echo "$job built in $(((SECONDS - started) / 60)) min"
|
||||
unzip -o "$RUNNER_TEMP"/result/pkg-*.zip -d "$pkg"
|
||||
done
|
||||
ls -la "$pkg"
|
||||
tar -C "$pkg" -czf "$out" .
|
||||
ls -la "$out"
|
||||
@@ -0,0 +1,3 @@
|
||||
# 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.
|
||||
backend: no
|
||||
@@ -0,0 +1,54 @@
|
||||
# Test: onboarding and the chat list in the SimpleX Chat Android app
|
||||
|
||||
You are testing the SimpleX Chat Android app, which runs in an Android emulator on this machine. Drive it only with the `ui` helper, through Bash, one command per call: `ui <command> [args]`. Anything else is refused; don't look for ways around that, and if something blocks the test, report it. The commands are listed at the end.
|
||||
|
||||
Your working directory is where your output goes. `ui shot <name>` saves `screens/NN-<name>.png` and prints the text on screen, one element per line after its centre `x,y` in screen pixels. Read the PNG to see the screen (1080×2400). The text comes from the app's accessibility tree, so it is spelled exactly, but it can list an element that another screen covers: when the text and the picture disagree, trust the picture. "Above" means a smaller y. `ui tree` also lists text fields and buttons that have no text.
|
||||
|
||||
## What is set up
|
||||
|
||||
- The app is already running, on its first screen, with a new, empty database: never run `ui start` or `ui stop`. Begin with `ui shot start` to see it.
|
||||
- `ui type` types into the field that has the focus: tap a field before typing into it. `ui key BACK` is Android's back button.
|
||||
- Besides the chats, the chat list shows the app's own cards as list items until they are closed: "New 1-time link / Paste link / Scan" (once there is a conversation), "Bottom bar / Top bar", and "Invest in SimpleX Chat!". They are part of the list, not something drawn over it: leave them open.
|
||||
|
||||
## The test
|
||||
|
||||
**A. Onboarding.** Each screen waits for the one before it; wait for a text before clicking it.
|
||||
|
||||
1. The first screen has "Get started": click it.
|
||||
2. Click the field "Enter profile name…", type `citester`, and click "Create profile".
|
||||
3. On "Your network", click "Continue".
|
||||
4. The app explains instant notifications in an alert: click "Allow". Android then asks whether the app may always run in the background: click "Allow" again.
|
||||
5. Click "Accept" on the conditions screen.
|
||||
6. Wait up to 60 s for the chat list, which shows "Talk to someone" while there are no conversations. Take a shot.
|
||||
|
||||
**B. A group, and the chat list with the toolbar at the bottom.**
|
||||
|
||||
1. Click "Start new chat" (the round button in the bottom toolbar), then "Create group". The name field under "Enter group name:" has the focus: type `seeded notes` and click "Create group". Wait for "Invite members", which shows once the group exists.
|
||||
2. Run `ui relaunch`: it restarts the app and waits for the chat list. Take a shot.
|
||||
3. Check, noting the evidence (screenshot names, positions):
|
||||
- "seeded notes" is listed;
|
||||
- the search field "Search or paste SimpleX link" is in the bottom half of the screen (y over 1200), and "Add list" is just above it;
|
||||
- nothing is cut off, overlapping or garbled.
|
||||
|
||||
**Between the layouts:** run `ui layout top`: it restarts the app with the toolbar at the top.
|
||||
|
||||
**C. The chat list, toolbar at the top.** Check, noting the evidence:
|
||||
|
||||
- "seeded notes" is still listed;
|
||||
- the search field is in the top half of the screen (y under 1200), and "Add list" is just below it;
|
||||
- nothing is cut off, overlapping or garbled. "Start new chat" is now a floating button at the bottom right, drawn over whatever the list shows there: note what it covers, but that alone is not a failure.
|
||||
|
||||
You have at most 60 turns, and the whole test fits in about 25 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example:
|
||||
|
||||
- `ui do click-text "Get started" -- click-text "Enter profile name" 30 -- type citester -- shot profile`
|
||||
- `ui do click-text "Create profile" -- wait Continue 60 -- shot your-network`
|
||||
- `ui do click-text "Start new chat" -- click-text "Create group" 10 -- wait "Enter group name" -- shot new-group`
|
||||
- `ui do relaunch -- shot chat-list-bottom`
|
||||
|
||||
`ui do` stops at the first command that fails, so a failed chain tells you where it stopped.
|
||||
|
||||
## Report
|
||||
|
||||
Write `report.md` in your working directory: for each of A, B and C, each check you ran as PASS or FAIL with its evidence, and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the checks that failed.
|
||||
|
||||
## The ui helper
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Boots the AVD "ci" (install-sdk.sh) without a window, with KVM, and waits until Android has booted and its
|
||||
# package manager answers; then turns animations off and keeps the screen on and unlocked.
|
||||
# Usage: scripts/ci/android/emulator.sh start <log dir>
|
||||
# scripts/ci/android/emulator.sh reverse <port> forwards the emulator's 127.0.0.1:<port> to the runner's,
|
||||
# and waits until the server there answers
|
||||
# The emulator keeps running for later steps of the job.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
boot_secs=600
|
||||
|
||||
start() {
|
||||
local log=$1/emulator.log started deadline missing=0
|
||||
mkdir -p "$1"
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
ls -l /dev/kvm
|
||||
emulator -accel-check
|
||||
|
||||
local flags=(-avd ci -no-window -no-audio -no-boot-anim -no-snapshot -no-metrics -gpu swiftshader_indirect -camera-back none -camera-front none -memory 4096 -cores 4)
|
||||
echo "emulator ${flags[*]}"
|
||||
started=$SECONDS
|
||||
nohup emulator "${flags[@]}" < /dev/null > "$log" 2>&1 &
|
||||
|
||||
deadline=$((SECONDS + boot_secs))
|
||||
until [ "$(adb shell getprop sys.boot_completed 2> /dev/null)" = 1 ]; do
|
||||
# the launcher starts qemu with the same arguments; just after the start neither may be running yet
|
||||
if pgrep -f -- '-avd ci' > /dev/null; then
|
||||
missing=0
|
||||
else
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
if [ "$missing" -ge 5 ] || [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "error: Android did not boot in $((SECONDS - started)) s$([ "$missing" -ge 5 ] && echo ", the emulator is gone")"
|
||||
cat "$log"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "booted in $((SECONDS - started)) s"
|
||||
until adb shell pm path android > /dev/null 2>&1; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "error: no package manager $((SECONDS - started)) s after the start"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "package manager ready after $((SECONDS - started)) s"
|
||||
|
||||
adb shell settings put global window_animation_scale 0
|
||||
adb shell settings put global transition_animation_scale 0
|
||||
adb shell settings put global animator_duration_scale 0
|
||||
adb shell svc power stayon true
|
||||
adb shell wm dismiss-keyguard
|
||||
adb shell getprop | grep -E 'ro.build.version.(sdk|release)|ro.product.cpu.abilist|native.bridge|ro.kernel.qemu' || true
|
||||
adb shell wm size
|
||||
}
|
||||
|
||||
# The test SMP server speaks TLS, which answers a plain-text request with an alert: any bytes back mean the request
|
||||
# reached it. The forward can take a while to answer after the boot.
|
||||
reverse() {
|
||||
local port=$1 deadline=$((SECONDS + 60)) bytes
|
||||
if ! [[ "$port" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: \"$port\" is not a port"
|
||||
return 1
|
||||
fi
|
||||
adb reverse "tcp:$port" "tcp:$port"
|
||||
adb reverse --list
|
||||
while :; do
|
||||
bytes=$(adb shell "printf 'GET / HTTP/1.0\r\n\r\n' | nc -w 5 127.0.0.1 $port | wc -c" || true)
|
||||
bytes=${bytes//[!0-9]/}
|
||||
echo "127.0.0.1:$port from the emulator: ${bytes:-no} bytes back"
|
||||
[ "${bytes:-0}" -eq 0 ] || return 0
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "error: the emulator does not reach the runner's 127.0.0.1:$port"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) start "$2" ;;
|
||||
reverse) reverse "$2" ;;
|
||||
*)
|
||||
echo "usage: $0 start <log dir> | reverse <port>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Unpacks the core package from build-core.sh where scripts/android/download-libs.sh puts the Hydra builds.
|
||||
# Usage: scripts/ci/android/install-core.sh <core .tar.gz>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
libs_dir=apps/multiplatform/common/src/commonMain/cpp/android/libs/arm64-v8a
|
||||
mkdir -p "$libs_dir"
|
||||
tar -C "$libs_dir" -xzf "$1"
|
||||
ls -la "$libs_dir"
|
||||
readelf -d "$libs_dir/libsimplex.so" | grep -E 'NEEDED|SONAME'
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Installs a pinned emulator, platform-tools and the Google APIs x86_64 system image of API 34 from Google's repository,
|
||||
# each checked against the SHA-1 its repository manifest lists and the SHA-256 recorded when it was pinned, and creates
|
||||
# the AVD "ci" for that image.
|
||||
# Usage: scripts/ci/android/install-sdk.sh <sdk dir>
|
||||
# Puts ANDROID_SDK_ROOT, ANDROID_HOME and ANDROID_AVD_HOME in GITHUB_ENV and the emulator and adb on GITHUB_PATH.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p "$1"
|
||||
sdk=$(cd "$1" && pwd)
|
||||
repo=https://dl.google.com/android/repository
|
||||
api=34
|
||||
|
||||
install() {
|
||||
local dest=$1 url=$2 sha1=$3 sha256=$4 zip
|
||||
zip=$sdk/downloads/$(basename "$url")
|
||||
mkdir -p "$sdk/downloads"
|
||||
curl --proto '=https' --tlsv1.2 -sSfL --retry 3 -o "$zip" "$url"
|
||||
echo "$sha1 $zip" | sha1sum -c
|
||||
echo "$sha256 $zip" | sha256sum -c
|
||||
mkdir -p "$dest"
|
||||
unzip -q "$zip" -d "$dest"
|
||||
rm "$zip"
|
||||
}
|
||||
|
||||
# emulator 37.2.12 and platform-tools 37.0.1, the stable channel on 2026-10-19
|
||||
install "$sdk" "$repo/emulator-linux_x64-16428233.zip" \
|
||||
cd7362ea55dfb86a418958138dc396e74165dd01 b08fc43d8608d2955f607f1b287beb525041e730086bdca3af152accac3af9c1
|
||||
install "$sdk" "$repo/platform-tools_r37.0.1-linux.zip" \
|
||||
477254aa5f903c15cf51001717bdf347fb6b53e0 d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1
|
||||
install "$sdk/system-images/android-$api/google_apis" "$repo/sys-img/google_apis/x86_64-34_r14.zip" \
|
||||
e0f6c9a0691aa27bd597d0deb1bcfdc943ac8ca7 783a40134baf4f3012d4464fbe1571b1612a0dbd2e7a44d14bd8328923443833
|
||||
|
||||
# avdmanager needs the package.xml that only sdkmanager writes
|
||||
avd_home=$sdk/avd
|
||||
mkdir -p "$avd_home/ci.avd"
|
||||
cat > "$avd_home/ci.ini" << EOF
|
||||
avd.ini.encoding=UTF-8
|
||||
path=$avd_home/ci.avd
|
||||
target=android-$api
|
||||
EOF
|
||||
cat > "$avd_home/ci.avd/config.ini" << EOF
|
||||
avd.ini.encoding=UTF-8
|
||||
AvdId=ci
|
||||
abi.type=x86_64
|
||||
hw.cpu.arch=x86_64
|
||||
hw.cpu.ncore=4
|
||||
hw.ramSize=4096
|
||||
disk.dataPartition.size=6G
|
||||
hw.lcd.width=1080
|
||||
hw.lcd.height=2400
|
||||
hw.lcd.density=420
|
||||
hw.keyboard=yes
|
||||
hw.gpu.enabled=yes
|
||||
hw.gpu.mode=swiftshader_indirect
|
||||
image.sysdir.1=system-images/android-$api/google_apis/x86_64/
|
||||
tag.id=google_apis
|
||||
PlayStore.enabled=false
|
||||
EOF
|
||||
|
||||
# the emulator's launcher finds its own libraries under lib64; only system libraries can be missing
|
||||
if LD_LIBRARY_PATH=$(find "$sdk/emulator/lib64" -type d | paste -sd :) ldd "$sdk/emulator/qemu/linux-x86_64/qemu-system-x86_64" | grep 'not found'; then
|
||||
echo "error: the emulator needs these libraries"
|
||||
exit 1
|
||||
fi
|
||||
"$sdk/emulator/emulator" -version | head -n 1
|
||||
"$sdk/platform-tools/adb" version | head -n 2
|
||||
grep -E '^(ro.build.version.sdk|ro.product.cpu.abilist|ro.system.product.cpu.abilist)=' "$sdk/system-images/android-$api/google_apis/x86_64/build.prop" || true
|
||||
du -sh "$sdk"
|
||||
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
{
|
||||
echo "ANDROID_SDK_ROOT=$sdk"
|
||||
echo "ANDROID_HOME=$sdk"
|
||||
echo "ANDROID_AVD_HOME=$avd_home"
|
||||
} >> "$GITHUB_ENV"
|
||||
echo "$sdk/emulator" >> "$GITHUB_PATH"
|
||||
echo "$sdk/platform-tools" >> "$GITHUB_PATH"
|
||||
fi
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Prints the app's SharedPreferences file with the settings given, typed as AppPreferences in SimpleXAPI.kt reads them.
|
||||
# Usage: prefs.py <prefs .xml, empty when the app has none yet> <name>=<value>...
|
||||
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
TYPES = {"OneHandUI": "boolean", "DeveloperTools": "boolean", "NetworkSMPProxyMode": "string", "LogLevel": "string"}
|
||||
|
||||
|
||||
def main():
|
||||
path, settings = sys.argv[1], sys.argv[2:]
|
||||
with open(path, "rb") as f:
|
||||
data = f.read().strip()
|
||||
prefs = ET.fromstring(data) if data else ET.Element("map")
|
||||
for setting in settings:
|
||||
name, _, value = setting.partition("=")
|
||||
kind = TYPES.get(name)
|
||||
if kind is None or (kind == "boolean" and value not in ("true", "false")):
|
||||
known = ", ".join(f"{n}=<{t}>" for n, t in TYPES.items())
|
||||
sys.exit(f"error: the settings are {known}, not {setting}")
|
||||
for old in prefs.findall(f"*[@name='{name}']"):
|
||||
prefs.remove(old)
|
||||
if kind == "boolean":
|
||||
ET.SubElement(prefs, kind, name=name, value=value)
|
||||
else:
|
||||
ET.SubElement(prefs, kind, name=name).text = value
|
||||
print("<?xml version='1.0' encoding='utf-8' standalone='yes' ?>")
|
||||
print(ET.tostring(prefs, encoding="unicode"))
|
||||
|
||||
|
||||
main()
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Downloads the released arm64 APK at the branch's latest release tag and checks that SimpleX signed it.
|
||||
# Usage: scripts/ci/android/release-apk.sh <dir>
|
||||
# The APK is then <dir>/simplex-aarch64.apk. Needs the build tools of the runner's SDK, before install-sdk.sh replaces it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# the release signing certificate, as scripts/simplex-chat-reproduce-builds-android.sh checks it
|
||||
simplex_key=3C:52:C4:FD:3C:AD:1C:07:C9:B0:0A:70:80:E3:58:FA:B9:FE:FC:B8:AF:5A:EC:14:77:65:F1:6D:0F:21:AD:85
|
||||
dir=$1
|
||||
tag=$(git describe --tags --abbrev=0 --match 'v*' --exclude '*-armv7a' --exclude '*-fdroid')
|
||||
apk=$dir/simplex-aarch64.apk
|
||||
echo "release $tag"
|
||||
|
||||
mkdir -p "$dir"
|
||||
curl -fsSL -o "$apk" "https://github.com/simplex-chat/simplex-chat/releases/download/$tag/simplex-aarch64.apk"
|
||||
apksigner=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -n 1)
|
||||
signer=$("$apksigner" verify --print-certs "$apk" | sed -n 's/.*certificate SHA-256 digest: //p' | head -n 1 | fold -w2 | paste -sd: | tr '[:lower:]' '[:upper:]')
|
||||
if [ "$signer" != "$simplex_key" ]; then
|
||||
echo "error: $apk is signed by $signer, not SimpleX"
|
||||
exit 1
|
||||
fi
|
||||
echo "signed by SimpleX: $signer"
|
||||
ls -la "$apk"
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Reads a uiautomator dump of the screen:
|
||||
# screen-tree.py <dump .xml> lines the elements with a text or a content description, one per line: "x,y text", the element's centre
|
||||
# screen-tree.py <dump .xml> find <text> "x y left top right bottom" of the element with the text, or exits 1
|
||||
# screen-tree.py <dump .xml> tree the elements that have a text or can be used: class, text, bounds and states
|
||||
# screen-tree.py <dump .xml> focused [<text>] exits 1 unless a text field has the focus, and shows the text exactly
|
||||
# Text is compared as the desktop ui helper compares it: letters and digits only, any case. An element that is exactly
|
||||
# the text wins over one that contains it, and then one inside a clickable element (Compose puts a button's text in a
|
||||
# child of the clickable view) over a title with the same text.
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
BOUNDS = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]")
|
||||
STATES = ["clickable", "checkable", "checked", "focused", "scrollable", "selected"]
|
||||
FIELD = "android.widget.EditText"
|
||||
# listed by tree even without a text: fields, and the progress indicators of screens that are busy
|
||||
WIDGETS = [FIELD, "android.widget.ProgressBar"]
|
||||
|
||||
|
||||
def normalise(text):
|
||||
return "".join(c for c in text.lower() if c.isalnum())
|
||||
|
||||
|
||||
def label(node):
|
||||
return " ".join((node.get("text") or node.get("content-desc") or "").split())
|
||||
|
||||
|
||||
# Elements off the screen, such as the chat list while a chat covers it, have empty bounds.
|
||||
def nodes(node, in_clickable=False):
|
||||
clickable = in_clickable or node.get("clickable") == "true"
|
||||
bounds = BOUNDS.fullmatch(node.get("bounds", ""))
|
||||
if bounds:
|
||||
left, top, right, bottom = map(int, bounds.groups())
|
||||
if right > left and bottom > top:
|
||||
yield node, clickable, (left, top, right, bottom)
|
||||
for child in node:
|
||||
yield from nodes(child, clickable)
|
||||
|
||||
|
||||
def tree_line(node, bounds):
|
||||
left, top, right, bottom = bounds
|
||||
states = [s for s in STATES if node.get(s) == "true"] + (["disabled"] if node.get("enabled") == "false" else [])
|
||||
kind = node.get("class", "").rsplit(".", 1)[-1]
|
||||
return f"{kind} {json.dumps(label(node))} at {left},{top} size {right - left}x{bottom - top}{''.join(' ' + s for s in states)}"
|
||||
|
||||
|
||||
def main():
|
||||
path, command = sys.argv[1], sys.argv[2]
|
||||
found = list(nodes(ET.parse(path).getroot()))
|
||||
labelled = [(label(n), clickable, bounds) for n, clickable, bounds in found if label(n)]
|
||||
if command == "lines":
|
||||
for text, _, (left, top, right, bottom) in labelled:
|
||||
print(f"{(left + right) // 2},{(top + bottom) // 2} {text}")
|
||||
elif command == "tree":
|
||||
for node, _, bounds in found:
|
||||
if label(node) or node.get("class") in WIDGETS or any(node.get(s) == "true" for s in ["clickable", "checkable", "scrollable"]):
|
||||
print(tree_line(node, bounds))
|
||||
elif command == "focused":
|
||||
text = " ".join(sys.argv[3].split()) if len(sys.argv) > 3 else ""
|
||||
if not any(n.get("class") == FIELD and n.get("focused") == "true" and text in label(n) for n, _, _ in found):
|
||||
sys.exit(1)
|
||||
elif command == "find":
|
||||
target = normalise(sys.argv[3])
|
||||
matches = [e for e in labelled if target and target in normalise(e[0])]
|
||||
if not matches:
|
||||
sys.exit(1)
|
||||
_, _, (left, top, right, bottom) = min(matches, key=lambda e: (normalise(e[0]) != target, not e[1]))
|
||||
print(f"{(left + right) // 2} {(top + bottom) // 2} {left} {top} {right} {bottom}")
|
||||
else:
|
||||
sys.exit(f"unknown command: {command}")
|
||||
|
||||
|
||||
main()
|
||||
Executable
+303
@@ -0,0 +1,303 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Installs the APK in the booted emulator (emulator.sh) and runs the scripted UI cases with the ui helper: onboarding to
|
||||
# the chat list, a local group, then the chat list with the reachable toolbar (the default) and with the top bar.
|
||||
# Usage: scripts/ci/android/smoke-test.sh <apk> <output dir> [start|onboarding|layouts|backend]
|
||||
# With "start" it stops at the first screen, for an agent to go on from; with "onboarding", at the chat list. "backend"
|
||||
# also adds the test SMP server (NAMES_SERVER, from backend.sh, reached through emulator.sh reverse) under Your servers
|
||||
# and tests it, before both layouts.
|
||||
# Each step saves a numbered screenshot, the accessibility tree and the text on screen to the output dir, prints the
|
||||
# text to the log, and adds a line to steps.log there; logcat is in app.log. What the screens show is checked without
|
||||
# stopping: every check is listed in checks.txt and the job summary, and the test fails at the end if any failed.
|
||||
# The app keeps running for later steps of the job, which get UI_OUT.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
apk=$1
|
||||
export UI_OUT=$2
|
||||
cases=${3:-layouts}
|
||||
case "$cases" in
|
||||
start | onboarding | layouts | backend) ;;
|
||||
*)
|
||||
echo "error: unknown case \"$cases\": start, onboarding, layouts (the default) or backend"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
package=chat.simplex.app
|
||||
local_chat="seeded notes"
|
||||
search_field="Search or paste SimpleX link"
|
||||
ui=$(dirname "$0")/ui
|
||||
checks=$UI_OUT/checks.txt
|
||||
mkdir -p "$UI_OUT"
|
||||
: > "$checks"
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "UI_OUT=$UI_OUT" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
report_checks() {
|
||||
[ -s "$checks" ] || return 0
|
||||
echo "::group::checks"
|
||||
cat "$checks"
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{
|
||||
echo "### Smoke test checks"
|
||||
echo
|
||||
sed 's/^/- /' "$checks"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
local status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
"$ui" shot final || true
|
||||
fi
|
||||
echo "::group::the core and crashes in logcat"
|
||||
grep -E 'ndk_translation|SIMPLEX *: (start|App crashed)|AndroidRuntime: (FATAL EXCEPTION|Process: )|Fatal signal' "$UI_OUT/app.log" | head -n 100 || true
|
||||
echo "::endgroup::"
|
||||
report_checks
|
||||
exit "$status"
|
||||
}
|
||||
trap finish EXIT
|
||||
|
||||
fail() {
|
||||
echo "error: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
check() {
|
||||
local what=$1
|
||||
shift
|
||||
if "$@"; then
|
||||
echo "PASS $what" | tee -a "$checks"
|
||||
else
|
||||
echo "FAIL $what" | tee -a "$checks"
|
||||
echo "::error::$what"
|
||||
fi
|
||||
}
|
||||
|
||||
# A crash: the app logs its own under SIMPLEX ("App crashed"), Android's FATAL EXCEPTION covers one before the app's
|
||||
# handler is set, and a native fatal signal names the process by the last 15 characters of its name. Not any line
|
||||
# with "error" or from AndroidRuntime, which logs every uiautomator dump.
|
||||
app_running() {
|
||||
! grep -q 'SIMPLEX *: App crashed' "$UI_OUT/app.log" &&
|
||||
! grep -A 1 'FATAL EXCEPTION' "$UI_OUT/app.log" | grep -q "Process: $package," &&
|
||||
! grep -q "Fatal signal .*(${package: -15})" "$UI_OUT/app.log" &&
|
||||
adb shell pidof "$package" > /dev/null
|
||||
}
|
||||
|
||||
y_of() {
|
||||
local pos
|
||||
pos=$("$ui" find "$1") || return 1
|
||||
cut -d ' ' -f 2 <<< "$pos"
|
||||
}
|
||||
|
||||
above() {
|
||||
local a b
|
||||
a=$(y_of "$1") || return 1
|
||||
b=$(y_of "$2") || return 1
|
||||
[ "$a" -lt "$b" ]
|
||||
}
|
||||
|
||||
screen_height() {
|
||||
adb shell wm size | sed -n 's/^Physical size: [0-9]*x//p'
|
||||
}
|
||||
|
||||
# Back closes one screen at a time, here between the screens of Settings, which cover the chat list without removing it
|
||||
# from the tree: a restart is what returns to the chat list.
|
||||
back_to() {
|
||||
for _ in 1 2 3 4 5; do
|
||||
"$ui" find "$1" > /dev/null 2>&1 && return 0
|
||||
"$ui" key BACK
|
||||
done
|
||||
"$ui" find "$1" > /dev/null
|
||||
}
|
||||
|
||||
# On a first start the chat list can show for a moment before onboarding, long enough to open its What's new sheet over
|
||||
# the first screen: it is closed and noted, not failed.
|
||||
first_screen() {
|
||||
local deadline=$((SECONDS + 180))
|
||||
until "$ui" find "Get started" > /dev/null 2>&1; do
|
||||
app_running || return 1
|
||||
if "$ui" find "New in" > /dev/null 2>&1; then
|
||||
echo "NOTE the What's new sheet covered the first onboarding screen" | tee -a "$checks"
|
||||
"$ui" shot whats-new-over-onboarding
|
||||
"$ui" click-text OK
|
||||
fi
|
||||
[ "$SECONDS" -lt "$deadline" ] || return 1
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# After Your network Android asks about instant notifications, in front of the conditions: the app's alert, then the
|
||||
# system's battery dialog, each with "Allow". The conditions show for a moment between the two.
|
||||
allow_notifications_and_accept() {
|
||||
local deadline=$((SECONDS + 120))
|
||||
until "$ui" find "Talk to someone" > /dev/null 2>&1; do
|
||||
if "$ui" find "Allow" > /dev/null 2>&1; then
|
||||
"$ui" shot notifications
|
||||
"$ui" click-text "Allow"
|
||||
elif "$ui" find "Accept" > /dev/null 2>&1; then
|
||||
"$ui" shot network-commitments
|
||||
"$ui" click-text "Accept"
|
||||
fi
|
||||
[ "$SECONDS" -lt "$deadline" ] || return 1
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# The centre "x y" of the first text field below the text, which may have neither a text nor a placeholder.
|
||||
field_below() {
|
||||
local y
|
||||
y=$(y_of "$1") || return 1
|
||||
"$ui" tree | sed -n -E 's/^EditText .* at ([0-9]+),([0-9]+) size ([0-9]+)x([0-9]+).*/\1 \2 \3 \4/p' |
|
||||
awk -v y="$y" '$2 > y { print $1 + int($3 / 2), $2 + int($4 / 2); exit }'
|
||||
}
|
||||
|
||||
# The centre "x y" of the switch in the row of the text.
|
||||
switch_of() {
|
||||
local y
|
||||
y=$(y_of "$1") || return 1
|
||||
"$ui" tree | sed -n -E 's/.* at ([0-9]+),([0-9]+) size ([0-9]+)x([0-9]+) .*checkable.*/\1 \2 \3 \4/p' |
|
||||
awk -v y="$y" '$2 <= y && y <= $2 + $4 { print $1 + int($3 / 2), $2 + int($4 / 2); exit }'
|
||||
}
|
||||
|
||||
# The user picker's button in the toolbar, before the list has chats: chats show the same profile image placeholder.
|
||||
open_settings() {
|
||||
"$ui" click-text "profile image placeholder" 10
|
||||
"$ui" click-text "Settings" 10
|
||||
}
|
||||
|
||||
# Green pixels of the test status left of a server's host name: SimplexGreen, the tick of a server that passed its test.
|
||||
green_before() {
|
||||
local x y l t r b png
|
||||
read -r x y l t r b < <("$ui" find "$1") || return 1
|
||||
"$ui" shot "$2" > /dev/null || return 1
|
||||
png=$(find "$UI_OUT" -maxdepth 1 -name "*-$2.png" | sort | tail -n 1)
|
||||
convert "$png" -alpha off -crop "${l}x$((b - t))+0+$t" +repage -fuzz 20% -fill black +opaque 'rgb(77,218,103)' \
|
||||
-fill white -opaque 'rgb(77,218,103)' -colorspace Gray -format '%[fx:round(mean*w*h)]' info:
|
||||
}
|
||||
|
||||
# Waits up to 60 s for Test servers to finish: the test status turns green, or the app shows that the test failed.
|
||||
server_tested() {
|
||||
local deadline=$((SECONDS + 60)) green
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
if "$ui" find "Server test failed" > /dev/null 2>&1; then
|
||||
"$ui" shot server-test-failed
|
||||
return 1
|
||||
fi
|
||||
green=$(green_before 127.0.0.1 server-test) || return 1
|
||||
echo "green pixels before the test server's host: $green"
|
||||
if [ "$green" -ge 50 ]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Settings > Network & servers > Your servers: the test SMP server added, tested and saved. Its address has 127.0.0.1,
|
||||
# which the emulator forwards to the runner's.
|
||||
test_server() {
|
||||
local x y
|
||||
open_settings
|
||||
"$ui" click-text "Network & servers" 10
|
||||
"$ui" click-text "Your servers" 10
|
||||
"$ui" click-text "Add server" 10
|
||||
"$ui" click-text "Enter server manually" 10
|
||||
"$ui" wait "Your server address" 10
|
||||
read -r x y < <(field_below "Your server address") || fail "no address field for the new server"
|
||||
"$ui" click "$x" "$y"
|
||||
"$ui" type "$NAMES_SERVER"
|
||||
# a server entered by hand is off, and Test servers tests only the servers that are on
|
||||
read -r x y < <(switch_of "Use for new connections") || fail "no \"Use for new connections\" switch"
|
||||
"$ui" click "$x" "$y"
|
||||
"$ui" shot new-server
|
||||
back_to "Test servers" || fail "no Your servers after adding the server"
|
||||
"$ui" wait 127.0.0.1 10
|
||||
"$ui" click-text "Test servers"
|
||||
check "the test SMP server passes Test servers" server_tested
|
||||
back_to "Save servers" || fail "no Network & servers after Your servers"
|
||||
"$ui" click-text "Save servers"
|
||||
"$ui" shot servers-saved
|
||||
"$ui" relaunch
|
||||
}
|
||||
|
||||
# A group of the user's own: without chats the chat list shows the onboarding pages, not the search field. The new
|
||||
# group's name field has the focus.
|
||||
create_local_chat() {
|
||||
"$ui" click-text "Start new chat" 10
|
||||
"$ui" click-text "Create group" 10
|
||||
"$ui" wait "Enter group name" 10
|
||||
"$ui" type "$local_chat"
|
||||
"$ui" wait "$local_chat" 10
|
||||
"$ui" shot new-group
|
||||
"$ui" click-text "Create group" 10
|
||||
# the app offers to invite members once it has created the group; without contacts it has no "Skip inviting members"
|
||||
"$ui" wait "Invite members" 30
|
||||
"$ui" shot group-created
|
||||
"$ui" relaunch
|
||||
"$ui" wait "$search_field" 30
|
||||
"$ui" shot local-chat
|
||||
}
|
||||
|
||||
# The search field and "Add list" next to it: at the bottom of the chat list, "Add list" above the field; at the top, below it.
|
||||
check_layout() {
|
||||
local layout=$1 field half
|
||||
half=$(($(screen_height) / 2))
|
||||
field=$(y_of "$search_field") || field=""
|
||||
"$ui" shot "$layout-layout"
|
||||
check "$layout: \"$local_chat\" listed" "$ui" find "$local_chat"
|
||||
if [ "$layout" = reachable ]; then
|
||||
check "reachable: the search field is in the bottom half" [ "${field:-0}" -gt "$half" ]
|
||||
check "reachable: \"Add list\" above the search field" above "Add list" "$search_field"
|
||||
else
|
||||
check "top: the search field is in the top half" [ "${field:-$((half * 2))}" -lt "$half" ]
|
||||
check "top: \"Add list\" below the search field" above "$search_field" "Add list"
|
||||
fi
|
||||
}
|
||||
|
||||
# private routing to unknown servers only: the preset servers that would proxy to the test SMP server can't reach the
|
||||
# runner's 127.0.0.1; developer tools at the debug level put the app's debug log in logcat
|
||||
started=$SECONDS
|
||||
"$ui" start "$apk" NetworkSMPProxyMode=Unknown DeveloperTools=true LogLevel=DEBUG
|
||||
first_screen || fail "no first onboarding screen 180 s after the start, or the app stopped: its crashes in logcat are below"
|
||||
echo "first screen after $((SECONDS - started)) s"
|
||||
"$ui" shot first-screen
|
||||
if [ "$cases" = start ]; then
|
||||
echo "smoke test: the app shows its first screen"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"$ui" click-text "Get started"
|
||||
"$ui" click-text "Enter profile name" 30
|
||||
"$ui" type citester
|
||||
"$ui" wait citester 10
|
||||
"$ui" shot profile-name
|
||||
"$ui" click-text "Create profile"
|
||||
|
||||
"$ui" wait "Continue" 60
|
||||
"$ui" shot your-network
|
||||
"$ui" click-text "Continue"
|
||||
|
||||
allow_notifications_and_accept || fail "no chat list 120 s after Your network"
|
||||
"$ui" shot chat-list
|
||||
echo "onboarding reached the chat list $((SECONDS - started)) s after the start"
|
||||
|
||||
if [ "$cases" != onboarding ]; then
|
||||
if [ "$cases" = backend ]; then
|
||||
: "${NAMES_SERVER:?NAMES_SERVER must be the test SMP server address}"
|
||||
test_server
|
||||
fi
|
||||
create_local_chat
|
||||
check_layout reachable
|
||||
"$ui" layout top
|
||||
check_layout top
|
||||
fi
|
||||
check "the app is running and logged no crash" app_running
|
||||
|
||||
if grep -q '^FAIL' "$checks"; then
|
||||
fail "the smoke test failed checks: $(grep -c '^FAIL' "$checks") of $(grep -cE '^(PASS|FAIL)' "$checks")"
|
||||
fi
|
||||
echo "smoke test passed: $cases"
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Drives the Android app in the emulator with adb, for the scripted cases and for an agent on the runner.
|
||||
# Text and positions come from uiautomator's accessibility tree; coordinates are screen pixels, as in the screenshots.
|
||||
# Screenshots, trees and a log of every command (steps.log) go to UI_OUT, and logcat to UI_OUT/app.log.
|
||||
#
|
||||
# ui start <apk> [<setting>=<value>...]
|
||||
# install the app, give a debug build the settings (OneHandUI, NetworkSMPProxyMode,
|
||||
# DeveloperTools, LogLevel), and start it with logcat
|
||||
# ui shot <name> save UI_OUT/NN-<name>.png, NN-<name>.xml (the tree) and NN-<name>.txt, and print the text on screen
|
||||
# ui text print the text on screen, one element per line after its centre x,y
|
||||
# ui tree print the elements that have a text or can be used: class, text, bounds and states
|
||||
# ui find <text> print "x y left top right bottom", the element's centre and bounds; fail if it is not on screen
|
||||
# ui wait <text> [secs] wait up to secs (30) until the text is on screen
|
||||
# ui click <x> <y> tap at x,y
|
||||
# ui click-text <text> [secs] wait up to secs (20) for the text and tap its centre
|
||||
# ui scroll <x> <y> <up|down> [times]
|
||||
# swipe at x,y to scroll towards the top (up) or bottom (down) of the content, 3 times unless given
|
||||
# ui type <text> type ASCII text into the focused field, waiting up to 10 s for a field to have the focus;
|
||||
# fail unless the field then shows the text
|
||||
# ui key <key>... press keys by their Android names without KEYCODE_ (BACK, ENTER, DEL)
|
||||
# ui layout <reachable|top> restart the app with the reachable toolbar (the one-hand UI, the default) or the top bar
|
||||
# ui relaunch restart the app and wait for the chat list
|
||||
# ui text-size <scale> set the system font scale: 1 is the default, 1.3 and 2 are larger
|
||||
# ui do <command> [args] -- <command> [args] ...
|
||||
# run the commands in order, stopping at the first that fails
|
||||
# ui stop stop the app and logcat
|
||||
#
|
||||
# Text is compared ignoring case, spaces and punctuation; an element that is exactly the text wins over one that contains it.
|
||||
|
||||
set -euo pipefail
|
||||
# an agent on the runner runs this, and nothing it starts needs the agent's credentials
|
||||
unset CLAUDE_CODE_OAUTH_TOKEN
|
||||
|
||||
: "${UI_OUT:?UI_OUT must be the output directory}"
|
||||
# Claude Code's Bash tool builds its own PATH, without the SDK that install-sdk.sh put on the job's
|
||||
PATH=${ANDROID_HOME:?ANDROID_HOME must be the SDK from install-sdk.sh}/platform-tools:$PATH
|
||||
package=chat.simplex.app
|
||||
prefs_file=$package.SIMPLEX_APP_PREFS.xml
|
||||
# the button that opens New chat, in the toolbar or floating, whenever the chat list shows
|
||||
chat_list_button="Start new chat"
|
||||
state=${UI_STATE:-${RUNNER_TEMP:-/tmp}/ui}
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
mkdir -p "$UI_OUT" "$state"
|
||||
|
||||
log() {
|
||||
echo "$(date -u +%H:%M:%S) $*" | tee -a "$UI_OUT/steps.log"
|
||||
}
|
||||
|
||||
number() {
|
||||
if ! [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
echo "error: \"$1\" is not a number"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# uiautomator fails while the screen is changing ("could not get idle state"), so a dump is retried
|
||||
dump() {
|
||||
local attempt
|
||||
for attempt in 1 2 3 4 5; do
|
||||
adb shell rm -f /sdcard/ui.xml
|
||||
if adb shell uiautomator dump /sdcard/ui.xml > /dev/null 2>&1 && adb exec-out cat /sdcard/ui.xml > "$state/ui.xml" && grep -q '<hierarchy' "$state/ui.xml"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "error: uiautomator could not dump the screen" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
screen() {
|
||||
dump
|
||||
python3 "$here/screen-tree.py" "$state/ui.xml" "$@"
|
||||
}
|
||||
|
||||
wait_for() {
|
||||
local text=$1 deadline=$((SECONDS + $2))
|
||||
until screen find "$text" 2> /dev/null; do
|
||||
[ "$SECONDS" -lt "$deadline" ] || return 1
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
next_file() {
|
||||
local n
|
||||
n=$(find "$UI_OUT" -maxdepth 1 -name '[0-9][0-9]-*.png' | wc -l)
|
||||
printf '%s/%02d-%s' "$UI_OUT" $((n + 1)) "$1"
|
||||
}
|
||||
|
||||
screenshot() {
|
||||
local dir=$state/screenshot png
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir"
|
||||
# without a window the emulator composes the screen on the host, where the guest's screencap can't read it
|
||||
adb emu screenrecord screenshot "$dir" > /dev/null
|
||||
for _ in $(seq 20); do
|
||||
png=$(find "$dir" -name '*.png' -size +0 | head -n 1)
|
||||
if [ -n "$png" ]; then
|
||||
mv "$png" "$1"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "error: no screenshot from the emulator"
|
||||
return 1
|
||||
}
|
||||
|
||||
shot() {
|
||||
local f
|
||||
if ! [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||
echo "error: a shot name has only letters, digits, - and _"
|
||||
return 1
|
||||
fi
|
||||
f=$(next_file "$1")
|
||||
screenshot "$f.png"
|
||||
dump
|
||||
cp "$state/ui.xml" "$f.xml"
|
||||
python3 "$here/screen-tree.py" "$f.xml" lines > "$f.txt"
|
||||
echo "::group::text on screen: $(basename "$f")"
|
||||
cat "$f.txt"
|
||||
echo "::endgroup::"
|
||||
log "shot $(basename "$f").png"
|
||||
}
|
||||
|
||||
launch() {
|
||||
local activity
|
||||
activity=$(adb shell cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER "$package" | tail -n 1)
|
||||
adb shell am start -W -n "$activity"
|
||||
}
|
||||
|
||||
# A debug build lets run-as rewrite its preferences while the app is stopped; the file is read back to check the write.
|
||||
set_prefs() {
|
||||
adb shell am force-stop "$package"
|
||||
adb shell "run-as $package cat shared_prefs/$prefs_file 2> /dev/null" > "$state/prefs.xml" || true
|
||||
python3 "$here/prefs.py" "$state/prefs.xml" "$@" > "$state/prefs-new.xml"
|
||||
adb shell "run-as $package sh -c 'mkdir -p shared_prefs && cat > shared_prefs/$prefs_file'" < "$state/prefs-new.xml"
|
||||
if ! adb shell "run-as $package cat shared_prefs/$prefs_file" | cmp -s - "$state/prefs-new.xml"; then
|
||||
echo "error: the app's preferences are not what was written"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
start() {
|
||||
local apk=$1 started=$SECONDS
|
||||
shift
|
||||
adb install -r -t -g "$apk"
|
||||
log "installed in $((SECONDS - started)) s"
|
||||
if [ $# -gt 0 ]; then
|
||||
# a release build isn't debuggable, so run-as can't reach its preferences
|
||||
if adb shell run-as "$package" true 2> /dev/null; then
|
||||
set_prefs "$@"
|
||||
else
|
||||
log "start: $package is not debuggable, so it keeps its default settings, not $*"
|
||||
fi
|
||||
fi
|
||||
adb logcat -c
|
||||
setsid adb logcat -v threadtime < /dev/null >> "$UI_OUT/app.log" 2>&1 &
|
||||
echo $! > "$state/logcat.pid"
|
||||
launch
|
||||
}
|
||||
|
||||
relaunch() {
|
||||
adb shell am force-stop "$package"
|
||||
launch
|
||||
if ! wait_for "$chat_list_button" 90 > /dev/null; then
|
||||
echo "error: no chat list 90 s after the restart"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
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
|
||||
set_prefs "OneHandUI=$one_hand"
|
||||
relaunch
|
||||
}
|
||||
|
||||
# The app, slowed by the ARM translation, drops characters typed faster than it takes them, so the text is typed a few
|
||||
# characters at a time, each part checked on the field. Each part goes to the device's shell as one single-quoted word,
|
||||
# so neither shell interprets it; input text reads %s as a space.
|
||||
type_text() {
|
||||
local text=$1 typed="" part deadline=$((SECONDS + 10))
|
||||
until screen focused; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "error: no text field has the focus"
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
while [ -n "$text" ]; do
|
||||
part=${text:0:8}
|
||||
text=${text:8}
|
||||
typed+=$part
|
||||
part=${part// /%s}
|
||||
adb shell "input text '${part//\'/\'\\\'\'}'"
|
||||
deadline=$((SECONDS + 10))
|
||||
until screen focused "$typed"; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "error: the field doesn't show \"$typed\""
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
scroll() {
|
||||
local x=$1 y=$2 times=$4 to i
|
||||
number "$x"
|
||||
number "$y"
|
||||
number "$times"
|
||||
case "$3" in
|
||||
up) to=$((y + 600)) ;;
|
||||
down) to=$((y - 600)) ;;
|
||||
*)
|
||||
echo "error: scroll up or down, not \"$3\""
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
for ((i = 0; i < times; i++)); do
|
||||
adb shell input swipe "$x" "$y" "$x" "$to" 300
|
||||
done
|
||||
}
|
||||
|
||||
key() {
|
||||
local k
|
||||
for k in "$@"; do
|
||||
if ! [[ "$k" =~ ^[A-Z0-9_]+$ ]]; then
|
||||
echo "error: \"$k\" is not a key name such as BACK or ENTER"
|
||||
return 1
|
||||
fi
|
||||
adb shell input keyevent "KEYCODE_$k"
|
||||
done
|
||||
}
|
||||
|
||||
stop() {
|
||||
adb shell am force-stop "$package"
|
||||
if [ -f "$state/logcat.pid" ]; then
|
||||
kill "$(cat "$state/logcat.pid")" 2> /dev/null || true
|
||||
rm -f "$state/logcat.pid"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start)
|
||||
shift
|
||||
log "start $*"
|
||||
start "$@"
|
||||
;;
|
||||
shot) shot "$2" ;;
|
||||
text) screen lines ;;
|
||||
tree) screen tree ;;
|
||||
find)
|
||||
if ! pos=$(screen find "$2"); then
|
||||
log "find \"$2\": not on screen" >&2
|
||||
exit 1
|
||||
fi
|
||||
log "find \"$2\": $pos" > /dev/null
|
||||
echo "$pos"
|
||||
;;
|
||||
wait)
|
||||
secs=${3:-30}
|
||||
number "$secs"
|
||||
if ! pos=$(wait_for "$2" "$secs"); then
|
||||
log "wait \"$2\": not on screen after $secs s"
|
||||
exit 1
|
||||
fi
|
||||
read -r x y _ <<< "$pos"
|
||||
log "wait \"$2\": at $x,$y"
|
||||
;;
|
||||
click)
|
||||
number "$2"
|
||||
number "$3"
|
||||
adb shell input tap "$2" "$3"
|
||||
log "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
|
||||
fi
|
||||
read -r x y _ <<< "$pos"
|
||||
adb shell input tap "$x" "$y"
|
||||
log "click-text \"$2\" at $x,$y"
|
||||
;;
|
||||
scroll)
|
||||
scroll "$2" "$3" "$4" "${5:-3}"
|
||||
log "scroll $4 at $2,$3, ${5:-3} times"
|
||||
;;
|
||||
type)
|
||||
type_text "$2"
|
||||
log "type \"$2\""
|
||||
;;
|
||||
key)
|
||||
shift
|
||||
key "$@"
|
||||
log "key $*"
|
||||
;;
|
||||
layout)
|
||||
layout "$2"
|
||||
log "layout $2"
|
||||
;;
|
||||
relaunch)
|
||||
relaunch
|
||||
log "relaunch"
|
||||
;;
|
||||
text-size)
|
||||
number "$2"
|
||||
adb shell settings put system font_scale "$2"
|
||||
log "text-size $2"
|
||||
;;
|
||||
do)
|
||||
shift
|
||||
command=()
|
||||
for arg in "$@" --; do
|
||||
if [ "$arg" != -- ]; then
|
||||
command+=("$arg")
|
||||
elif [ ${#command[@]} -gt 0 ]; then
|
||||
"$0" "${command[@]}"
|
||||
command=()
|
||||
fi
|
||||
done
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
log "stop"
|
||||
;;
|
||||
*)
|
||||
sed -n '3,/^$/s/^# \{0,1\}//p' "$0"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Runs Claude Code to repeat the smoke test with the ui helper, as claude-test.md describes, and passes when
|
||||
# its final message ends with "CLAUDE-TEST: PASS".
|
||||
# Usage: scripts/ci/desktop-linux/claude-test.sh <output dir> <transcript .jsonl>
|
||||
# Runs Claude Code to repeat the smoke test with the ui helper, as the prompt (claude-test.md unless given) describes,
|
||||
# and passes when its final message ends with "CLAUDE-TEST: PASS".
|
||||
# Usage: scripts/ci/desktop-linux/claude-test.sh <output dir> <transcript .jsonl> [prompt .md]
|
||||
# Expects the app started (smoke-test.sh ... start) with UI_OUT under <output dir>, claude and ui on PATH,
|
||||
# CLAUDE_MODEL and CLAUDE_CODE_OAUTH_TOKEN. Claude works in <output dir>: the screenshots, its report.md and the
|
||||
# prompt it was given end up there. The transcript, Claude Code's stderr (<transcript>.stderr) and the guard hook's
|
||||
@@ -17,16 +17,17 @@ unset CLAUDE_CODE_OAUTH_TOKEN
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
out=$(cd "$1" && pwd)
|
||||
transcript=$2
|
||||
prompt=${3:-$here/claude-test.md}
|
||||
stderr=$transcript.stderr
|
||||
settings=$(mktemp)
|
||||
export CLAUDE_GUARD_LOG=$transcript.guard.log
|
||||
: > "$CLAUDE_GUARD_LOG"
|
||||
|
||||
{
|
||||
cat "$here/claude-test.md"
|
||||
cat "$prompt"
|
||||
echo
|
||||
echo '```'
|
||||
"$here/ui" || true
|
||||
ui || true
|
||||
echo '```'
|
||||
} > "$out/prompt.md"
|
||||
|
||||
|
||||
Reference in new issue
Block a user