taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 828d290d7b04971c0db9a129069921ece0865025
parent c3fffe82bdf240a15b4576f3392cc27ca2794466
Author: Florian Dold <dold@taler.net>
Date:   Sun, 13 Sep 2026 20:54:09 +0200

taler-harness: add Android wallet UI scenarios

Drive the nightly wallet with Maestro against local bank, exchange,
and merchant services. Cover withdrawals, payments, fixed and editable
templates, deposits, payment services, and bank accounts.

Install the flows alongside the harness and read the wallet APK from
TALER_HARNESS_WALLET_APK. Keep the wallet-android suite disabled unless
explicitly selected, and settle deposits with exchange time travel.

Diffstat:
Mpackages/taler-harness/Makefile | 4++++
Mpackages/taler-harness/README.md | 6++++++
Apackages/taler-harness/android/README.md | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/build-nightly.sh | 44++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/add-account.yaml | 37+++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/add-exchange.yaml | 23+++++++++++++++++++++++
Apackages/taler-harness/android/flows/already-paid.yaml | 20++++++++++++++++++++
Apackages/taler-harness/android/flows/balance.yaml | 19+++++++++++++++++++
Apackages/taler-harness/android/flows/bank-accounts.yaml | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/deposit-complete.yaml | 21+++++++++++++++++++++
Apackages/taler-harness/android/flows/deposit.yaml | 34++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/exchanges.yaml | 47+++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/open-uri.yaml | 27+++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/pay-confirm.yaml | 24++++++++++++++++++++++++
Apackages/taler-harness/android/flows/pay.yaml | 18++++++++++++++++++
Apackages/taler-harness/android/flows/save-account.yaml | 20++++++++++++++++++++
Apackages/taler-harness/android/flows/setup.yaml | 24++++++++++++++++++++++++
Apackages/taler-harness/android/flows/template-editable.yaml | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/withdraw-complete.yaml | 23+++++++++++++++++++++++
Apackages/taler-harness/android/flows/withdraw.yaml | 26++++++++++++++++++++++++++
Mpackages/taler-harness/package.json | 1+
Apackages/taler-harness/src/harness/android-wallet.ts | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-selection.test.ts | 20++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-android.ts | 266+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
25 files changed, 1052 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/Makefile b/packages/taler-harness/Makefile @@ -34,6 +34,10 @@ install-nodeps: install ./dist/taler-harness-bundled.cjs.map $(DESTDIR)$(NODEDIR)/dist/ install ./bin/taler-harness.mjs $(DESTDIR)$(NODEDIR)/bin/ install ./README.md $(DESTDIR)$(NODEDIR)/ + install -d $(DESTDIR)$(NODEDIR)/android/flows + install -m 644 ./android/README.md $(DESTDIR)$(NODEDIR)/android/ + install ./android/build-nightly.sh $(DESTDIR)$(NODEDIR)/android/ + install -m 644 ./android/flows/*.yaml $(DESTDIR)$(NODEDIR)/android/flows/ ln -sf ../lib/taler-harness/node_modules/taler-harness/bin/taler-harness.mjs $(DESTDIR)$(BINDIR)/taler-harness deps: pnpm install --frozen-lockfile --filter @gnu-taler/taler-harness... diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md @@ -15,6 +15,12 @@ suite. `--experimental` controls experimental tests independently and also does not enable disabled suites. `list-integrationtests` marks tests belonging to a disabled suite, and `run-integrationtests --dry` shows the actual selection. +## Android wallet UI tests + +See [Android wallet UI tests](android/README.md) to build a nightly APK and run +withdrawals, payments, templates, deposits, exchange management, and bank-account +management against local harness services on an Android emulator. + ## Stagefright merchant browser scenarios Run the full merchant scenario using this checkout's Web UI against staging: diff --git a/packages/taler-harness/android/README.md b/packages/taler-harness/android/README.md @@ -0,0 +1,113 @@ +# Android wallet UI tests + +The `wallet-android` harness suite drives the installed wallet with +[Maestro](https://docs.maestro.dev/maestro-cli/how-to-install-maestro-cli). +The harness creates fresh local bank, exchange, and merchant services for each +test. Wallet operations happen through the UI; bank confirmation, order setup, +and settlement use the harness service clients. + +## Requirements + +- The usual local integration-test services and PostgreSQL used by taler-harness. +- Java 21, the Android SDK (`platform-tools`, `build-tools`, an emulator and + system image), and Maestro 2.10.0. +- A disposable Android emulator in English. Set `TALER_ANDROID_DEVICE` to its + explicit adb serial. The suite reinstalls **net.taler.wallet.nightly**, deleting + its wallet data on every test. +- Ports 8081–8083 available on the host and as adb reverse ports on the device. + Run one Android suite at a time per host; the harness runs its tests sequentially. + +## Build the nightly APK + +From the TypeScript checkout, with `JAVA_HOME` pointing to Java 21 and its `bin` +directory in `PATH`: + +```sh +export ANDROID_HOME="$HOME/Android/Sdk" # use your SDK location +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$HOME/.maestro/bin:$PATH" +packages/taler-harness/android/build-nightly.sh ../taler-android /tmp/wallet-nightly.apk +``` + +This runs `:wallet:assembleNightlyRelease`, including R8, aligns the APK, signs +it with a temporary test key, verifies the signature, and prints its SHA-256. +It does not require the nightly publishing key. Rebuild after Android source +changes; the test runner installs the supplied APK without downloading one. + +## Run + +Start a configured emulator, wait for it to boot, and check `adb devices`: + +```sh +"$ANDROID_HOME/emulator/emulator" -avd YOUR_AVD -no-window -no-audio \ + -no-snapshot -gpu swangle +``` + +ANGLE (`swangle`) works on the Linux host used to develop these tests, where +the direct SwiftShader GLES renderer crashed. Other hosts can use their normal +renderer. A phone-sized display and Android 14 are suitable; animations can +remain enabled. The flows use labels and relative selectors rather than screen +coordinates. They enable developer mode through Settings because local HTTP +Taler links require it. + +```sh +pnpm --filter @gnu-taler/taler-harness build +export TALER_ANDROID_DEVICE=emulator-5554 +export TALER_HARNESS_WALLET_APK=/tmp/wallet-nightly.apk +packages/taler-harness/bin/taler-harness.mjs run-integrationtests \ + --suites wallet-android --test-dir /tmp/android-ui-results + +# Select a single scenario: +packages/taler-harness/bin/taler-harness.mjs run-integrationtests \ + wallet-android-payment --suites wallet-android --test-dir /tmp/android-payment +``` + +The `wallet-android` suite is disabled by default. Select it explicitly with +`--suites wallet-android`, including when selecting a single test by name. +Neither a matching test pattern nor `--experimental` enables this suite. +A missing device, APK, or tool is a failure, not a skipped test. `ADB` and +`MAESTRO` can override executable locations. + +After configuring the installation prefix, `make -C packages/taler-harness install` +installs the CLI, all YAML flows, these instructions, and the APK build script. +The Android files go under +`$prefix/lib/taler-harness/node_modules/taler-harness/android/`. The harness finds +the flows relative to its own code, independently of the current directory. +Set `TALER_HARNESS_WALLET_APK` to the signed APK's path and `TALER_ANDROID_DEVICE` +to the emulator serial. The runner installs that exact APK; it does not discover +or download one. Neither source checkout is needed to test an already-built APK: + +```sh +cd /tmp +taler-harness run-integrationtests --suites wallet-android \ + --test-dir /tmp/android-ui-installed-results +``` + +## Coverage and diagnostics + +- Bank withdrawal: accept terms, confirm, authorize at the bank, check the + completed transaction, fees, and balance after restarting the app. +- Payment: pay an order, verify merchant payment status, and reopen the paid order. +- Templates: all four combinations of fixed/editable amounts and summaries, + including rejecting an empty summary and verifying the resulting merchant order. +- Payment services: add, reload, accept/review terms, cancel deletion, delete, + and check persistence after restart. +- Bank accounts: add, rename, restart, cancel deletion, forget, and add again. +- Deposit: select the saved account, enter an amount, submit, settle, and check + both the completed UI transaction and the recipient's bank credit. Settlement + runs the exchange aggregator and transfer worker three hours ahead; the + Android device's clock stays unchanged. + +Each scenario writes harness service logs and `android-logcat-stdout.log` to its +result directory. Each UI phase has a JUnit report and Maestro's command log, +failure screenshot, and accessibility hierarchy under `android/`. Named +screenshots record successful milestones too. The harness preserves failures +and stops its child processes; normal completion also removes the reverse +ports it created. If a process is forcibly killed, remove its port mappings +before rerunning (`adb -s "$TALER_ANDROID_DEVICE" reverse --remove tcp:8081`, +and similarly for 8082 and 8083). + +The YAML files are small UI phases invoked by +`src/integrationtests/test-wallet-android.ts`, rather than a standalone Maestro +directory suite. Keep backend fixtures and assertions in TypeScript and UI +actions in the flows. Waiting should depend on visible state, with an explicit +timeout for asynchronous transaction completion. diff --git a/packages/taler-harness/android/build-nightly.sh b/packages/taler-harness/android/build-nightly.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 TALER_ANDROID_CHECKOUT OUTPUT_APK" >&2 + exit 2 +fi +android_checkout=$(realpath "$1") +output_apk=$(realpath -m "$2") +: "${ANDROID_HOME:?Set ANDROID_HOME to the Android SDK directory}" +apksigner=$(find "$ANDROID_HOME/build-tools" -mindepth 2 -maxdepth 2 -name apksigner -type f | sort -V | tail -1) +[[ -x "$apksigner" ]] || { echo "Install Android SDK build-tools" >&2; exit 1; } +build_tools=$(dirname "$apksigner") +mkdir -p "$(dirname "$output_apk")" + +# The release variant exercises R8 and has the nightly distribution's app ID. +(cd "$android_checkout" && ./gradlew :wallet:assembleNightlyRelease) +unsigned_apk="$android_checkout/wallet/build/outputs/apk/nightly/release/wallet-nightly-release-unsigned.apk" + +# Use a test-only key, independent of the nightly publishing credentials. +signing_dir=$(mktemp -d) +trap 'rm -rf "$signing_dir"' EXIT +keytool -genkeypair -keystore "$signing_dir/test.keystore" -storepass android \ + -keypass android -alias android-ui-test -keyalg RSA -keysize 2048 \ + -validity 2 -dname "CN=Taler Android UI tests" -noprompt +"$build_tools/zipalign" -f -P 16 4 "$unsigned_apk" "$signing_dir/aligned.apk" +"$build_tools/apksigner" sign --ks "$signing_dir/test.keystore" \ + --ks-pass pass:android --key-pass pass:android --out "$output_apk" "$signing_dir/aligned.apk" +"$build_tools/apksigner" verify "$output_apk" +sha256sum "$output_apk" diff --git a/packages/taler-harness/android/flows/add-account.yaml b/packages/taler-harness/android/flows/add-account.yaml @@ -0,0 +1,37 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- tapOn: "Add account" +- tapOn: "Account holder" +- inputText: "Android test customer" +- tapOn: "Account" +- inputText: ${ACCOUNT} +- hideKeyboard +- scrollUntilVisible: + element: "Note" +- tapOn: "Note" +- inputText: "Android payout" +- hideKeyboard +- scrollUntilVisible: + element: + text: "Add account" + enabled: true + below: "Note" +- tapOn: + text: "Add account" + below: "Note" +- assertVisible: "Android payout" +- assertVisible: ${ACCOUNT} diff --git a/packages/taler-harness/android/flows/add-exchange.yaml b/packages/taler-harness/android/flows/add-exchange.yaml @@ -0,0 +1,23 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- tapOn: "Settings" +- tapOn: "Payment services" +- tapOn: "Add payment service" +- tapOn: "Enter address of payment service" +- inputText: ${EXCHANGE_URL} +- tapOn: "OK" +- assertVisible: "Currency: TESTKUDOS" diff --git a/packages/taler-harness/android/flows/already-paid.yaml b/packages/taler-harness/android/flows/already-paid.yaml @@ -0,0 +1,20 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: open-uri.yaml +- assertVisible: ${SUMMARY} +- assertVisible: "This transaction was completed successfully" +- assertNotVisible: "Pay .*" diff --git a/packages/taler-harness/android/flows/balance.yaml b/packages/taler-harness/android/flows/balance.yaml @@ -0,0 +1,19 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- assertVisible: ".*9[.]85" +- takeScreenshot: funded-balance diff --git a/packages/taler-harness/android/flows/bank-accounts.yaml b/packages/taler-harness/android/flows/bank-accounts.yaml @@ -0,0 +1,54 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: add-exchange.yaml +- launchApp +- tapOn: "Settings" +- tapOn: "Bank accounts" +- assertVisible: "No bank accounts saved in the wallet" +- runFlow: add-account.yaml +- tapOn: "Android payout" +- scrollUntilVisible: + element: "Android payout" +- tapOn: "Android payout" +- eraseText +- inputText: "Android renamed" +- hideKeyboard +- scrollUntilVisible: + element: + text: "Edit account" + below: "Note" +- tapOn: + text: "Edit account" + below: "Note" +- assertVisible: "Android renamed" +- launchApp +- tapOn: "Settings" +- tapOn: "Bank accounts" +- assertVisible: "Android renamed" +- assertVisible: ${ACCOUNT} +- tapOn: "Delete" +- assertVisible: "Forget account" +- tapOn: "Cancel" +- assertVisible: "Android renamed" +- tapOn: "Delete" +- assertVisible: "Forget account" +- tapOn: + text: "Delete" + below: "Forget account" +- assertVisible: "No bank accounts saved in the wallet" +- runFlow: add-account.yaml +- takeScreenshot: saved-bank-account diff --git a/packages/taler-harness/android/flows/deposit-complete.yaml b/packages/taler-harness/android/flows/deposit-complete.yaml @@ -0,0 +1,21 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: "This transaction was completed successfully" + timeout: 60000 +- assertVisible: ".*1[.]98" +- takeScreenshot: deposit-settled diff --git a/packages/taler-harness/android/flows/deposit.yaml b/packages/taler-harness/android/flows/deposit.yaml @@ -0,0 +1,34 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- tapOn: + text: "Actions" + traits: square +- tapOn: "Deposit" +- tapOn: "Android payout" +- assertVisible: ${ACCOUNT} +- tapOn: + containsChild: + text: "Amount to deposit" +# The amount input shifts digits into fractional places: 200 is 2.00. +- inputText: "200" +- assertVisible: "1[.]98 TESTKUDOS" +- tapOn: "Make deposit" +- extendedWaitUntil: + visible: "This transaction is finalizing" + timeout: 60000 +- takeScreenshot: deposit-awaiting-settlement diff --git a/packages/taler-harness/android/flows/exchanges.yaml b/packages/taler-harness/android/flows/exchanges.yaml @@ -0,0 +1,47 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: add-exchange.yaml +- tapOn: "Menu: http://localhost:8081" +- tapOn: "Reload information" +- assertVisible: "Currency: TESTKUDOS" +- tapOn: "Menu: http://localhost:8081" +- tapOn: "Accept Terms of Service" +- assertVisible: "Android UI test exchange terms." +- tapOn: "Accept Terms of Service" +- launchApp +- tapOn: "Settings" +- tapOn: "Payment services" +- assertVisible: "Currency: TESTKUDOS" +- tapOn: "Menu: http://localhost:8081" +- tapOn: "Review terms of service" +- assertVisible: "Android UI test exchange terms." +- back +- tapOn: "Menu: http://localhost:8081" +- tapOn: "Delete payment service" +- tapOn: "Cancel" +- assertVisible: "Currency: TESTKUDOS" +- tapOn: "Menu: http://localhost:8081" +- tapOn: "Delete payment service" +- tapOn: + text: "Delete" + below: "Delete payment service" +- assertNotVisible: "http://localhost:8081" +- launchApp +- tapOn: "Settings" +- tapOn: "Payment services" +- assertNotVisible: "http://localhost:8081" +- takeScreenshot: exchange-removed diff --git a/packages/taler-harness/android/flows/open-uri.yaml b/packages/taler-harness/android/flows/open-uri.yaml @@ -0,0 +1,27 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +# Restarting also checks that wallet state survives the app process. +- launchApp +# The first-use tooltip has the same label as the icon. +- tapOn: + text: "Actions" + traits: square +- tapOn: "Enter Taler link" +- tapOn: "Taler link" +- inputText: ${URI} +- hideKeyboard +- tapOn: "Open" diff --git a/packages/taler-harness/android/flows/pay-confirm.yaml b/packages/taler-harness/android/flows/pay-confirm.yaml @@ -0,0 +1,24 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- assertVisible: ${SUMMARY} +- assertVisible: "Total: .${ORDER_AMOUNT}" +- tapOn: "Pay .${PAY_AMOUNT}" +- extendedWaitUntil: + visible: "This transaction was completed successfully" + timeout: 60000 +- assertVisible: ${SUMMARY} +- takeScreenshot: payment-complete diff --git a/packages/taler-harness/android/flows/pay.yaml b/packages/taler-harness/android/flows/pay.yaml @@ -0,0 +1,18 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: open-uri.yaml +- runFlow: pay-confirm.yaml diff --git a/packages/taler-harness/android/flows/save-account.yaml b/packages/taler-harness/android/flows/save-account.yaml @@ -0,0 +1,20 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- tapOn: "Settings" +- tapOn: "Bank accounts" +- runFlow: add-account.yaml diff --git a/packages/taler-harness/android/flows/setup.yaml b/packages/taler-harness/android/flows/setup.yaml @@ -0,0 +1,24 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- assertVisible: "Welcome to Taler Wallet!" +# Local HTTP Taler links are intentionally restricted to developer mode. +- tapOn: "Settings" +- scrollUntilVisible: + element: "Developer mode" +- tapOn: "Developer mode" +- tapOn: "Balances" diff --git a/packages/taler-harness/android/flows/template-editable.yaml b/packages/taler-harness/android/flows/template-editable.yaml @@ -0,0 +1,50 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: open-uri.yaml +- runFlow: + when: + true: ${EDIT_SUMMARY == "true"} + commands: + - tapOn: "Suggested summary" + - eraseText + - hideKeyboard + - assertVisible: + containsChild: + text: "Create order" + enabled: false + - tapOn: "Subject" + - inputText: ${SUMMARY} + - hideKeyboard +- assertVisible: ${SUMMARY} +- runFlow: + when: + true: ${EDIT_AMOUNT == "true"} + commands: + - tapOn: "Reset" + - tapOn: + containsChild: + text: "Amount to send" + # Amount input shifts digits into fractional places, like a cash register. + - inputText: "250" + - hideKeyboard +- runFlow: + when: + true: ${EDIT_AMOUNT == "false"} + commands: + - assertNotVisible: "Amount to send" +- tapOn: "Create order" +- runFlow: pay-confirm.yaml diff --git a/packages/taler-harness/android/flows/withdraw-complete.yaml b/packages/taler-harness/android/flows/withdraw-complete.yaml @@ -0,0 +1,23 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: "This transaction was completed successfully" + timeout: 60000 +- assertVisible: ".*9[.]85" +- assertVisible: ".*10[.]00" +- assertVisible: ".*0[.]15" +- takeScreenshot: withdrawal-complete diff --git a/packages/taler-harness/android/flows/withdraw.yaml b/packages/taler-harness/android/flows/withdraw.yaml @@ -0,0 +1,26 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: setup.yaml +- runFlow: + file: open-uri.yaml + env: + URI: ${WITHDRAW_URI} +- tapOn: "Terms of Service" +- assertVisible: "Android UI test exchange terms." +- tapOn: "Accept Terms of Service" +- tapOn: "Confirm withdrawal" +- assertVisible: "Authorize in bank" diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json @@ -32,6 +32,7 @@ "README-challenger-tester.md", "COPYING", "bin/", + "android/", "dist/node", "src/" ], diff --git a/packages/taler-harness/src/harness/android-wallet.ts b/packages/taler-harness/src/harness/android-wallet.ts @@ -0,0 +1,129 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { GlobalTestState, runCommand } from "./harness.js"; + +/** A disposable, explicitly selected device running the release nightly APK. */ +export class AndroidWallet { + readonly appId = "net.taler.wallet.nightly"; + private readonly serial: string; + private readonly ports: number[] = []; + private step = 0; + + constructor(private readonly t: GlobalTestState) { + const serial = process.env.TALER_ANDROID_DEVICE; + if (!serial) + throw Error( + "Set TALER_ANDROID_DEVICE to a disposable emulator's adb serial", + ); + this.serial = serial; + } + + private adb(...args: string[]): Promise<string> { + return runCommand(this.t, "adb", process.env.ADB ?? "adb", [ + "-s", + this.serial, + ...args, + ]); + } + + async prepare(): Promise<void> { + const apk = process.env.TALER_HARNESS_WALLET_APK; + if (!apk || !fs.existsSync(apk) || !fs.statSync(apk).isFile()) { + throw Error( + "Set TALER_HARNESS_WALLET_APK to the signed, locally built nightly release APK", + ); + } + if ( + (await this.adb("shell", "getprop", "sys.boot_completed")).trim() !== "1" + ) { + throw Error(`Android device ${this.serial} has not finished booting`); + } + // Reset the nightly wallet and allow a fresh local test signing key. + if ((await this.adb("shell", "pm", "path", this.appId)).trim()) { + await this.adb("uninstall", this.appId); + } + await this.adb("install", apk); + for (const port of [8081, 8082, 8083]) { + await this.adb("reverse", "--no-rebind", `tcp:${port}`, `tcp:${port}`); + this.ports.push(port); + } + this.t.spawnService( + process.env.ADB ?? "adb", + ["-s", this.serial, "logcat", "-T", "1"], + "android-logcat", + ); + } + + async flow(name: string, env: Record<string, string> = {}): Promise<void> { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + // Bundles live in dist/; unbundled modules live in src/harness/ or lib/harness/. + const roots = [ + path.resolve(moduleDir, "../android/flows"), + path.resolve(moduleDir, "../../android/flows"), + ]; + const root = roots.find((candidate) => + fs.existsSync(path.join(candidate, `${name}.yaml`)), + ); + if (!root) + throw Error( + `Android flow ${name} not found; build/install taler-harness with its android directory`, + ); + const step = `${++this.step}-${name}`; + this.t.logStep(`Android: ${step}`); + const artifacts = path.resolve(this.t.testDir, "android", step); + fs.mkdirSync(artifacts, { recursive: true }); + const proc = this.t.spawnService( + process.env.MAESTRO ?? "maestro", + [ + "--device", + this.serial, + "test", + "--no-ansi", + "--format", + "JUNIT", + "--output", + path.join(artifacts, "junit.xml"), + "--debug-output", + artifacts, + ...Object.entries({ APP_ID: this.appId, ...env }).flatMap( + ([key, value]) => ["-e", `${key}=${value}`], + ), + path.join(root, `${name}.yaml`), + ], + `android-${step}`, + { + ...process.env, + MAESTRO_CLI_NO_ANALYTICS: "1", + MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true", + }, + ); + const result = await proc.wait(); + if (result.code !== 0) + throw Error( + `Android flow ${step} failed (${result.code ?? result.signal}); see ${artifacts} and android-${step}-stdout.log`, + ); + } + + async close(): Promise<void> { + for (const port of this.ports) { + await this.adb("reverse", "--remove", `tcp:${port}`); + } + } +} diff --git a/packages/taler-harness/src/integrationtests/test-selection.test.ts b/packages/taler-harness/src/integrationtests/test-selection.test.ts @@ -17,6 +17,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { selectTests, TestSelectionSpec } from "./test-selection.js"; +import { getTestInfo } from "./testrunner.js"; const tests = [ { name: "unclassified" }, @@ -108,3 +109,22 @@ for (const { name, spec, expected } of cases) { ); }); } + +test("all Android tests are registered in the disabled suite", () => { + const all = getTestInfo(); + const android = all.filter((t) => t.name.startsWith("wallet-android-")); + assert.ok(android.length > 0); + assert.ok(android.every((t) => t.disabledByDefault && !t.experimental)); + assert.ok( + selectTests(all, { includeExperimental: true }).every( + (t) => !t.name.startsWith("wallet-android-"), + ), + ); + assert.deepEqual( + selectTests(all, { + suiteSpec: "wallet-android", + includeExperimental: false, + }), + android, + ); +}); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-android.ts b/packages/taler-harness/src/integrationtests/test-wallet-android.ts @@ -0,0 +1,266 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + AmountString, + Amounts, + CreditDebitIndicator, + Duration, + succeedOrThrow, + TalerMerchantInstanceHttpClient, + TemplateType, +} from "@gnu-taler/taler-util"; +import { AndroidWallet } from "../harness/android-wallet.js"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +type Environment = Awaited< + ReturnType<typeof createSimpleTestkudosEnvironmentV3> +>; +type Scenario = (wallet: AndroidWallet, env: Environment) => Promise<void>; + +/** Every test owns fresh services and wallet data; all wallet actions use the UI. */ +async function withAndroidWallet( + t: GlobalTestState, + funded: boolean, + scenario: Scenario, +) { + const wallet = new AndroidWallet(t); + try { + await wallet.prepare(); + const terms = path.resolve(t.testDir, "terms"); + fs.mkdirSync(path.join(terms, "en"), { recursive: true }); + fs.writeFileSync( + path.join(terms, "en", "v1.txt"), + "Android UI test exchange terms.\n", + ); + const env = await createSimpleTestkudosEnvironmentV3(t, undefined, { + additionalExchangeConfig(exchange) { + exchange.changeConfig((config) => { + config.setString("exchange", "terms_etag", "v1"); + config.setString("exchange", "terms_dir", terms); + }); + }, + }); + if (funded) { + const { bankClient } = env; + const user = await bankClient.createRandomBankUser(); + bankClient.setAuth(user); + const withdrawal = await bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:10", + ); + await wallet.flow("withdraw", { + WITHDRAW_URI: withdrawal.taler_withdraw_uri, + }); + succeedOrThrow( + await bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: withdrawal.withdrawal_id, + }), + ); + await wallet.flow("withdraw-complete"); + } else { + await wallet.flow("setup"); + } + await scenario(wallet, env); + } finally { + await wallet.close(); + } +} + +export async function runWalletAndroidWithdrawalTest(t: GlobalTestState) { + await withAndroidWallet(t, true, async (wallet) => { + await wallet.flow("balance"); + }); +} + +export async function runWalletAndroidPaymentTest(t: GlobalTestState) { + await withAndroidWallet(t, true, async (wallet, env) => { + const client = new TalerMerchantInstanceHttpClient( + env.merchant.makeInstanceBaseUrl(), + ); + const order = succeedOrThrow( + await client.createOrder(env.merchantAdminAccessToken, { + order: { summary: "Android coffee", amount: "TESTKUDOS:1" }, + }), + ); + const unpaid = succeedOrThrow( + await client.getOrderDetails( + env.merchantAdminAccessToken, + order.order_id, + ), + ); + t.assertTrue(unpaid.order_status === "unpaid"); + await wallet.flow("pay", { + URI: unpaid.taler_pay_uri, + SUMMARY: "Android coffee", + ORDER_AMOUNT: "1[.]00", + PAY_AMOUNT: "1[.]00", + }); + const paid = succeedOrThrow( + await client.getOrderDetails( + env.merchantAdminAccessToken, + order.order_id, + ), + ); + t.assertDeepEqual(paid.order_status, "paid"); + // Reopening a paid order should display its receipt without a pay button. + await wallet.flow("already-paid", { + URI: unpaid.taler_pay_uri, + SUMMARY: "Android coffee", + }); + }); +} + +async function templateTest( + t: GlobalTestState, + editableAmount: boolean, + editableSummary: boolean, +) { + await withAndroidWallet(t, true, async (wallet, env) => { + const client = new TalerMerchantInstanceHttpClient( + env.merchant.makeInstanceBaseUrl(), + ); + const summary = editableSummary + ? "Android chosen summary" + : "Android fixed summary"; + const amount: AmountString = editableAmount + ? "TESTKUDOS:2.5" + : "TESTKUDOS:1.5"; + succeedOrThrow( + await client.addTemplate(env.merchantAdminAccessToken, { + template_id: "android-template", + template_description: "Android payment template", + template_contract: { + template_type: TemplateType.FIXED_ORDER, + ...(editableAmount ? { currency: "TESTKUDOS" } : { amount }), + ...(editableSummary ? {} : { summary }), + pay_duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + }, + editable_defaults: { + ...(editableAmount ? { amount: "TESTKUDOS:1" as AmountString } : {}), + ...(editableSummary ? { summary: "Suggested summary" } : {}), + }, + }), + ); + await wallet.flow( + editableAmount || editableSummary ? "template-editable" : "pay", + { + URI: `taler+http://pay-template/localhost:${env.merchant.port}/android-template`, + SUMMARY: summary, + ORDER_AMOUNT: editableAmount ? "2[.]50" : "1[.]50", + PAY_AMOUNT: editableAmount ? "2[.]59" : "1[.]50", + EDIT_AMOUNT: String(editableAmount), + EDIT_SUMMARY: String(editableSummary), + }, + ); + const history = succeedOrThrow( + await client.listOrders(env.merchantAdminAccessToken), + ); + t.assertDeepEqual(history.orders.length, 1); + t.assertDeepEqual(history.orders[0].summary, summary); + t.assertAmountEquals(history.orders[0].amount, amount); + const paid = succeedOrThrow( + await client.getOrderDetails( + env.merchantAdminAccessToken, + history.orders[0].order_id, + ), + ); + t.assertDeepEqual(paid.order_status, "paid"); + }); +} + +export async function runWalletAndroidFixedTemplateTest(t: GlobalTestState) { + await templateTest(t, false, false); +} + +export async function runWalletAndroidEditableTemplateTest(t: GlobalTestState) { + await templateTest(t, true, true); +} + +export async function runWalletAndroidFixedAmountTemplateTest( + t: GlobalTestState, +) { + await templateTest(t, false, true); +} + +export async function runWalletAndroidFixedSummaryTemplateTest( + t: GlobalTestState, +) { + await templateTest(t, true, false); +} + +export async function runWalletAndroidExchangesTest(t: GlobalTestState) { + await withAndroidWallet(t, false, async (wallet, env) => { + await wallet.flow("exchanges", { EXCHANGE_URL: env.exchange.baseUrl }); + }); +} + +export async function runWalletAndroidBankAccountsTest(t: GlobalTestState) { + await withAndroidWallet(t, false, async (wallet, env) => { + const user = await env.bankClient.createRandomBankUser(); + await wallet.flow("bank-accounts", { + ACCOUNT: user.username, + EXCHANGE_URL: env.exchange.baseUrl, + }); + }); +} + +export async function runWalletAndroidDepositTest(t: GlobalTestState) { + await withAndroidWallet(t, true, async (wallet, env) => { + const user = await env.bankClient.createRandomBankUser(); + env.bankClient.setAuth(user); + const before = await env.bankClient.getAccountBalance(user.username); + await wallet.flow("save-account", { ACCOUNT: user.username }); + await wallet.flow("deposit", { ACCOUNT: user.username }); + // Settle without waiting for the wire deadline. Advance only the exchange's + // settlement workers, leaving the Android device's clock and TLS intact. + const time = { timetravelMicroseconds: 3 * 60 * 60 * 1_000_000 }; + await env.exchange.runAggregatorOnceWithTimetravel(time); + await env.exchange.runTransferOnceWithTimetravel(time); + await wallet.flow("deposit-complete"); + const after = await env.bankClient.getAccountBalance(user.username); + t.assertDeepEqual( + after.balance.credit_debit_indicator, + CreditDebitIndicator.Credit, + ); + t.assertAmountEquals( + after.balance.amount, + Amounts.add(before.balance.amount, "TESTKUDOS:1.98").amount, + ); + }); +} + +export const androidWalletTests = [ + runWalletAndroidWithdrawalTest, + runWalletAndroidPaymentTest, + runWalletAndroidFixedTemplateTest, + runWalletAndroidEditableTemplateTest, + runWalletAndroidFixedAmountTemplateTest, + runWalletAndroidFixedSummaryTemplateTest, + runWalletAndroidExchangesTest, + runWalletAndroidBankAccountsTest, + runWalletAndroidDepositTest, +].map((test) => + Object.assign(test, { + suites: ["wallet-android"], + timeoutMs: 300_000, + }), +); diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -257,6 +257,7 @@ import { runWalletWebUiPwaDepositKycAuthSwissTest } from "./test-wallet-web-ui-p import { runWalletWebUiPwaCashAcceptorTest } from "./test-wallet-web-ui-pwa-cash-acceptor.js"; import { runWalletWebUiPwaStartupTest } from "./test-wallet-web-ui-pwa-startup.js"; import { runWalletWebUiPwaLifecycleTest } from "./test-wallet-web-ui-pwa-lifecycle.js"; +import { androidWalletTests } from "./test-wallet-android.js"; import { runWalletWebUiPwaStorybookTest } from "./test-wallet-web-ui-pwa-storybook.js"; import { runWalletWebUiDemoTest } from "./test-wallet-web-ui-demo.js"; import { @@ -559,6 +560,7 @@ const allTests: TestMainFunction[] = [ runWalletWebUiPwaCashAcceptorTest, runWalletWebUiPwaStartupTest, runWalletWebUiPwaLifecycleTest, + ...androidWalletTests, runWalletWebUiPwaStorybookTest, runWalletWebUiDemoTest, runWalletWebUiPwaSqliteTest,