mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-20 08:02:24 +00:00
ci: test the desktop app on Linux runners on request, with an optional Claude session (#7736)
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
name: desktop-linux
|
||||
|
||||
# A push runs the jobs only when its head commit's message asks for them: see scripts/ci/desktop-linux/README.md.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
# a squash merge's message lists the merged commits' subjects, with their tags
|
||||
- "!master"
|
||||
- "!stable"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
claude:
|
||||
description: Also run the Claude job, which spends the Claude subscription's usage limits
|
||||
type: boolean
|
||||
default: false
|
||||
backend:
|
||||
description: Also start the test backend and test the released app
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
# a push that asks for no run doesn't cancel one that does
|
||||
group: desktop-linux-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GHC_VER: 9.6.3
|
||||
CABAL_VER: 3.10.2.0
|
||||
JAVA_VER: 17
|
||||
CORE_PKG: desktop-core-linux-x86_64.tar.gz
|
||||
BACKEND_PKG: desktop-linux-backend.tar.gz
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: choose jobs, find earlier builds
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.head_commit.message, '[app-ci]') ||
|
||||
contains(github.event.head_commit.message, '[app-ci:desktop]') ||
|
||||
contains(github.event.head_commit.message, '[claude-test]') ||
|
||||
contains(github.event.head_commit.message, '[claude-test:desktop]')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
run-claude: ${{ steps.jobs.outputs.claude }}
|
||||
run-backend: ${{ steps.jobs.outputs.backend }}
|
||||
backend: ${{ steps.names.outputs.backend }}
|
||||
backend-run: ${{ steps.backend.outputs.run-id }}
|
||||
steps:
|
||||
- name: Choose jobs
|
||||
id: jobs
|
||||
env:
|
||||
CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }}
|
||||
BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }}
|
||||
run: |
|
||||
echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
|
||||
echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout code
|
||||
if: steps.jobs.outputs.backend == 'true'
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded
|
||||
- name: Artifact names
|
||||
id: names
|
||||
if: steps.jobs.outputs.backend == 'true'
|
||||
run: 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 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@v5
|
||||
|
||||
- name: Show runner
|
||||
run: |
|
||||
lsb_release -ds
|
||||
nproc
|
||||
free -h
|
||||
df -h /
|
||||
|
||||
- name: Setup Haskell
|
||||
id: haskell
|
||||
uses: simplex-chat/setup-haskell-action@v2
|
||||
with:
|
||||
ghc-version: ${{ env.GHC_VER }}
|
||||
cabal-version: ${{ env.CABAL_VER }}
|
||||
|
||||
# before the first backend store exists, the core library's has most of the same Hackage dependencies
|
||||
- name: Restore cabal store
|
||||
id: cabal-cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
${{ steps.haskell.outputs.cabal-store }}
|
||||
dist-newstyle
|
||||
key: desktop-linux-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
desktop-linux-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-
|
||||
desktop-linux-backend-store-ghc${{ env.GHC_VER }}-
|
||||
desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-
|
||||
|
||||
- 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: Save cabal store
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
${{ steps.haskell.outputs.cabal-store }}
|
||||
dist-newstyle
|
||||
key: ${{ steps.cabal-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload backend
|
||||
uses: actions/upload-artifact@v6
|
||||
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 Linux
|
||||
needs: plan
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Show runner
|
||||
run: |
|
||||
lsb_release -ds
|
||||
nproc
|
||||
free -h
|
||||
df -h /
|
||||
|
||||
- name: Restore core library
|
||||
id: core-cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/core/${{ env.CORE_PKG }}
|
||||
key: desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }}
|
||||
|
||||
- name: Setup Haskell
|
||||
id: haskell
|
||||
if: steps.core-cache.outputs.cache-hit != 'true'
|
||||
uses: simplex-chat/setup-haskell-action@v2
|
||||
with:
|
||||
ghc-version: ${{ env.GHC_VER }}
|
||||
cabal-version: ${{ env.CABAL_VER }}
|
||||
|
||||
- name: Restore cabal store
|
||||
id: cabal-cache
|
||||
if: steps.core-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
${{ steps.haskell.outputs.cabal-store }}
|
||||
dist-newstyle
|
||||
key: desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-
|
||||
desktop-linux-core-store-ghc${{ env.GHC_VER }}-
|
||||
|
||||
- name: Install libraries
|
||||
if: steps.core-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev patchelf
|
||||
|
||||
- name: Build core library
|
||||
if: steps.core-cache.outputs.cache-hit != 'true'
|
||||
run: scripts/ci/desktop-linux/build-core.sh ~/core/${{ env.CORE_PKG }}
|
||||
|
||||
- name: Save cabal store
|
||||
if: ${{ !cancelled() && steps.core-cache.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
${{ steps.haskell.outputs.cabal-store }}
|
||||
dist-newstyle
|
||||
key: ${{ steps.cabal-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save core library
|
||||
if: steps.core-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ~/core/${{ env.CORE_PKG }}
|
||||
key: ${{ steps.core-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload core library
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: desktop-core-linux-x86_64
|
||||
path: ~/core/${{ env.CORE_PKG }}
|
||||
compression-level: 0
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
app:
|
||||
name: desktop app under Xvfb
|
||||
needs: [plan, core, backend]
|
||||
# backend is skipped when it isn't asked for or an earlier run built it
|
||||
if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
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" || true
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
|
||||
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
|
||||
|
||||
- name: Download core library
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: desktop-core-linux-x86_64
|
||||
path: ~/core
|
||||
|
||||
- name: Install core library
|
||||
id: core
|
||||
run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }}
|
||||
|
||||
- name: Download backend
|
||||
if: needs.plan.outputs.run-backend == 'true'
|
||||
uses: actions/download-artifact@v7
|
||||
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 }}
|
||||
cache: gradle
|
||||
|
||||
- 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
|
||||
app=release/main/app/simplex
|
||||
ls -la "$app/bin" "$app/lib/app/resources"
|
||||
du -sh "$app"
|
||||
LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so"
|
||||
|
||||
- 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
|
||||
|
||||
- name: Report desktop tests
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
run: scripts/ci/desktop-linux/test-summary.py apps/multiplatform/common/build/test-results/desktopTest
|
||||
|
||||
- name: Run smoke test
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' }}
|
||||
env:
|
||||
BACKEND: ${{ steps.backend.outcome }}
|
||||
run: |
|
||||
if [ "$BACKEND" = success ]; then
|
||||
export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server")
|
||||
cases=backend
|
||||
fi
|
||||
scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$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@v6
|
||||
with:
|
||||
name: desktop-smoke-test
|
||||
path: ${{ runner.temp }}/smoke-test
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload desktop test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: desktop-test-report
|
||||
path: |
|
||||
apps/multiplatform/common/build/reports/tests/desktopTest
|
||||
apps/multiplatform/common/build/test-results/desktopTest
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload backend logs
|
||||
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: desktop-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 desktop app
|
||||
needs: [plan, core, backend]
|
||||
if: ${{ !cancelled() && !failure() && needs.plan.outputs.run-claude == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
environment:
|
||||
name: app-sim-claude
|
||||
deployment: false
|
||||
concurrency:
|
||||
group: desktop-linux-claude-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# the first release with Haiku 5.5's token counts right (2.1.293 added the model, 2.1.296 fixed its counts)
|
||||
CLAUDE_CODE_VERSION: 2.1.296
|
||||
CLAUDE_MODEL: claude-haiku-5-5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: recursive
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
|
||||
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
|
||||
|
||||
- name: Download core library
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: desktop-core-linux-x86_64
|
||||
path: ~/core
|
||||
|
||||
- name: Install core library
|
||||
run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }}
|
||||
|
||||
- name: Download backend
|
||||
if: needs.plan.outputs.run-backend == 'true'
|
||||
uses: actions/download-artifact@v7
|
||||
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
|
||||
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"
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: corretto
|
||||
java-version: ${{ env.JAVA_VER }}
|
||||
cache: gradle
|
||||
|
||||
- name: Build app
|
||||
working-directory: apps/multiplatform
|
||||
run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable
|
||||
|
||||
# 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/desktop-linux/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui
|
||||
sudo chmod +x /usr/local/bin/ui
|
||||
|
||||
- name: Start the app
|
||||
run: scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$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"
|
||||
|
||||
- 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() && needs.plan.outputs.run-backend == 'true' }}
|
||||
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
|
||||
|
||||
- name: Upload Claude test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: desktop-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() && needs.plan.outputs.run-backend == 'true' }}
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: desktop-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 under Xvfb
|
||||
needs: plan
|
||||
if: needs.plan.outputs.run-backend == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
filter: tree:0
|
||||
|
||||
- name: Download released app
|
||||
run: scripts/ci/desktop-linux/release-app.sh "$RUNNER_TEMP/release"
|
||||
|
||||
- name: Install UI tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr
|
||||
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr
|
||||
|
||||
- name: Run smoke test
|
||||
run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/release/app/opt/simplex" "$RUNNER_TEMP/smoke-test" onboarding
|
||||
|
||||
- name: Upload smoke test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: desktop-release-smoke-test
|
||||
path: ${{ runner.temp }}/smoke-test
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,61 @@
|
||||
# Desktop app CI on Linux
|
||||
|
||||
`.github/workflows/desktop-linux.yml` builds the desktop app from the branch on a GitHub runner (Ubuntu 24.04), runs its JVM tests (`:common:desktopTest`), and drives the app on a virtual display: onboarding to the chat list, then the chat list with the toolbar at the bottom (the default) and at the top. On request it also runs Claude Code driving the same path, a test backend, and the latest release as a baseline.
|
||||
|
||||
## Running it
|
||||
|
||||
On any branch, in either of two ways:
|
||||
|
||||
- **Dispatch:** Actions → desktop-linux → Run workflow, with the `claude` and `backend` checkboxes (both off by default).
|
||||
- **Push** with a tag in the head commit's message, which is how agents in push sessions ask for a run:
|
||||
|
||||
| Tag | Runs |
|
||||
|---|---|
|
||||
| `[app-ci]` | the scripted test, here and in the iOS app's workflow where the branch has it |
|
||||
| `[app-ci:desktop]` | the scripted test, here only |
|
||||
| `[claude-test]` | the scripted test and the Claude job, here and in the iOS app's workflow where the branch has it |
|
||||
| `[claude-test:desktop]` | the scripted test and the Claude job, here only |
|
||||
| `[ci-backend]` | with one of the above: also the test backend and the release job |
|
||||
|
||||
A push without these tags (or with only iOS tags) starts a run whose jobs are all skipped, and it doesn't cancel a run in progress. A newer run that was asked for cancels an older one on the same branch. Pushes to master and stable never start it, because a squash merge's message lists the merged commits' subjects with their tags; dispatch it there instead.
|
||||
|
||||
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: the core by a cache keyed on `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md` and the build scripts; the backend by an artifact of the same branch named after a hash of its sources (30 days). So a branch that changes core and app code together tests both.
|
||||
|
||||
## Jobs
|
||||
|
||||
| Job | Does | Time |
|
||||
|---|---|---|
|
||||
| `plan` | chooses the jobs, finds an earlier backend build | 10 s |
|
||||
| `core` | `scripts/desktop/build-lib-linux.sh` via `build-core.sh` | 15 s cached, about 23 min not |
|
||||
| `app` | the app (`:desktop:createDistributable`), desktopTest, `smoke-test.sh` | about 10 min: build 4.5, desktopTest 0.5, smoke test 3.5 |
|
||||
| `claude` (opt-in) | the app at its first screen, then Claude Code drives it | about 7 min, Claude 2.5 |
|
||||
| `backend` (opt-in) | `build-backend.sh`: `simplex-chat`, `simplex-directory-service`, and `smp-server` from the simplexmq commit `cabal.project` pins | about 27 min, skipped when reused |
|
||||
| `release` (opt-in) | the latest release's .deb for this Ubuntu, onboarding only, to tell a branch's failure from the runner's | 1.5 min |
|
||||
|
||||
Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-claude-test`, and the backend's logs. The job summaries list the checks, the desktopTest results and the Claude usage.
|
||||
|
||||
## 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.
|
||||
- `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-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
|
||||
|
||||
`backend.sh` starts, on the runner, with CLI peers:
|
||||
|
||||
- a chat relay (`--relay --headless`) and a directory service on the preset servers;
|
||||
- a group `ci_group` and a channel `ci_channel` with letter avatars as large as the apps make them (`avatar`), registered and approved by the admin `ci_admin`, and found by a new user who sends the directory "ci seed";
|
||||
- an SMP server on 127.0.0.1:5223 whose `[NAMES]` section asks `names-resolver.py`, and a contact `ci_contact` whose address it resolves as `seeded.testing`, checked with a connect plan from another client.
|
||||
|
||||
It writes the directory's address to `<dir>/directory-link` and the SMP server's to `<dir>/names-server`. The app reaches the names server only with private routing set to unknown servers (`ui start … NetworkSMPProxyMode=Unknown`), since the preset proxies can't reach 127.0.0.1, and with the test server's receiving role off: a directory can't answer requests whose reply queue is on it.
|
||||
|
||||
## 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. The token goes only to the step that runs `claude`, and every `ui` call unsets it. With `[ci-backend]` the job also starts the test backend for prompts that use it, and waits for its build; this smoke path's prompt doesn't use it. The job installs Claude Code 2.1.296 and runs `claude-haiku-5-5` with `--max-turns 60` and `--max-budget-usd 3`. 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
|
||||
|
||||
The runners are free for this public repository. A Claude run spends the subscription's usage limits, which interactive sessions share: Haiku 5.5 does this smoke path in 15 to 17 turns, about 0.008 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription).
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test backend on the app runner: a chat relay and a directory service on the preset servers, with one group
|
||||
# and one channel registered and approved by a CLI peer, and another CLI peer that must find both by sending the
|
||||
# directory the search text. Both have profile images as large as the apps make them.
|
||||
# For SimpleX names, an SMP server on 127.0.0.1 resolves .testing names through a local resolver, and a CLI peer
|
||||
# with an address on it claims the name seeded.testing.
|
||||
# Usage:
|
||||
# scripts/ci/desktop-linux/backend.sh start <backend .tar.gz> <work dir> writes the directory's short address to <work dir>/directory-link
|
||||
# and the names server's address to <work dir>/names-server
|
||||
# scripts/ci/desktop-linux/backend.sh stop <work dir> stops the servers, prints their logs
|
||||
# The relay, the directory, the SMP server and the resolver keep running between start and stop.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
directory_name="SimpleX Directory"
|
||||
admin=ci_admin
|
||||
search_text="ci seed"
|
||||
# the longest profile image data URI the apps make and the core accepts (maxProfileImageSize)
|
||||
max_image_size=12500
|
||||
name_label=seeded
|
||||
resolver_port=8090
|
||||
|
||||
# Runs one chat command in a CLI client that stays online for <secs> after it; the output is in $work/out.
|
||||
chat() {
|
||||
local db=$1 secs=$2 cmd=$3
|
||||
shift 3
|
||||
local status=0
|
||||
"$bin/simplex-chat" -d "$work/$db" -e "$cmd" -t "$secs" --execute-log all "$@" < /dev/null > "$work/out" 2>&1 || status=$?
|
||||
cat "$work/out" >> "$work/$db-cli.log"
|
||||
# commands with images are thousands of characters long
|
||||
echo "::group::$db: ${cmd:0:200}"
|
||||
cat "$work/out"
|
||||
echo "::endgroup::"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
admin() {
|
||||
chat "$admin" "$1" "$2" --user-display-name "$admin"
|
||||
}
|
||||
|
||||
expect_out() {
|
||||
if ! grep -qE "$1" "$work/out"; then
|
||||
echo "error: \"$1\" not in the output"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Each run keeps the client online for a few seconds, which also lets the handshakes it waits for progress.
|
||||
until_shown() {
|
||||
local client=$1 pattern=$2 secs=$3 cmd=$4
|
||||
local deadline=$((SECONDS + secs))
|
||||
until "$client" 5 "$cmd" && grep -qE "$pattern" "$work/out"; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "error: \"$pattern\" not shown by \"$cmd\" after $secs s"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
admin_until() {
|
||||
until_shown admin "$@"
|
||||
}
|
||||
|
||||
db_query() {
|
||||
sqlite3 "$work/$1_chat.db" "$2"
|
||||
}
|
||||
|
||||
# Prints a profile image data URI made as the apps make one: JPEG at quality 85, scaled down by the square root
|
||||
# of its excess, at most halved per step, until it is at most max_image_size characters. The picture fills the
|
||||
# whole square, a gradient between two shades of one colour with a large white letter; noise keeps JPEG from
|
||||
# compressing it much, so the image ends up near the limit.
|
||||
avatar() {
|
||||
local top=$1 bottom=$2 letter=$3 source=$work/avatar-source.png file=$work/avatar.jpg size=512 uri font
|
||||
font=$(fc-match -f '%{file}' 'DejaVu Sans:bold')
|
||||
convert -size 512x512 gradient:"$top-$bottom" -seed 1 -attenuate 0.5 +noise Gaussian \
|
||||
-font "$font" -pointsize 320 -fill white -gravity center -annotate +0+0 "$letter" "$source"
|
||||
while :; do
|
||||
convert "$source" -resize "${size}x${size}" -quality 85 "$file"
|
||||
uri="data:image/jpg;base64,$(base64 -w 0 "$file")"
|
||||
[ "${#uri}" -gt "$max_image_size" ] || break
|
||||
size=$(awk -v s="$size" -v n="${#uri}" -v m="$max_image_size" 'BEGIN { r = sqrt(n / m); if (r > 2) r = 2; print int(s / r) }')
|
||||
done
|
||||
echo "avatar \"$letter\" over $top to $bottom: ${size}x${size} px, ${#uri} characters, mean colour $(convert "$file" -resize '1x1!' -format '%[pixel:p{0,0}]' info:)" >&2
|
||||
echo "$uri"
|
||||
}
|
||||
|
||||
start_relay() {
|
||||
nohup "$bin/simplex-chat" -d "$work/relay" --relay --headless --user-display-name ci_relay < /dev/null > "$work/relay.log" 2>&1 &
|
||||
echo $! > "$work/relay.pid"
|
||||
local deadline=$((SECONDS + 120))
|
||||
until grep -qE '^https://[^ ]+/r#' "$work/relay.log"; do
|
||||
if ! kill -0 "$(cat "$work/relay.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "error: the relay did not create its address"
|
||||
cat "$work/relay.log"
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
relay_link=$(grep -m 1 -oE '^https://[^ ]+/r#[^ ]+' "$work/relay.log")
|
||||
echo "relay address: $relay_link"
|
||||
}
|
||||
|
||||
# The address is created here, like the directory tests do, with auto-accept on before the service subscribes to it.
|
||||
create_directory() {
|
||||
chat directory 1 "/ad pq_ratchet=on" --create-bot-display-name "$directory_name"
|
||||
directory_link=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out")
|
||||
case "$directory_link" in
|
||||
https://*/a#*) ;;
|
||||
*)
|
||||
echo "error: no short directory address in the output"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
chat directory 1 "/auto_accept on"
|
||||
expect_out "^auto_accept on"
|
||||
echo "$directory_link" > "$work/directory-link"
|
||||
echo "directory address: $directory_link"
|
||||
}
|
||||
|
||||
start_directory() {
|
||||
# -y: the service adds its own tables to the database the CLI created, and would otherwise ask on stdin
|
||||
nohup "$bin/simplex-directory-service" -d "$work/directory" -y --super-users "$1" < /dev/null >> "$work/directory.log" 2>&1 &
|
||||
echo $! > "$work/directory.pid"
|
||||
sleep 5
|
||||
if ! kill -0 "$(cat "$work/directory.pid")" 2> /dev/null; then
|
||||
echo "error: the directory service exited"
|
||||
cat "$work/directory.log"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop_directory() {
|
||||
local pid deadline
|
||||
pid=$(cat "$work/directory.pid")
|
||||
kill -INT "$pid" 2> /dev/null || true
|
||||
deadline=$((SECONDS + 30))
|
||||
while kill -0 "$pid" 2> /dev/null; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
kill -KILL "$pid" 2> /dev/null || true
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# A super user is known by contact ID, which exists only after the admin connects, so the service starts
|
||||
# with nobody and is restarted with the admin.
|
||||
connect_admin() {
|
||||
admin 10 "/c $directory_link"
|
||||
expect_out "connection request sent!"
|
||||
admin_until "Welcome to $directory_name" 180 "/tail @'$directory_name' 10"
|
||||
local id
|
||||
id=$(db_query directory "SELECT contact_id FROM contacts WHERE local_display_name = '$admin'")
|
||||
stop_directory
|
||||
start_directory "$id:$admin"
|
||||
echo "directory restarted with super user $id:$admin"
|
||||
}
|
||||
|
||||
# The directory sends each registration to super users as a message with the exact /approve command.
|
||||
approve() {
|
||||
local name=$1 reply=$2
|
||||
admin_until "/approve [0-9]+:$name [0-9]+" 300 "/tail @'$directory_name' 20"
|
||||
local cmd
|
||||
cmd=$(grep -oE "/approve [0-9]+:$name [0-9]+" "$work/out" | tail -n 1)
|
||||
admin 5 "@'$directory_name' $cmd"
|
||||
admin_until "$reply" 120 "/tail @'$directory_name' 20"
|
||||
}
|
||||
|
||||
# The CLI's group commands can't set an image, so groups are created with the API commands, with the profiles
|
||||
# that "/g" and "/public group" make plus the image.
|
||||
group_json() {
|
||||
local name=$1 descr=$2 image=$3 prefs=$4
|
||||
echo "{\"displayName\":\"$name\",\"fullName\":\"\",\"shortDescr\":\"$descr\",\"image\":\"$image\",\"groupPreferences\":{\"directMessages\":{\"enable\":\"on\"},\"history\":{\"enable\":\"on\"}$prefs}}"
|
||||
}
|
||||
|
||||
seed_group() {
|
||||
local user_id
|
||||
user_id=$(db_query "$admin" "SELECT user_id FROM users WHERE local_display_name = '$admin'")
|
||||
group_image=$(avatar "rgb(235,80,70)" "rgb(150,25,30)" G)
|
||||
admin 1 "/_group $user_id $(group_json ci_group "CI seeded group" "$group_image" "")"
|
||||
expect_out "group #ci_group \(CI seeded group\) is created"
|
||||
admin 5 "/a ci_group '$directory_name' admin"
|
||||
expect_out "invitation to join the group #ci_group sent to"
|
||||
approve ci_group "Group approved!"
|
||||
}
|
||||
|
||||
seed_channel() {
|
||||
admin 1 "/relays name=ci_relay $relay_link"
|
||||
expect_out "^ok$"
|
||||
local user_id relay_id
|
||||
user_id=$(db_query "$admin" "SELECT user_id FROM users WHERE local_display_name = '$admin'")
|
||||
relay_id=$(db_query "$admin" "SELECT chat_relay_id FROM chat_relays WHERE display_name = 'ci_relay' AND deleted = 0")
|
||||
channel_image=$(avatar "rgb(80,200,90)" "rgb(25,120,45)" C)
|
||||
admin 10 "/_public group $user_id $relay_id $(group_json ci_channel "CI seeded channel" "$channel_image" ",\"support\":{\"enable\":\"off\"}")"
|
||||
expect_out "group #ci_channel \(CI seeded channel\) is created"
|
||||
local group_id
|
||||
group_id=$(db_query "$admin" "SELECT group_id FROM groups WHERE local_display_name = 'ci_channel'")
|
||||
admin_until "relay id [0-9]+: active" 180 "/_get relays #$group_id"
|
||||
admin 5 "/share chat #ci_channel @'$directory_name'"
|
||||
expect_out "link to join channel #ci_channel"
|
||||
approve ci_channel "Channel approved!"
|
||||
}
|
||||
|
||||
# An SMP server on 127.0.0.1 whose [NAMES] section asks the local resolver, which answers from names.json.
|
||||
start_names_server() {
|
||||
local cfg=$work/smp/etc logs=$work/smp/var ini deadline
|
||||
echo '{}' > "$work/names.json"
|
||||
nohup python3 "$here/names-resolver.py" "$resolver_port" "$work/names.json" < /dev/null > "$work/resolver.log" 2>&1 &
|
||||
echo $! > "$work/resolver.pid"
|
||||
SMP_SERVER_CFG_PATH=$cfg SMP_SERVER_LOG_PATH=$logs "$bin/smp-server" init -y --ip 127.0.0.1 --no-password --disable-web
|
||||
ini=$cfg/smp-server.ini
|
||||
# only 5223: 443 needs root
|
||||
sed -i -e 's/^port = 5223,443$/port = 5223/' \
|
||||
-e '/^\[NAMES\]/,/^\[/{s/^enable: off$/enable: on/;s|^# resolver_endpoint: http://127.0.0.1:8000$|resolver_endpoint: http://127.0.0.1:'"$resolver_port"'|}' "$ini"
|
||||
if ! grep -qx 'port = 5223' "$ini" ||
|
||||
! sed -n '/^\[NAMES\]/,/^\[/p' "$ini" | grep -qx 'enable: on' ||
|
||||
! sed -n '/^\[NAMES\]/,/^\[/p' "$ini" | grep -qx "resolver_endpoint: http://127.0.0.1:$resolver_port"; then
|
||||
echo "error: $ini has a different port or [NAMES] than expected:"
|
||||
grep -E '^port|^\[NAMES\]|enable|resolver' "$ini"
|
||||
return 1
|
||||
fi
|
||||
SMP_SERVER_CFG_PATH=$cfg SMP_SERVER_LOG_PATH=$logs nohup "$bin/smp-server" start < /dev/null > "$work/smp-server.log" 2>&1 &
|
||||
echo $! > "$work/smp-server.pid"
|
||||
deadline=$((SECONDS + 60))
|
||||
until (exec 3<> /dev/tcp/127.0.0.1/5223) 2> /dev/null; do
|
||||
if ! kill -0 "$(cat "$work/smp-server.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "error: the SMP server is not listening on 5223"
|
||||
cat "$work/smp-server.log"
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
names_server="smp://$(cat "$cfg/fingerprint")@127.0.0.1"
|
||||
echo "$names_server" > "$work/names-server"
|
||||
echo "names server: $names_server"
|
||||
}
|
||||
|
||||
# A CLI client started with -s uses only that server and, once its user exists, gives it every role, names included.
|
||||
names_client() {
|
||||
local db=$1
|
||||
shift
|
||||
chat "$db" "$@" --user-display-name "$db" -s "$names_server"
|
||||
}
|
||||
|
||||
# ci_contact's address is on the names server; the resolver maps seeded.testing to it, and ci_contact claims the name.
|
||||
seed_name() {
|
||||
local image link
|
||||
image=$(avatar "rgb(70,130,240)" "rgb(30,50,170)" N)
|
||||
names_client ci_contact 1 "/_profile 1 {\"displayName\":\"ci_contact\",\"fullName\":\"\",\"shortDescr\":\"CI seeded contact\",\"image\":\"$image\"}"
|
||||
names_client ci_contact 3 "/ad"
|
||||
link=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out")
|
||||
case "$link" in
|
||||
https://*/a#*) ;;
|
||||
*)
|
||||
echo "error: no short address for ci_contact in the output"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
jq -n --arg n "$name_label" --arg l "$link" '{($n): {simplexContact: [$l]}}' > "$work/names.json"
|
||||
names_client ci_contact 5 "/_set domain 1 $name_label.testing"
|
||||
# what the app does on the Search key, from a client that knows nothing but the names server; it finds the
|
||||
# address only if ci_contact's claim is in the address data
|
||||
names_client name_probe 1 "/user"
|
||||
names_client name_probe 5 "/_connect plan 1 @$name_label.testing"
|
||||
expect_out "contact address: ok to connect"
|
||||
echo "@$name_label.testing resolves to ci_contact's address"
|
||||
}
|
||||
|
||||
searcher() {
|
||||
chat searcher "$1" "$2" --user-display-name ci_searcher
|
||||
}
|
||||
|
||||
# A new user connects to the directory and sends the search text; it replies with the count, then one message per
|
||||
# group or channel found, with its image.
|
||||
search_by_message() {
|
||||
searcher 10 "/c $directory_link"
|
||||
expect_out "connection request sent!"
|
||||
until_shown searcher "Welcome to $directory_name" 180 "/tail @'$directory_name' 10"
|
||||
searcher 5 "@'$directory_name' $search_text"
|
||||
until_shown searcher "Found 2 group\(s\)" 120 "/tail @'$directory_name' 10"
|
||||
until_shown searcher "ci_group" 60 "/tail @'$directory_name' 10"
|
||||
until_shown searcher "ci_channel" 60 "/tail @'$directory_name' 10"
|
||||
echo "search for \"$search_text\" by message found ci_group and ci_channel"
|
||||
}
|
||||
|
||||
dump_directory() {
|
||||
echo "::group::directory registrations"
|
||||
db_query directory "SELECT group_id, group_reg_status FROM sx_directory_group_regs" || true
|
||||
echo "::endgroup::"
|
||||
}
|
||||
|
||||
start() {
|
||||
bin=$work/bin
|
||||
mkdir -p "$bin"
|
||||
tar -C "$bin" -xzf "$1"
|
||||
"$bin/simplex-chat" --version
|
||||
"$bin/simplex-directory-service" --version
|
||||
|
||||
start_relay
|
||||
create_directory
|
||||
start_directory "0:nobody"
|
||||
trap dump_directory EXIT
|
||||
connect_admin
|
||||
seed_group
|
||||
seed_channel
|
||||
search_by_message
|
||||
start_names_server
|
||||
seed_name
|
||||
}
|
||||
|
||||
stop() {
|
||||
local p
|
||||
for p in directory relay smp-server resolver; do
|
||||
if [ -f "$work/$p.pid" ]; then
|
||||
kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true
|
||||
fi
|
||||
done
|
||||
sleep 3
|
||||
for p in directory relay smp-server resolver; do
|
||||
echo "::group::$p log"
|
||||
cat "$work/$p.log" 2> /dev/null || true
|
||||
echo "::endgroup::"
|
||||
done
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start)
|
||||
work=$3
|
||||
mkdir -p "$work"
|
||||
start "$2"
|
||||
;;
|
||||
stop)
|
||||
work=$2
|
||||
stop
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 start <backend .tar.gz> <work dir> | stop <work dir>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Builds the CLI, the directory service and the SMP server that the app job runs as its test backend.
|
||||
# Usage: scripts/ci/desktop-linux/build-backend.sh <output .tar.gz>
|
||||
# They need the server-capable configuration (no client_library), as build.yml's Linux job builds the CLI.
|
||||
# The SMP server, which resolves the test's SimpleX names, comes from a clone of the simplexmq commit that
|
||||
# cabal.project pins: cabal builds only the libraries of a source-repository-package.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p "$(dirname "$1")"
|
||||
out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
|
||||
ghc --version
|
||||
cabal --version
|
||||
du -sh ~/.cabal/store dist-newstyle 2> /dev/null || true
|
||||
|
||||
cat > cabal.project.local << EOF
|
||||
ignore-project: False
|
||||
jobs: \$ncpus
|
||||
|
||||
package direct-sqlcipher
|
||||
flags: +openssl
|
||||
EOF
|
||||
|
||||
cabal build exe:simplex-chat exe:simplex-directory-service
|
||||
|
||||
simplexmq_tag=$(sed -n '/simplex-chat\/simplexmq.git/{n;s/^ *tag: *//p}' cabal.project)
|
||||
smq=$(mktemp -d)
|
||||
git -C "$smq" init -q
|
||||
git -C "$smq" fetch -q --depth 1 https://github.com/simplex-chat/simplexmq.git "$simplexmq_tag"
|
||||
git -C "$smq" checkout -q FETCH_HEAD
|
||||
git -C "$smq" submodule update -q --init --recursive
|
||||
cp cabal.project.local "$smq"
|
||||
(cd "$smq" && cabal build exe:smp-server)
|
||||
|
||||
pkg=$(mktemp -d)
|
||||
for exe in simplex-chat simplex-directory-service; do
|
||||
cp "$(cabal list-bin "exe:$exe")" "$pkg"
|
||||
done
|
||||
# in a fresh project list-bin first prints git's output from syncing the source-repository-packages
|
||||
cp "$(cd "$smq" && cabal list-bin exe:smp-server | tail -n 1)" "$pkg"
|
||||
ldd "$pkg"/*
|
||||
|
||||
tar -C "$pkg" -czf "$out" .
|
||||
ls -la "$out"
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Builds the desktop core library for Linux with scripts/desktop/build-lib-linux.sh, as build.yml does, and packs its libs directory.
|
||||
# Usage: scripts/ci/desktop-linux/build-core.sh <output .tar.gz>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p "$(dirname "$1")"
|
||||
out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
arch=$(uname -m)
|
||||
ghc_ver=$(ghc --numeric-version)
|
||||
version=$(sed -n 's/^version: *//p' simplex-chat.cabal)
|
||||
libs_dir=apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-$arch
|
||||
|
||||
ghc --version
|
||||
cabal --version
|
||||
du -sh ~/.local/state/cabal/store ~/.cabal/store dist-newstyle 2> /dev/null || true
|
||||
|
||||
cat > cabal.project.local << EOF
|
||||
ignore-project: False
|
||||
jobs: \$ncpus
|
||||
|
||||
package direct-sqlcipher
|
||||
flags: +openssl
|
||||
EOF
|
||||
|
||||
# a restored dist-newstyle can hold an older version's build directory, which breaks the script's simplex-chat-* glob
|
||||
find "dist-newstyle/build/$arch-linux/ghc-$ghc_ver" -maxdepth 1 -name 'simplex-chat-*' ! -name "simplex-chat-$version" -exec rm -rf {} + 2> /dev/null || true
|
||||
|
||||
SKIP_VLC=1 scripts/desktop/build-lib-linux.sh
|
||||
|
||||
ls -la "$libs_dir"
|
||||
readelf -d "$libs_dir/libsimplex.so" | grep -E 'SONAME|RUNPATH|NEEDED'
|
||||
echo "Selected symbols defined in libsimplex.so:"
|
||||
nm -D --defined-only "$libs_dir/libsimplex.so" | awk '$NF ~ /^(chat_migrate_init_key|chat_send_cmd_retry|chat_recv_msg_wait)$/ {print " " $NF}'
|
||||
du -sh "$libs_dir"
|
||||
|
||||
tar -C "$libs_dir" -czf "$out" .
|
||||
ls -la "$out"
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# PreToolUse hook for Claude on the runner: lets through only Bash commands that run the ui helper, with plain
|
||||
# arguments, so the session has no general shell. Claude Code runs read-only commands such as cat and echo without
|
||||
# asking in every permission mode, so permission rules alone can't exclude them.
|
||||
# Exits 2 for anything else; Claude sees the message as the reason. Each decision is appended to CLAUDE_GUARD_LOG
|
||||
# when it is set, as evidence that the hook ran.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# nothing that chains, pipes, redirects, substitutes, expands or escapes
|
||||
UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?")
|
||||
|
||||
call = json.load(sys.stdin)
|
||||
command = (call.get("tool_input") or {}).get("command", "").strip()
|
||||
allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command)
|
||||
if os.environ.get("CLAUDE_GUARD_LOG"):
|
||||
with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log:
|
||||
log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n")
|
||||
if not allowed:
|
||||
print(f"Only the ui helper runs here, as one command: ui <command> [args]. Refused: {command[:200]}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,52 @@
|
||||
# Test: onboarding and the chat list in the SimpleX Chat desktop app
|
||||
|
||||
You are testing the SimpleX Chat desktop app, which runs on a virtual display 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 read from it, one phrase per line after its centre `x,y` in screen pixels. Read the PNG to see the screen (1440×900, the app window at the top left). OCR can misread or split words; when the text and the picture disagree, trust the picture. "Above" means a smaller y.
|
||||
|
||||
## 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.
|
||||
- After onboarding, the chat list is in the left pane (x 1 to 389), and the right pane shows the empty chat list's cards. An empty chat list shows only its toolbar, with the profile picture at about 27,730 and a pencil at about 363,730: the search field "Search or paste SimpleX link" appears once there is a chat. In the default "Reachable app toolbars" layout, the toolbar and the search field are at the bottom of the left pane.
|
||||
|
||||
## The test
|
||||
|
||||
**A. Onboarding.** Each screen waits for the one before it; wait for a text before clicking it.
|
||||
|
||||
1. The first screen has "Create your profile": click it.
|
||||
2. On "Create profile", click the field "Enter profile name", type `citester`, and click "Create profile".
|
||||
3. The next screen offers a database passphrase: click "Use random passphrase", then "OK" in the alert that follows.
|
||||
4. Click "Continue", then "Accept" on the screen after it.
|
||||
5. Wait up to 60 s for the chat list: the right pane shows "Talk to someone". Take a shot.
|
||||
|
||||
**B. A group, and the chat list with the toolbar at the bottom.**
|
||||
|
||||
1. Click the pencil (about 363,730) to open New chat, then "Create group". Click the name field, about 40 px below the text "Enter group name", type `seeded notes`, and click "Create group". If "Skip inviting members" shows, click it.
|
||||
2. The group opens in the right pane, and stays there. Press Escape until the left pane shows the chat list again, then take a shot.
|
||||
3. Check, noting the evidence (screenshot names, positions):
|
||||
- "seeded notes" is listed in the left pane;
|
||||
- the search field "Search or paste SimpleX link" is at the bottom of the left pane (y over 600), and "Add list" is just above it;
|
||||
- nothing is cut off, overlapping or garbled.
|
||||
|
||||
**Between the layouts:** click the profile picture at the left end of the bottom toolbar (about 27,730) to open the menu, open "Settings", then "Appearance", and turn off the switch of "Reachable app toolbars", at the right end of its row (about x 335). Press Escape until the chat list shows again; its search field should now be at the top.
|
||||
|
||||
**C. The chat list, toolbar at the top.** Check, noting the evidence:
|
||||
|
||||
- "seeded notes" is still listed;
|
||||
- the search field is at the top of the left pane (y under 200), and "Add list" is just below it;
|
||||
- nothing is cut off, overlapping or garbled. The pencil is now a floating button at the bottom right of the left pane, 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 "Create your profile" -- wait "Enter profile name" -- click-text "Enter profile name" -- type citester -- shot profile`
|
||||
- `ui do click-text "Create profile" -- wait "Use random passphrase" 60 -- shot passphrase`
|
||||
- `ui do click 363 730 -- click-text "Create group" 10 -- wait "Enter group name" -- shot new-group`
|
||||
- `ui do click 27 730 -- click-text Settings 10 -- click-text Appearance 10 -- wait "Reachable app toolbars" -- shot appearance`
|
||||
|
||||
`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
+81
@@ -0,0 +1,81 @@
|
||||
#!/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>
|
||||
# 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
|
||||
# log (<transcript>.guard.log) are kept outside it, where Claude can't write. If any of them holds the token, all are
|
||||
# deleted and the run fails.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set}"
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
out=$(cd "$1" && pwd)
|
||||
transcript=$2
|
||||
stderr=$transcript.stderr
|
||||
settings=$(mktemp)
|
||||
export CLAUDE_GUARD_LOG=$transcript.guard.log
|
||||
: > "$CLAUDE_GUARD_LOG"
|
||||
|
||||
{
|
||||
cat "$here/claude-test.md"
|
||||
echo
|
||||
echo '```'
|
||||
"$here/ui" || true
|
||||
echo '```'
|
||||
} > "$out/prompt.md"
|
||||
|
||||
cat > "$settings" << EOF
|
||||
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "$here/claude-guard.py"}]}]}}
|
||||
EOF
|
||||
|
||||
claude --version
|
||||
cd "$out"
|
||||
status=0
|
||||
claude -p "$(cat prompt.md)" \
|
||||
--model "$CLAUDE_MODEL" \
|
||||
--max-turns 60 \
|
||||
--max-budget-usd 3 \
|
||||
--output-format stream-json \
|
||||
--verbose \
|
||||
--tools Bash,Read,Write \
|
||||
--permission-mode dontAsk \
|
||||
--allowedTools "Bash(ui *)" "Read(/$out/**)" "Edit(/$out/**)" \
|
||||
--disallowedTools "mcp__*" \
|
||||
--strict-mcp-config \
|
||||
--disable-slash-commands \
|
||||
--no-session-persistence \
|
||||
--settings "$settings" \
|
||||
< /dev/null > "$transcript" 2> "$stderr" || status=$?
|
||||
|
||||
# A second safeguard to the file tools' limits: what the session wrote is printed and uploaded only when it is known
|
||||
# not to hold the token. grep reads the token from a pipe, so it is in no process's arguments.
|
||||
leak=0
|
||||
printf '%s\n' "$CLAUDE_CODE_OAUTH_TOKEN" | grep -rqF -f - -- "$out" "$transcript" "$stderr" "$CLAUDE_GUARD_LOG" || leak=$?
|
||||
if [ "$leak" -ne 1 ]; then
|
||||
rm -rf -- "$out" "$transcript" "$stderr" "$CLAUDE_GUARD_LOG"
|
||||
echo "error: the token is in the Claude session's output, or checking for it failed (grep exited $leak); the output, transcript, stderr and guard log are deleted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::group::claude stderr"
|
||||
cat "$stderr"
|
||||
echo "::endgroup::"
|
||||
echo "::group::report.md"
|
||||
cat report.md 2> /dev/null || echo "no report.md"
|
||||
echo "::endgroup::"
|
||||
echo "::group::guard hook decisions"
|
||||
cat "$CLAUDE_GUARD_LOG"
|
||||
echo "::endgroup::"
|
||||
result=$(jq -r 'select(.type == "result") | .result // empty' "$transcript" 2> /dev/null | tail -n 20 || true)
|
||||
echo "final message, last lines:"
|
||||
echo "$result"
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "error: claude exited with $status"
|
||||
exit 1
|
||||
fi
|
||||
last=$(grep -v '^[[:space:]]*$' <<< "$result" | tail -n 1 || true)
|
||||
grep -qxE '[*`]*CLAUDE-TEST: PASS[*`]*' <<< "$last"
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Reports what a Claude Code run spent, from the result event at the end of its stream-json transcript: prints it,
|
||||
# adds it to the job summary, and writes it as JSON. Without a result event (a timeout or a crash) it says so
|
||||
# rather than reporting zero.
|
||||
# Usage: scripts/ci/desktop-linux/claude-usage.py <transcript .jsonl> <output .json>
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
FIELDS = ["subtype", "is_error", "num_turns", "duration_ms", "total_cost_usd", "usage", "modelUsage"]
|
||||
|
||||
|
||||
def last_result(path):
|
||||
result = None
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(event, dict) and event.get("type") == "result":
|
||||
result = event
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def summary(result):
|
||||
lines = ["### Claude run usage", ""]
|
||||
if result is None:
|
||||
return lines + ["No result event in the transcript (timeout or crash): usage unknown, not zero."]
|
||||
usage = result.get("usage") or {}
|
||||
lines += [
|
||||
f"- result: {result.get('subtype')}{' (error)' if result.get('is_error') else ''}",
|
||||
f"- turns: {result.get('num_turns')}",
|
||||
f"- duration: {(result.get('duration_ms') or 0) / 1000:.0f} s",
|
||||
f"- tokens: input {usage.get('input_tokens')}, output {usage.get('output_tokens')}, "
|
||||
f"cache read {usage.get('cache_read_input_tokens')}, cache write {usage.get('cache_creation_input_tokens')}",
|
||||
f"- total_cost_usd: {result.get('total_cost_usd')}, estimated at API prices "
|
||||
"(computed by Claude Code; on a subscription it is not a bill)",
|
||||
]
|
||||
for model, model_usage in (result.get("modelUsage") or {}).items():
|
||||
lines.append(f"- {model}: {json.dumps(model_usage)}")
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
transcript, out = sys.argv[1], sys.argv[2]
|
||||
result = last_result(transcript)
|
||||
with open(out, "w") as f:
|
||||
json.dump({k: result.get(k) for k in FIELDS} if result else {"result": None}, f, indent=2)
|
||||
text = "\n".join(summary(result))
|
||||
print(text)
|
||||
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
|
||||
f.write(text + "\n")
|
||||
|
||||
|
||||
main()
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Finds the newest run of this branch in this repository that uploaded an unexpired artifact with this name,
|
||||
# and writes its id as run-id to GITHUB_OUTPUT. Needs GH_TOKEN with actions: read.
|
||||
# Usage: scripts/ci/desktop-linux/earlier-run.sh <artifact name>
|
||||
# Artifacts from forks and other branches are not used: their workflows could upload anything under this name.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
name=$1
|
||||
run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" | jq -r --arg branch "$GITHUB_REF_NAME" '
|
||||
[.artifacts[]
|
||||
| select((.expired | not) and .workflow_run.head_branch == $branch and .workflow_run.head_repository_id == .workflow_run.repository_id)]
|
||||
| sort_by(.created_at) | last | .workflow_run.id // empty')
|
||||
if [ -n "$run_id" ]; then
|
||||
echo "$name: from run $run_id"
|
||||
echo "run-id=$run_id" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "$name: not built yet"
|
||||
fi
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Unpacks the core package from build-core.sh where scripts/desktop/build-lib-linux.sh leaves the libraries,
|
||||
# and links them as the desktop app's resources, as that script does.
|
||||
# Usage: scripts/ci/desktop-linux/install-core.sh <core .tar.gz>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
aarch64) compose_arch=arm64 ;;
|
||||
*) compose_arch=x64 ;;
|
||||
esac
|
||||
libs_dir=apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-$arch
|
||||
|
||||
mkdir -p "$libs_dir"
|
||||
tar -C "$libs_dir" -xzf "$1"
|
||||
ls -la "$libs_dir"
|
||||
|
||||
mkdir -p apps/multiplatform/build/links
|
||||
ln -sfT "../../common/src/commonMain/cpp/desktop/libs/linux-$arch/" "apps/multiplatform/build/links/linux-$compose_arch"
|
||||
ls -la apps/multiplatform/build/links
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# A SimpleX name resolver for the test SMP server's [NAMES] section, as simplexmq's test resolver answers:
|
||||
# GET /v2/resolve/<query> with a NameResponse for every name, registered or not, and GET /health.
|
||||
# Names are read from the registry file on each request: {"<label>": {"simplexContact": [...], "simplexChannel": [...]}},
|
||||
# all under .testing. A v22 router asks by label hash, "[<keccak-256 hex>].testing", so names are matched by hash.
|
||||
# Usage: names-resolver.py <port> <registry .json>
|
||||
|
||||
import json
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import unquote
|
||||
|
||||
MASK = (1 << 64) - 1
|
||||
ROTATIONS = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
|
||||
|
||||
|
||||
def round_constants():
|
||||
r, constants = 1, []
|
||||
for _ in range(24):
|
||||
c = 0
|
||||
for j in range(7):
|
||||
if r & 1:
|
||||
c |= 1 << ((1 << j) - 1)
|
||||
r = ((r << 1) ^ 0x71) & 0xFF if r & 0x80 else r << 1
|
||||
constants.append(c)
|
||||
return constants
|
||||
|
||||
|
||||
ROUND_CONSTANTS = round_constants()
|
||||
|
||||
|
||||
def rotate(x, n):
|
||||
return ((x << n) | (x >> (64 - n))) & MASK if n else x
|
||||
|
||||
|
||||
def keccak_f(a):
|
||||
for rc in ROUND_CONSTANTS:
|
||||
c = [a[x][0] ^ a[x][1] ^ a[x][2] ^ a[x][3] ^ a[x][4] for x in range(5)]
|
||||
d = [c[x - 1] ^ rotate(c[(x + 1) % 5], 1) for x in range(5)]
|
||||
a = [[a[x][y] ^ d[x] for y in range(5)] for x in range(5)]
|
||||
b = [[0] * 5 for _ in range(5)]
|
||||
for x in range(5):
|
||||
for y in range(5):
|
||||
b[y][(2 * x + 3 * y) % 5] = rotate(a[x][y], ROTATIONS[x][y])
|
||||
a = [[b[x][y] ^ (~b[(x + 1) % 5][y] & b[(x + 2) % 5][y]) for y in range(5)] for x in range(5)]
|
||||
a[0][0] ^= rc
|
||||
return a
|
||||
|
||||
|
||||
# Keccak-256 as Ethereum uses it (padding 0x01, not SHA3-256's 0x06), which labelHash in SimplexName.hs is
|
||||
def keccak256(data):
|
||||
rate = 136
|
||||
padded = bytearray(data) + b"\x01" + bytes(-(len(data) + 1) % rate)
|
||||
padded[-1] |= 0x80
|
||||
a = [[0] * 5 for _ in range(5)]
|
||||
for block in range(0, len(padded), rate):
|
||||
for i in range(rate // 8):
|
||||
a[i % 5][i // 5] ^= int.from_bytes(padded[block + 8 * i:block + 8 * i + 8], "little")
|
||||
a = keccak_f(a)
|
||||
return b"".join(a[i % 5][i // 5].to_bytes(8, "little") for i in range(4))
|
||||
|
||||
|
||||
def query_key(label):
|
||||
return "[" + keccak256(label.lower().encode()).hex() + "].testing"
|
||||
|
||||
|
||||
def record(label, links):
|
||||
return {
|
||||
"name": label + ".testing",
|
||||
"nickname": "",
|
||||
"website": "",
|
||||
"location": "",
|
||||
"simplexContact": links.get("simplexContact", []),
|
||||
"simplexChannel": links.get("simplexChannel", []),
|
||||
"eth": None,
|
||||
"btc": None,
|
||||
"xmr": None,
|
||||
"dot": None,
|
||||
"owner": "",
|
||||
"resolver": "",
|
||||
}
|
||||
|
||||
|
||||
def name_response(query, registry_file):
|
||||
with open(registry_file) as f:
|
||||
registry = json.load(f)
|
||||
for label, links in registry.items():
|
||||
if query in (query_key(label), label.lower() + ".testing"):
|
||||
return {"registration": {"type": "registered", "nameRecord": record(label, links)}}
|
||||
return {"registration": {"type": "available", "pricing": {"registrationPrices": {}, "basePrice": 1000, "minLabelLength": 1}}}
|
||||
|
||||
|
||||
class Resolver(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
prefix = "/v2/resolve/"
|
||||
if self.path == "/health":
|
||||
self.reply(200, {})
|
||||
elif self.path.startswith(prefix):
|
||||
query = unquote(self.path[len(prefix):])
|
||||
response = name_response(query, self.server.registry_file)
|
||||
print(f"resolve {query}: {response['registration']['type']}", flush=True)
|
||||
self.reply(200, response)
|
||||
else:
|
||||
self.reply(404, {})
|
||||
|
||||
def reply(self, status, body):
|
||||
data = json.dumps(body).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
def main():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Resolver)
|
||||
server.registry_file = sys.argv[2]
|
||||
print(f"names resolver on 127.0.0.1:{sys.argv[1]}, registry {sys.argv[2]}", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Downloads the released desktop app for this runner's Ubuntu at the branch's latest release tag, and unpacks it.
|
||||
# Usage: scripts/ci/desktop-linux/release-app.sh <dir>
|
||||
# The app is then in <dir>/app/opt/simplex, laid out as release/main/app/simplex of a branch build.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
dir=$1
|
||||
tag=$(git describe --tags --abbrev=0 --match 'v*' --exclude '*-armv7a' --exclude '*-fdroid')
|
||||
. /etc/os-release
|
||||
asset=simplex-desktop-ubuntu-${VERSION_ID/./_}-$(uname -m).deb
|
||||
echo "release $tag, asset $asset"
|
||||
|
||||
mkdir -p "$dir"
|
||||
curl -fsSL -o "$dir/$asset" "https://github.com/simplex-chat/simplex-chat/releases/download/$tag/$asset"
|
||||
dpkg-deb -x "$dir/$asset" "$dir/app"
|
||||
rm "$dir/$asset"
|
||||
ls -la "$dir/app/opt/simplex/bin"
|
||||
du -sh "$dir/app/opt/simplex"
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Prints how many pixels of an image stand out from its background (its most common colour), and their mean colour.
|
||||
# With a colour, fails unless enough pixels stand out and that channel of their mean exceeds the other two by a margin.
|
||||
# Usage: screen-colour.py <image.ppm> [red|green|blue]
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
# A pixel stands out when one of its channels differs from the background's by more than this.
|
||||
DIFFERENCE = 48
|
||||
# A 42 px profile image has about 1200 pixels; fewer than this standing out is no image.
|
||||
MIN_PIXELS = 300
|
||||
DOMINANCE = 50
|
||||
CHANNELS = {"red": 0, "green": 1, "blue": 2}
|
||||
|
||||
|
||||
def read_ppm(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
header = re.match(rb"P6\s+(\d+)\s+(\d+)\s+(\d+)\s", data)
|
||||
if int(header[3]) != 255:
|
||||
sys.exit(f"{path}: expected 8-bit samples, got maximum {int(header[3])}")
|
||||
samples = data[header.end():]
|
||||
return [tuple(samples[i:i + 3]) for i in range(0, len(samples) - 2, 3)]
|
||||
|
||||
|
||||
def main():
|
||||
pixels = read_ppm(sys.argv[1])
|
||||
colour = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
if colour is not None and colour not in CHANNELS:
|
||||
sys.exit(f"unknown colour: {colour}")
|
||||
background = Counter(pixels).most_common(1)[0][0]
|
||||
standing_out = [p for p in pixels if max(abs(a - b) for a, b in zip(p, background)) > DIFFERENCE]
|
||||
n = len(standing_out)
|
||||
mean = [round(sum(p[c] for p in standing_out) / n) if n else 0 for c in range(3)]
|
||||
print(f"{n} px, mean {mean[0]},{mean[1]},{mean[2]}")
|
||||
if colour is not None:
|
||||
c = CHANNELS[colour]
|
||||
if n < MIN_PIXELS or any(mean[c] - mean[o] < DOMINANCE for o in range(3) if o != c):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
main()
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Prints the height in pixels of the ink in a box of a screenshot: from the first to the last row with a pixel darker than
|
||||
# halfway between the box's lightest and darkest. OCR's boxes are padded, so they hardly tell text sizes apart.
|
||||
#
|
||||
# screen-ink.py <gray.pgm> <left> <top> <right> <bottom>
|
||||
#
|
||||
# <gray.pgm> is the screenshot in 8-bit grayscale; the box is in its pixels, edges included.
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
path, left, top, right, bottom = sys.argv[1], *map(int, sys.argv[2:6])
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
header = re.match(rb"P5\s+(\d+)\s+(\d+)\s+(\d+)\s", data)
|
||||
width, height, pixels = int(header[1]), int(header[2]), data[header.end():]
|
||||
columns = range(max(left, 0), min(right, width - 1) + 1)
|
||||
rows = [[pixels[y * width + x] for x in columns] for y in range(max(top, 0), min(bottom, height - 1) + 1)]
|
||||
threshold = (max(map(max, rows)) + min(map(min, rows))) / 2
|
||||
ink = [i for i, row in enumerate(rows) if min(row) < threshold]
|
||||
print(ink[-1] - ink[0] + 1 if ink else 0)
|
||||
|
||||
|
||||
main()
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Reads the words tesseract found on a screenshot and prints the phrases on screen, or where one text is.
|
||||
#
|
||||
# screen-text.py <scale> <ocr dir> lines one phrase per line: centre x,y and its words
|
||||
# screen-text.py <scale> <ocr dir> find <text> "x y left top right bottom": centre and box of the text, from the phrase
|
||||
# that is exactly the text, else the first one containing it; fails if none does
|
||||
#
|
||||
# <ocr dir> has tesseract's TSV of the dark-text and the light-text pass (dark.tsv, light.tsv), in pixels of the
|
||||
# screenshot enlarged by <scale>, and gray.pgm, the screenshot in 8-bit grayscale. Printed coordinates are
|
||||
# screen pixels. Text is compared ignoring case, spaces and punctuation.
|
||||
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
|
||||
# Luminance (0-255) below which a background is dark enough for light text: blue buttons are about 115, disabled grey
|
||||
# ones about 137, light ones (window, fields, cards) over 230.
|
||||
LIGHT_BACKGROUND = 190
|
||||
|
||||
|
||||
# In the app's font a capital I looks like a lowercase l, and OCR reads it as one, so the two compare equal.
|
||||
def key(text):
|
||||
return re.sub(r"[^0-9a-z]", "", text.lower()).replace("l", "i")
|
||||
|
||||
|
||||
def read_words(path, scale):
|
||||
words = []
|
||||
with open(path, newline="") as f:
|
||||
for row in csv.DictReader(f, delimiter="\t", quoting=csv.QUOTE_NONE):
|
||||
text = (row["text"] or "").strip()
|
||||
if row["level"] != "5" or not key(text):
|
||||
continue
|
||||
left, top, width, height = (int(row[k]) / scale for k in ("left", "top", "width", "height"))
|
||||
words.append({"text": text, "key": key(text), "l": left, "t": top, "r": left + width, "b": top + height})
|
||||
return words
|
||||
|
||||
|
||||
def read_gray(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
header = re.match(rb"P5\s+(\d+)\s+(\d+)\s+(\d+)\s", data)
|
||||
if int(header[3]) != 255:
|
||||
sys.exit(f"{path}: expected 8-bit samples, got maximum {int(header[3])}")
|
||||
return int(header[1]), int(header[2]), data[header.end():]
|
||||
|
||||
|
||||
# The median of the rows 2 to 4 pixels above and below the word: its background, free of its own letters.
|
||||
def background(gray, w):
|
||||
width, height, luminance = gray
|
||||
left, right = max(int(w["l"]), 0), min(int(w["r"]), width - 1)
|
||||
rows = [int(w["t"]) - d for d in (2, 3, 4)] + [int(w["b"]) + d for d in (2, 3, 4)]
|
||||
return statistics.median(luminance[y * width + x] for y in rows if 0 <= y < height for x in range(left, right + 1))
|
||||
|
||||
|
||||
def box(words):
|
||||
return min(w["l"] for w in words), min(w["t"] for w in words), max(w["r"] for w in words), max(w["b"] for w in words)
|
||||
|
||||
|
||||
def centre(words):
|
||||
left, top, right, bottom = box(words)
|
||||
return (left + right) / 2, (top + bottom) / 2
|
||||
|
||||
|
||||
# Each pass also reads noise where its polarity doesn't apply (the gaps between dark letters look like light text),
|
||||
# so a word counts only from the pass that matches the background under it.
|
||||
def screen_words(ocr_dir, scale):
|
||||
gray = read_gray(os.path.join(ocr_dir, "gray.pgm"))
|
||||
|
||||
def on_light_background(w):
|
||||
return background(gray, w) >= LIGHT_BACKGROUND
|
||||
|
||||
dark = read_words(os.path.join(ocr_dir, "dark.tsv"), scale)
|
||||
light = read_words(os.path.join(ocr_dir, "light.tsv"), scale)
|
||||
return [w for w in dark if on_light_background(w)] + [w for w in light if not on_light_background(w)]
|
||||
|
||||
|
||||
# A word continues a phrase when it is on the same line as the phrase's last word and at most 1.5 of their height after it;
|
||||
# wider gaps separate UI elements.
|
||||
def follows(a, b):
|
||||
h = min(a["b"] - a["t"], b["b"] - b["t"])
|
||||
return min(a["b"], b["b"]) - max(a["t"], b["t"]) >= 0.5 * h and -0.5 * h <= b["l"] - a["r"] <= 1.5 * h
|
||||
|
||||
|
||||
def phrases(words):
|
||||
result = []
|
||||
for w in sorted(words, key=lambda w: w["l"]):
|
||||
phrase = next((p for p in result if follows(p[-1], w)), None)
|
||||
if phrase:
|
||||
phrase.append(w)
|
||||
else:
|
||||
result.append([w])
|
||||
return sorted(result, key=lambda p: (min(w["t"] for w in p), p[0]["l"]))
|
||||
|
||||
|
||||
# OCR can split or join words ("#ci_group" read as "#ci_ group"), so words are compared by their joined characters.
|
||||
# It can also glue an icon to the first word, or punctuation or the text cursor to the last, so the text may start
|
||||
# inside the first word and end inside the last when at least 4 of its characters are in that word.
|
||||
def span_matches(words, target):
|
||||
keys = [w["key"] for w in words]
|
||||
joined = "".join(keys)
|
||||
start = joined.find(target)
|
||||
while start >= 0:
|
||||
end = start + len(target)
|
||||
first_ok = start == 0 or len(keys[0]) - start >= 4
|
||||
last_ok = end == len(joined) or len(keys[-1]) - (len(joined) - end) >= 4
|
||||
if start < len(keys[0]) and end > len(joined) - len(keys[-1]) and first_ok and last_ok:
|
||||
return True
|
||||
start = joined.find(target, start + 1)
|
||||
return False
|
||||
|
||||
|
||||
def find(all_phrases, text):
|
||||
target = key(text)
|
||||
if not target:
|
||||
return None
|
||||
for phrase in all_phrases:
|
||||
if "".join(w["key"] for w in phrase) == target:
|
||||
return phrase
|
||||
for phrase in all_phrases:
|
||||
for i in range(len(phrase)):
|
||||
for j in range(i + 1, len(phrase) + 1):
|
||||
if span_matches(phrase[i:j], target):
|
||||
return phrase[i:j]
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
scale, ocr_dir, command, args = float(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4:]
|
||||
on_screen = phrases(screen_words(ocr_dir, scale))
|
||||
if command == "lines":
|
||||
for phrase in on_screen:
|
||||
x, y = centre(phrase)
|
||||
print(f"{round(x)},{round(y)} {' '.join(w['text'] for w in phrase)}")
|
||||
elif command == "find":
|
||||
words = find(on_screen, args[0])
|
||||
if not words:
|
||||
sys.exit(1)
|
||||
print(" ".join(str(round(v)) for v in centre(words) + box(words)))
|
||||
else:
|
||||
sys.exit(f"unknown command: {command}")
|
||||
|
||||
|
||||
main()
|
||||
Executable
+374
@@ -0,0 +1,374 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Starts the desktop app on a virtual display and runs the scripted UI cases with the ui helper: onboarding to the
|
||||
# chat list, a local group, then the chat list with the toolbar at the bottom (the default) and at the top.
|
||||
# Usage: scripts/ci/desktop-linux/smoke-test.sh <app dir> <output dir> [start|onboarding|layouts|setup|backend]
|
||||
# With "start" it stops at the first screen, for an agent to go on from; with "onboarding", at the chat list.
|
||||
# "setup" creates the group, makes the test SMP server (backend.sh) the one that resolves SimpleX names, and stops;
|
||||
# "backend" does the same, connects to the name that backend.sh registered, and goes on to both layouts.
|
||||
# Both need NAMES_SERVER, the test SMP server's address.
|
||||
# Each step saves a numbered screenshot and the text read from it to the output dir, prints the text to the log,
|
||||
# and adds a line to steps.log there. 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
|
||||
|
||||
app_dir=$1
|
||||
export UI_OUT=$2
|
||||
cases=${3:-layouts}
|
||||
case "$cases" in
|
||||
start | onboarding | layouts | setup | backend) ;;
|
||||
*)
|
||||
echo "error: unknown case \"$cases\": start, onboarding, layouts (the default), setup or backend"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# backend.sh registered seeded.testing for ci_contact
|
||||
name_label=seeded
|
||||
local_chat="seeded notes"
|
||||
ui=$(dirname "$0")/ui
|
||||
checks=$UI_OUT/checks.txt
|
||||
# switches are at the right of the left pane, where Settings open
|
||||
switch_x=335
|
||||
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::app log, last 300 lines"
|
||||
tail -n 300 "$UI_OUT/app.log" || 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
|
||||
}
|
||||
|
||||
# text compared as ui compares it: letters and digits, lowercase, a lowercase l read as i
|
||||
normalise() {
|
||||
tr -cd '[:alnum:]' <<< "$1" | tr '[:upper:]' '[:lower:]' | tr l i
|
||||
}
|
||||
|
||||
# Prints "x y" of the text in the chat list pane, left of x 390: a phrase equal to it, else, unless the second argument
|
||||
# is "exact", the first containing it. The chats, results and Settings are there; the right pane shows the open chat,
|
||||
# which can have the same names.
|
||||
left_pane_position() {
|
||||
local target line x y found=""
|
||||
target=$(normalise "$1")
|
||||
while IFS= read -r line; do
|
||||
x=${line%%,*}
|
||||
y=${line#*,}
|
||||
y=${y%% *}
|
||||
[ "$x" -lt 390 ] || continue
|
||||
case "$(normalise "${line#* }")" in
|
||||
"$target")
|
||||
echo "$x $y"
|
||||
return 0
|
||||
;;
|
||||
*"$target"*)
|
||||
found=${found:-"$x $y"}
|
||||
;;
|
||||
esac
|
||||
done < <("$ui" text)
|
||||
[ -n "$found" ] && [ "${2:-}" != exact ] || return 1
|
||||
echo "$found"
|
||||
}
|
||||
|
||||
on_screen() {
|
||||
left_pane_position "$1" > /dev/null
|
||||
}
|
||||
|
||||
not_on_screen() {
|
||||
! left_pane_position "$1" > /dev/null
|
||||
}
|
||||
|
||||
y_of() {
|
||||
local pos
|
||||
pos=$(left_pane_position "$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" ]
|
||||
}
|
||||
|
||||
# the value is on the same line as its setting's title
|
||||
in_line_with() {
|
||||
local value title
|
||||
value=$(y_of "$1") || return 1
|
||||
title=$(y_of "$2") || return 1
|
||||
[ $((value - title)) -lt 10 ] && [ $((title - value)) -lt 10 ]
|
||||
}
|
||||
|
||||
# Scrolls the left pane down until the text shows above its bottom toolbar.
|
||||
scroll_to() {
|
||||
local y
|
||||
for _ in 1 2 3 4 5 6 7 8; do
|
||||
if y=$(y_of "$1") && [ "$y" -lt 640 ]; then
|
||||
return 0
|
||||
fi
|
||||
"$ui" scroll 195 400 down
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Escape closes one screen at a time, and clears the search field
|
||||
back_to_chat_list() {
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
on_chat_list && return 0
|
||||
"$ui" key Escape
|
||||
done
|
||||
on_chat_list
|
||||
}
|
||||
|
||||
# New chat has the same search field
|
||||
on_chat_list() {
|
||||
not_on_screen "Settings" && not_on_screen "Chat preferences" && not_on_screen "Network" && not_on_screen "New chat" && on_screen "Search or paste SimpleX link"
|
||||
}
|
||||
|
||||
# Opens Settings from the user menu, behind the profile picture at the left of the bottom toolbar.
|
||||
open_settings() {
|
||||
"$ui" click 27 730
|
||||
"$ui" click-text "Settings" 10
|
||||
}
|
||||
|
||||
# Turns "Reachable app toolbars" off in Settings > Appearance, which moves the toolbar and the search field to the top.
|
||||
use_top_toolbar() {
|
||||
local y
|
||||
"$ui" key Escape
|
||||
open_settings
|
||||
"$ui" click-text "Appearance" 10
|
||||
"$ui" wait "Reachable app toolbars" 10
|
||||
"$ui" shot appearance
|
||||
y=$(y_of "Reachable app toolbars")
|
||||
"$ui" click "$switch_x" "$y"
|
||||
"$ui" shot appearance-toolbar-switched
|
||||
back_to_chat_list || true
|
||||
"$ui" shot top-chat-list
|
||||
y=$(y_of "Search or paste SimpleX link") || fail "no search field after closing Settings"
|
||||
[ "$y" -lt 200 ] || fail "the search field is still at the bottom (y $y) after switching Reachable app toolbars"
|
||||
}
|
||||
|
||||
# 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
|
||||
field=$(y_of "Search or paste SimpleX link") || field=""
|
||||
"$ui" shot "$layout-layout"
|
||||
check "$layout: \"$local_chat\" listed" on_screen "$local_chat"
|
||||
if [ "$layout" = bottom ]; then
|
||||
check "bottom: the search field is at the bottom" [ "${field:-0}" -gt 600 ]
|
||||
check "bottom: \"Add list\" above the search field" above "Add list" "Search or paste SimpleX link"
|
||||
else
|
||||
check "top: the search field is at the top" [ "${field:-900}" -lt 200 ]
|
||||
check "top: \"Add list\" below the search field" above "Search or paste SimpleX link" "Add list"
|
||||
fi
|
||||
}
|
||||
|
||||
# A group of the user's own: without chats the chat list shows no search field.
|
||||
create_local_chat() {
|
||||
local y
|
||||
# the pencil at the right of the bottom toolbar opens New chat
|
||||
"$ui" click 363 730
|
||||
"$ui" click-text "Create group" 10
|
||||
"$ui" wait "Enter group name" 10
|
||||
y=$(y_of "Enter group name")
|
||||
"$ui" click 195 $((y + 40))
|
||||
"$ui" type "$local_chat"
|
||||
"$ui" shot new-group
|
||||
"$ui" click-text "Create group" 10
|
||||
"$ui" click-text "Skip inviting members" 10 || true
|
||||
"$ui" shot group-created
|
||||
"$ui" key Escape
|
||||
back_to_chat_list || fail "no chat list after creating the group"
|
||||
# the group stays open in the right pane: Escape doesn't close a chat on desktop
|
||||
on_screen "$local_chat" || fail "\"$local_chat\" is not in the chat list"
|
||||
"$ui" shot local-chat
|
||||
}
|
||||
|
||||
# Clicks a server role's value until its dropdown shows. The menu is open when an item reads exactly "yes", which no
|
||||
# role's value does ("default (yes)", "default (no)", "no"); it can take longer to show than OCR takes to read the screen.
|
||||
open_role_menu() {
|
||||
for _ in 1 2 3; do
|
||||
"$ui" click-text "$1" 10 || return 1
|
||||
for _ in 1 2 3; do
|
||||
left_pane_position yes exact > /dev/null && return 0
|
||||
sleep 1
|
||||
done
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Settings > Network & servers: the test SMP server added with the names role, and names off for the preset
|
||||
# operators, so that only the test server resolves names.
|
||||
use_names_server() {
|
||||
local x y operator
|
||||
open_settings
|
||||
# OCR reads "Network & servers" as two phrases
|
||||
"$ui" click-text "Network" 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
|
||||
y=$(y_of "Your server address")
|
||||
"$ui" click 195 $((y + 50))
|
||||
"$ui" type "$NAMES_SERVER"
|
||||
y=$(y_of "Use for new connections")
|
||||
"$ui" click "$switch_x" "$y"
|
||||
"$ui" shot new-server
|
||||
"$ui" key Escape
|
||||
# the New server screen shows the address with "127.0.0.1" too, until it closes
|
||||
"$ui" wait "Add server" 10
|
||||
"$ui" wait "Test servers" 10
|
||||
"$ui" click-text "127.0.0.1" 10
|
||||
"$ui" wait "SMP server" 10
|
||||
scroll_to "To resolve names" || fail "no \"To resolve names\" in the test server's settings"
|
||||
# A dropdown opens from its value; a custom server's roles default to yes, yes and no, so the first "default (yes)"
|
||||
# is the receiving one. A directory service can't reply to queues on this server, so it only resolves names.
|
||||
open_role_menu "default (yes)" || fail "no menu for the test server's \"To receive\""
|
||||
# OCR misreads the short "no", the item after "yes"
|
||||
read -r x y < <(left_pane_position yes exact) || fail "the \"To receive\" menu closed"
|
||||
"$ui" click "$x" $((y + 48))
|
||||
open_role_menu "default (no)" || fail "no menu for the test server's \"To resolve names\""
|
||||
read -r x y < <(left_pane_position yes exact) || fail "the \"To resolve names\" menu closed"
|
||||
"$ui" click "$x" "$y"
|
||||
"$ui" shot names-server-roles
|
||||
# the first "default (yes)" and the first "yes" are on the lines after "To receive", so it is no
|
||||
in_line_with "default (yes)" "For private routing" || fail "the test server's \"To receive\" is still its default"
|
||||
in_line_with "yes" "To resolve names" || fail "the test server's \"To resolve names\" is not yes, or \"To receive\" is"
|
||||
"$ui" key Escape
|
||||
"$ui" wait "Add server" 10
|
||||
"$ui" key Escape
|
||||
for operator in "SimpleX Chat" Flux; do
|
||||
"$ui" click-text "$operator" 10
|
||||
# the operator's own toggle: the title "<operator> servers" can wrap
|
||||
"$ui" wait "Use servers" 10
|
||||
scroll_to "To resolve names" || fail "no \"To resolve names\" in the $operator settings"
|
||||
y=$(y_of "To resolve names")
|
||||
"$ui" click "$switch_x" "$y"
|
||||
"$ui" shot "names-off-$(tr -cd 'a-zA-Z' <<< "$operator")"
|
||||
"$ui" key Escape
|
||||
"$ui" wait "Your servers" 10
|
||||
done
|
||||
scroll_to "Save servers" || fail "no \"Save servers\" in Network & servers"
|
||||
"$ui" click-text "Save servers" 10
|
||||
sleep 2
|
||||
"$ui" shot servers-saved
|
||||
back_to_chat_list || fail "no chat list after saving the servers"
|
||||
}
|
||||
|
||||
# A bare name in the search field is offered with the .testing domain; the test SMP server resolves it to ci_contact's
|
||||
# address, and the app shows that profile before connecting.
|
||||
connect_by_name() {
|
||||
"$ui" click-text "Search or paste SimpleX link"
|
||||
"$ui" type "$name_label"
|
||||
"$ui" wait "Connect to $name_label.testing" 15
|
||||
"$ui" shot name-typed
|
||||
"$ui" click-text "Connect to $name_label.testing"
|
||||
check "$name_label.testing resolves to ci_contact" "$ui" wait ci_contact 60
|
||||
check "the app offers to open a new chat with ci_contact" "$ui" wait "Open new chat" 10
|
||||
"$ui" shot name-resolved
|
||||
"$ui" click-text Cancel 10 || "$ui" key Escape
|
||||
back_to_chat_list || fail "no chat list after the name's alert"
|
||||
}
|
||||
|
||||
# private routing to unknown servers only: the preset servers that would proxy name lookups can't reach the test SMP
|
||||
# server on 127.0.0.1; developer tools at the debug level put the app's debug log in app.log
|
||||
"$ui" start "$app_dir" NetworkSMPProxyMode=Unknown DeveloperTools=true LogLevel=DEBUG
|
||||
|
||||
# onboarding to the chat list
|
||||
"$ui" wait "Create your profile" 180
|
||||
"$ui" shot first-screen
|
||||
if [ "$cases" = start ]; then
|
||||
echo "smoke test: the app shows its first screen"
|
||||
exit 0
|
||||
fi
|
||||
"$ui" click-text "Create your profile"
|
||||
|
||||
"$ui" wait "Create profile" 30
|
||||
"$ui" click-text "Enter profile name"
|
||||
"$ui" type citester
|
||||
"$ui" shot profile-name
|
||||
"$ui" click-text "Create profile"
|
||||
|
||||
# a new desktop database gets a random passphrase, which onboarding offers to replace
|
||||
"$ui" wait "Use random passphrase" 60
|
||||
"$ui" shot database-passphrase
|
||||
"$ui" click-text "Use random passphrase"
|
||||
"$ui" wait "OK" 10
|
||||
"$ui" shot random-passphrase-alert
|
||||
"$ui" click-text "OK"
|
||||
|
||||
"$ui" wait "Continue" 60
|
||||
"$ui" shot your-network
|
||||
"$ui" click-text "Continue"
|
||||
|
||||
"$ui" wait "Accept" 30
|
||||
"$ui" shot network-commitments
|
||||
"$ui" click-text "Accept"
|
||||
|
||||
"$ui" wait "Talk to someone" 60
|
||||
sleep 3
|
||||
"$ui" shot chat-list
|
||||
if [ "$cases" = onboarding ]; then
|
||||
echo "smoke test passed: onboarding reached the chat list"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
create_local_chat
|
||||
if [ "$cases" = setup ] || [ "$cases" = backend ]; then
|
||||
: "${NAMES_SERVER:?NAMES_SERVER must be the test SMP server address}"
|
||||
use_names_server
|
||||
if [ "$cases" = setup ]; then
|
||||
echo "smoke test setup done: \"$local_chat\" created, $NAMES_SERVER resolves names"
|
||||
exit 0
|
||||
fi
|
||||
connect_by_name
|
||||
fi
|
||||
|
||||
check_layout bottom
|
||||
use_top_toolbar
|
||||
check_layout top
|
||||
|
||||
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: onboarding reached the chat list, which shows \"$local_chat\" with the toolbar at the bottom and at the top"
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Prints the results in the JUnit XML reports of a Gradle test task, and adds them to the job summary on GitHub.
|
||||
# Usage: scripts/ci/desktop-linux/test-summary.py <test-results dir>
|
||||
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
results_dir = sys.argv[1]
|
||||
suites = [ET.parse(path).getroot() for path in sorted(glob.glob(os.path.join(results_dir, "*.xml")))]
|
||||
if not suites:
|
||||
sys.exit(f"no test results in {results_dir}")
|
||||
|
||||
rows = ["| Suite | Tests | Failed | Skipped | Seconds |", "|---|---|---|---|---|"]
|
||||
failures = []
|
||||
totals = [0, 0, 0]
|
||||
for suite in suites:
|
||||
tests, failed, skipped = (int(suite.get(k, 0)) for k in ("tests", "failures", "skipped"))
|
||||
failed += int(suite.get("errors", 0))
|
||||
totals = [totals[0] + tests, totals[1] + failed, totals[2] + skipped]
|
||||
rows.append(f"| {suite.get('name')} | {tests} | {failed} | {skipped} | {suite.get('time')} |")
|
||||
for case in suite.iter("testcase"):
|
||||
for problem in list(case.findall("failure")) + list(case.findall("error")):
|
||||
failures.append(f"{case.get('classname')}.{case.get('name')}: {problem.get('message')}")
|
||||
|
||||
summary = [f"### desktopTest: {totals[0]} tests, {totals[1]} failed, {totals[2]} skipped", ""] + rows
|
||||
if failures:
|
||||
summary += ["", "Failures:", ""] + [f"- {f}" for f in failures]
|
||||
print("\n".join(summary))
|
||||
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
|
||||
f.write("\n".join(summary) + "\n")
|
||||
Executable
+283
@@ -0,0 +1,283 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Drives the desktop app on a virtual X display, for the scripted cases and for an agent on the runner.
|
||||
# Compose for Desktop has no accessibility tree on Linux, so text is found on screenshots by OCR (tesseract);
|
||||
# coordinates are screen pixels, as in the screenshots. Screenshots, the text read from them and
|
||||
# a log of every command (steps.log) go to UI_OUT; the app's data stays in UI_STATE between starts.
|
||||
#
|
||||
# ui start <app dir> [<setting>=<value>...]
|
||||
# start Xvfb, openbox and <app dir>/bin/simplex, and wait for its window; the app gets
|
||||
# the settings given when UI_STATE has none yet
|
||||
# ui shot <name> save UI_OUT/NN-<name>.png and NN-<name>.txt (OCR's input and output in UI_OUT/ocr), and print the text on screen
|
||||
# ui text print the text on screen, one phrase per line after its centre x,y
|
||||
# ui find <text> print "x y left top right bottom", the text's centre and box; fail if it is not on screen
|
||||
# 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 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
|
||||
# ui avatar <text> [colour] print how many pixels of the profile image left of a list row's name (the text) stand out
|
||||
# from the background, and their mean colour, saving the area as UI_OUT/NN-avatar-<text>.png;
|
||||
# with red, green or blue, fail unless that colour dominates
|
||||
# ui type <text> type into the focused field
|
||||
# ui key <key>... press keys named as in xdotool (Return, Escape, ctrl+a)
|
||||
# ui do <command> [args] -- <command> [args] ...
|
||||
# run the commands in order, stopping at the first that fails
|
||||
# ui stop stop the app, openbox and Xvfb
|
||||
#
|
||||
# Text is compared ignoring case, spaces and punctuation, with a capital I and a lowercase l alike; a phrase 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}"
|
||||
export DISPLAY=${UI_DISPLAY:-:99}
|
||||
state=${UI_STATE:-${RUNNER_TEMP:-/tmp}/ui}
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
mkdir -p "$UI_OUT" "$state/ocr"
|
||||
|
||||
log() {
|
||||
echo "$(date -u +%H:%M:%S) $*" | tee -a "$UI_OUT/steps.log"
|
||||
}
|
||||
|
||||
running() {
|
||||
[ -f "$state/$1.pid" ] && kill -0 "$(cat "$state/$1.pid")" 2> /dev/null
|
||||
}
|
||||
|
||||
stop_process() {
|
||||
local name=$1 secs=$2 pid
|
||||
if ! running "$name"; then
|
||||
rm -f "$state/$name.pid"
|
||||
return
|
||||
fi
|
||||
pid=$(cat "$state/$name.pid")
|
||||
kill "$pid" 2> /dev/null || true
|
||||
for _ in $(seq "$secs"); do
|
||||
kill -0 "$pid" 2> /dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
kill -9 "$pid" 2> /dev/null || true
|
||||
rm -f "$state/$name.pid"
|
||||
}
|
||||
|
||||
start() {
|
||||
local app_dir settings=$state/config/simplex/settings.properties
|
||||
app_dir=$(cd "$1" && pwd)
|
||||
shift
|
||||
if running app; then
|
||||
echo "error: the app is already running"
|
||||
return 1
|
||||
fi
|
||||
stop_process openbox 2
|
||||
stop_process xvfb 2
|
||||
|
||||
setsid Xvfb "$DISPLAY" -screen 0 1440x900x24 -nolisten tcp < /dev/null > "$UI_OUT/xvfb.log" 2>&1 &
|
||||
echo $! > "$state/xvfb.pid"
|
||||
for _ in $(seq 20); do
|
||||
xdotool getdisplaygeometry > /dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
xdotool getdisplaygeometry
|
||||
|
||||
setsid openbox --sm-disable < /dev/null > "$UI_OUT/openbox.log" 2>&1 &
|
||||
echo $! > "$state/openbox.pid"
|
||||
|
||||
mkdir -p "$state/data" "$state/config" "$state/tmp"
|
||||
if [ $# -gt 0 ] && [ ! -f "$settings" ]; then
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
printf '%s\n' "$@" > "$settings"
|
||||
fi
|
||||
XDG_DATA_HOME="$state/data" \
|
||||
XDG_CONFIG_HOME="$state/config" \
|
||||
JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=$state/tmp -Dskiko.renderApi=SOFTWARE" \
|
||||
LD_LIBRARY_PATH="$app_dir/lib/app/resources" \
|
||||
setsid "$app_dir/bin/simplex" < /dev/null >> "$UI_OUT/app.log" 2>&1 &
|
||||
echo $! > "$state/app.pid"
|
||||
|
||||
if ! timeout 120 xdotool search --sync --onlyvisible --name '^SimpleX$' > "$state/window"; then
|
||||
echo "error: no app window after 120 s"
|
||||
return 1
|
||||
fi
|
||||
xdotool getwindowgeometry "$(head -n 1 "$state/window")"
|
||||
}
|
||||
|
||||
# ocr <png> lines | ocr <png> find <text>
|
||||
# Dark text is cut out by a local threshold, light text (labels on coloured buttons) by the difference from a morphological open:
|
||||
# a local threshold of the negative rings the buttons, and tesseract reads nothing inside a ring.
|
||||
ocr() {
|
||||
local png=$1 o=$state/ocr
|
||||
shift
|
||||
convert "$png" -colorspace Gray -depth 8 "$o/gray.pgm"
|
||||
convert "$o/gray.pgm" -resize 200% -lat 60x60-5% "$o/dark.png"
|
||||
convert "$o/gray.pgm" \( +clone -morphology Erode Rectangle:9x1 -morphology Erode Rectangle:1x9 -morphology Dilate Rectangle:9x1 -morphology Dilate Rectangle:1x9 \) -compose Difference -composite -negate -resize 200% "$o/light.png"
|
||||
tesseract "$o/dark.png" "$o/dark" --psm 11 tsv > /dev/null 2>&1
|
||||
tesseract "$o/light.png" "$o/light" --psm 11 tsv > /dev/null 2>&1
|
||||
python3 "$here/screen-text.py" 2 "$o" "$@"
|
||||
}
|
||||
|
||||
screen_ocr() {
|
||||
import -window root "$state/ocr/screen.png"
|
||||
ocr "$state/ocr/screen.png" "$@"
|
||||
}
|
||||
|
||||
# prints "x y left top right bottom" of the text once it is on screen
|
||||
wait_for() {
|
||||
local text=$1 deadline=$((SECONDS + $2))
|
||||
until screen_ocr find "$text"; do
|
||||
[ "$SECONDS" -lt "$deadline" ] || return 1
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# prints UI_OUT/NN-<name>, numbered after the files already saved
|
||||
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"
|
||||
}
|
||||
|
||||
shot() {
|
||||
local f
|
||||
f=$(next_file "$1")
|
||||
import -window root "$f.png"
|
||||
ocr "$f.png" lines > "$f.txt"
|
||||
mkdir -p "$UI_OUT/ocr"
|
||||
for pass in dark light; do
|
||||
cp "$state/ocr/$pass.png" "$UI_OUT/ocr/$(basename "$f")-$pass.png"
|
||||
cp "$state/ocr/$pass.tsv" "$UI_OUT/ocr/$(basename "$f")-$pass.tsv"
|
||||
done
|
||||
echo "::group::text on screen: $(basename "$f")"
|
||||
cat "$f.txt"
|
||||
echo "::endgroup::"
|
||||
log "shot $(basename "$f").png"
|
||||
}
|
||||
|
||||
click() {
|
||||
xdotool mousemove --sync "$1" "$2" click 1
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# X buttons 4 and 5 are the wheel turned up and down
|
||||
scroll() {
|
||||
local button
|
||||
case "$3" in
|
||||
up) button=4 ;;
|
||||
down) button=5 ;;
|
||||
*)
|
||||
echo "error: scroll up or down, not \"$3\""
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
xdotool mousemove --sync "$1" "$2" click --repeat "$4" --delay 100 "$button"
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# A list row's profile image is 42 px square, 8 px left of the row's name, and starts a little below the name's top:
|
||||
# the area allows for OCR's box being a few pixels off, and leaves out the rows above and below.
|
||||
avatar() {
|
||||
local text=$1 colour=${2:-} pos x y l t r b f stats
|
||||
if ! pos=$(screen_ocr find "$text"); then
|
||||
log "avatar \"$text\": not on screen"
|
||||
return 1
|
||||
fi
|
||||
read -r x y l t r b <<< "$pos"
|
||||
f=$(next_file "avatar-$(tr -cd 'a-zA-Z0-9_' <<< "$text")")
|
||||
convert "$state/ocr/screen.png" -crop "52x64+$((l > 56 ? l - 56 : 0))+$((t > 6 ? t - 6 : 0))" +repage "$f.png"
|
||||
convert "$f.png" -depth 8 "$state/ocr/avatar.ppm"
|
||||
if ! stats=$(python3 "$here/screen-colour.py" "$state/ocr/avatar.ppm" ${colour:+"$colour"}); then
|
||||
log "avatar \"$text\": $stats, not $colour"
|
||||
return 1
|
||||
fi
|
||||
log "avatar \"$text\": $stats${colour:+, $colour}"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start)
|
||||
shift
|
||||
log "start $*"
|
||||
start "$@"
|
||||
;;
|
||||
shot) shot "$2" ;;
|
||||
text) screen_ocr lines ;;
|
||||
find)
|
||||
if ! pos=$(screen_ocr find "$2"); then
|
||||
log "find \"$2\": not on screen" >&2
|
||||
exit 1
|
||||
fi
|
||||
log "find \"$2\": $pos" > /dev/null
|
||||
echo "$pos"
|
||||
;;
|
||||
ink)
|
||||
if ! pos=$(screen_ocr find "$2"); then
|
||||
log "ink \"$2\": not on screen" >&2
|
||||
exit 1
|
||||
fi
|
||||
read -r _ _ l t r b <<< "$pos"
|
||||
# 2 px around OCR's box, less than the gap between lines
|
||||
h=$(python3 "$here/screen-ink.py" "$state/ocr/gray.pgm" "$l" $((t - 2)) "$r" $((b + 2)))
|
||||
log "ink \"$2\": $h px" > /dev/null
|
||||
echo "$h"
|
||||
;;
|
||||
wait)
|
||||
secs=${3:-30}
|
||||
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)
|
||||
click "$2" "$3"
|
||||
log "click $2,$3"
|
||||
;;
|
||||
click-text)
|
||||
secs=${3:-20}
|
||||
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"
|
||||
click "$x" "$y"
|
||||
log "click-text \"$2\" at $x,$y"
|
||||
;;
|
||||
scroll)
|
||||
scroll "$2" "$3" "$4" "${5:-3}"
|
||||
log "scroll $4 at $2,$3, ${5:-3} clicks"
|
||||
;;
|
||||
avatar) avatar "$2" "${3:-}" ;;
|
||||
type)
|
||||
xdotool type --delay 50 -- "$2"
|
||||
log "type \"$2\""
|
||||
;;
|
||||
key)
|
||||
shift
|
||||
xdotool key --delay 100 -- "$@"
|
||||
log "key $*"
|
||||
;;
|
||||
do)
|
||||
shift
|
||||
command=()
|
||||
for arg in "$@" --; do
|
||||
if [ "$arg" != -- ]; then
|
||||
command+=("$arg")
|
||||
elif [ ${#command[@]} -gt 0 ]; then
|
||||
"$0" "${command[@]}"
|
||||
command=()
|
||||
fi
|
||||
done
|
||||
;;
|
||||
stop)
|
||||
stop_process app 10
|
||||
stop_process openbox 2
|
||||
stop_process xvfb 2
|
||||
log "stop"
|
||||
;;
|
||||
*)
|
||||
sed -n '3,/^$/s/^# \{0,1\}//p' "$0"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user