diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml new file mode 100644 index 0000000000..f826ccbbfc --- /dev/null +++ b/.github/workflows/desktop-linux.yml @@ -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 diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md new file mode 100644 index 0000000000..a9080e1c87 --- /dev/null +++ b/scripts/ci/desktop-linux/README.md @@ -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 [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 ` / `stop `: 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 `/directory-link` and the SMP server's to `/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). diff --git a/scripts/ci/desktop-linux/backend.sh b/scripts/ci/desktop-linux/backend.sh new file mode 100755 index 0000000000..836f09d275 --- /dev/null +++ b/scripts/ci/desktop-linux/backend.sh @@ -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 writes the directory's short address to /directory-link +# and the names server's address to /names-server +# scripts/ci/desktop-linux/backend.sh stop 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 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 | stop " + exit 1 + ;; +esac diff --git a/scripts/ci/desktop-linux/build-backend.sh b/scripts/ci/desktop-linux/build-backend.sh new file mode 100755 index 0000000000..adda90dcfe --- /dev/null +++ b/scripts/ci/desktop-linux/build-backend.sh @@ -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 +# 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" diff --git a/scripts/ci/desktop-linux/build-core.sh b/scripts/ci/desktop-linux/build-core.sh new file mode 100755 index 0000000000..13ab52eb30 --- /dev/null +++ b/scripts/ci/desktop-linux/build-core.sh @@ -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 + +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" diff --git a/scripts/ci/desktop-linux/claude-guard.py b/scripts/ci/desktop-linux/claude-guard.py new file mode 100755 index 0000000000..7b004584b2 --- /dev/null +++ b/scripts/ci/desktop-linux/claude-guard.py @@ -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 [args]. Refused: {command[:200]}", file=sys.stderr) + sys.exit(2) diff --git a/scripts/ci/desktop-linux/claude-test.md b/scripts/ci/desktop-linux/claude-test.md new file mode 100644 index 0000000000..94982805f1 --- /dev/null +++ b/scripts/ci/desktop-linux/claude-test.md @@ -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 [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 ` saves `screens/NN-.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 diff --git a/scripts/ci/desktop-linux/claude-test.sh b/scripts/ci/desktop-linux/claude-test.sh new file mode 100755 index 0000000000..d254226b75 --- /dev/null +++ b/scripts/ci/desktop-linux/claude-test.sh @@ -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 +# Expects the app started (smoke-test.sh ... start) with UI_OUT under , claude and ui on PATH, +# CLAUDE_MODEL and CLAUDE_CODE_OAUTH_TOKEN. Claude works in : the screenshots, its report.md and the +# prompt it was given end up there. The transcript, Claude Code's stderr (.stderr) and the guard hook's +# log (.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" diff --git a/scripts/ci/desktop-linux/claude-usage.py b/scripts/ci/desktop-linux/claude-usage.py new file mode 100755 index 0000000000..1cf9f8678d --- /dev/null +++ b/scripts/ci/desktop-linux/claude-usage.py @@ -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 + +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() diff --git a/scripts/ci/desktop-linux/earlier-run.sh b/scripts/ci/desktop-linux/earlier-run.sh new file mode 100755 index 0000000000..d83e516249 --- /dev/null +++ b/scripts/ci/desktop-linux/earlier-run.sh @@ -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 +# 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 diff --git a/scripts/ci/desktop-linux/install-core.sh b/scripts/ci/desktop-linux/install-core.sh new file mode 100755 index 0000000000..c521fbd588 --- /dev/null +++ b/scripts/ci/desktop-linux/install-core.sh @@ -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 + +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 diff --git a/scripts/ci/desktop-linux/names-resolver.py b/scripts/ci/desktop-linux/names-resolver.py new file mode 100644 index 0000000000..34ff8cfd8b --- /dev/null +++ b/scripts/ci/desktop-linux/names-resolver.py @@ -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/ with a NameResponse for every name, registered or not, and GET /health. +# Names are read from the registry file on each request: {"