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