diff --git a/.github/workflows/migration-wallet-setup.yml b/.github/workflows/migration-wallet-setup.yml index 2f9cae6d..153341d0 100644 --- a/.github/workflows/migration-wallet-setup.yml +++ b/.github/workflows/migration-wallet-setup.yml @@ -4,25 +4,25 @@ on: workflow_call: inputs: e2e_branch: - description: "Candidate branch of synonymdev/bitkit-e2e-tests. Re-validated immediately before checkout. Callers may pass an already-resolved ref; a missing default companion falls back to main." + description: 'Candidate branch of synonymdev/bitkit-e2e-tests. Re-validated immediately before checkout. Callers may pass an already-resolved ref; a missing default companion falls back to main.' required: true type: string e2e_branch_input: - description: "Resolution mode (main | default-feature-branch | custom branch name). Empty inherits the caller workflow_dispatch input e2e_branch, then default-feature-branch. A custom name hard-fails when the branch is missing." + description: 'Resolution mode (main | default-feature-branch | custom branch name). Empty inherits the caller workflow_dispatch input e2e_branch, then default-feature-branch. A custom name hard-fails when the branch is missing.' required: false type: string - default: "" + default: '' rn_version: - description: "Legacy RN app version to use for setup (e.g., v1.1.6)" + description: 'Legacy RN app version to use for setup (e.g., v1.1.6)' required: false type: string - default: "v1.1.6" + default: 'v1.1.6' setup_type: - description: "Wallet setup type (standard | passphrase | sweep)" + description: 'Wallet setup type (standard | passphrase | sweep)' required: true type: string scenario_name: - description: "Migration scenario name for artifact naming" + description: 'Migration scenario name for artifact naming' required: true type: string @@ -59,12 +59,13 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Download RN app for migration + working-directory: bitkit-e2e-tests + env: + RN_VERSION: ${{ inputs.rn_version }} run: | - mkdir -p bitkit-e2e-tests/aut - curl -L -o bitkit-e2e-tests/aut/bitkit_rn_regtest.apk \ - https://github.com/synonymdev/bitkit-e2e-tests/releases/download/migration-rn-regtest/bitkit_rn_regtest_${{ inputs.rn_version }}.apk - # Symlink to bitkit_e2e.apk so wdio.conf.ts can initialize the Appium session - cd bitkit-e2e-tests/aut && ln -sf bitkit_rn_regtest.apk bitkit_e2e.apk + python3 scripts/download-migration-app.py android rn "$RN_VERSION" + # WDIO initializes its session using the RN source during preparation. + ln -sf bitkit_rn_regtest.apk aut/bitkit_e2e.apk - name: Setup Node.js uses: actions/setup-node@v4 @@ -83,32 +84,7 @@ jobs: working-directory: bitkit-e2e-tests run: npm ci - - name: Clear previous migration env files - working-directory: bitkit-e2e-tests - run: | - rm -f artifacts/migration_setup_standard.env - rm -f artifacts/migration_setup_passphrase.env - rm -f artifacts/migration_setup_sweep.env - - - name: Prepare migration wallet 1 - continue-on-error: true - id: prepare1 - uses: reactivecircus/android-emulator-runner@v2 - with: - profile: pixel_6 - api-level: 33 - arch: x86_64 - avd-name: Pixel_6 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-front none - script: cd bitkit-e2e-tests && ./ci_run_android.sh --mochaOpts.grep "@migration_setup_${{ inputs.setup_type }}" - env: - BACKEND: regtest - - - name: Prepare migration wallet 2 - continue-on-error: true - id: prepare2 - if: steps.prepare1.outcome != 'success' + - name: Prepare independent wallets for all consumer attempts uses: reactivecircus/android-emulator-runner@v2 with: profile: pixel_6 @@ -117,32 +93,27 @@ jobs: avd-name: Pixel_6 force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-front none - script: cd bitkit-e2e-tests && ./ci_run_android.sh --mochaOpts.grep "@migration_setup_${{ inputs.setup_type }}" + script: cd bitkit-e2e-tests && bash scripts/prepare-migration-wallets.sh "$SETUP_TYPE" env: BACKEND: regtest + SETUP_TYPE: ${{ inputs.setup_type }} - - name: Prepare migration wallet 3 - id: prepare3 - if: steps.prepare1.outcome != 'success' && steps.prepare2.outcome != 'success' - uses: reactivecircus/android-emulator-runner@v2 + - name: Upload preparation diagnostics + if: always() + uses: actions/upload-artifact@v4 with: - profile: pixel_6 - api-level: 33 - arch: x86_64 - avd-name: Pixel_6 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-front none - script: cd bitkit-e2e-tests && ./ci_run_android.sh --mochaOpts.grep "@migration_setup_${{ inputs.setup_type }}" - env: - BACKEND: regtest - - - name: Verify migration env file - run: | - set -euo pipefail - test -f "bitkit-e2e-tests/artifacts/migration_setup_${{ inputs.setup_type }}.env" + name: migration-setup-logs_${{ inputs.scenario_name }}_${{ github.run_attempt }} + path: | + bitkit-e2e-tests/artifacts/attempt-*/ + bitkit-e2e-tests/artifacts/migration-source.json - name: Upload migration env file uses: actions/upload-artifact@v4 with: - name: migration-env_${{ inputs.rn_version }}_${{ inputs.scenario_name }} - path: bitkit-e2e-tests/artifacts/migration_setup_${{ inputs.setup_type }}.env + name: migration-env_${{ inputs.rn_version }}_${{ inputs.scenario_name }}_${{ github.run_attempt }} + path: | + bitkit-e2e-tests/artifacts/wallet-attempt-1/ + bitkit-e2e-tests/artifacts/wallet-attempt-2/ + bitkit-e2e-tests/artifacts/wallet-attempt-3/ + if-no-files-found: error + retention-days: 1 diff --git a/config/migration-baselines.json b/config/migration-baselines.json new file mode 100644 index 00000000..fe352e42 --- /dev/null +++ b/config/migration-baselines.json @@ -0,0 +1,4 @@ +{ + "android": "2.5.0", + "ios": "2.5.0" +} diff --git a/config/migration-checksums.json b/config/migration-checksums.json new file mode 100644 index 00000000..b4378188 --- /dev/null +++ b/config/migration-checksums.json @@ -0,0 +1,6 @@ +{ + "2.5.0": { + "bitkit_e2e.apk": "03834e6ff2fe7026438a2fb2a3eb59289062eea504964c3a7d152757f2e7f462", + "Bitkit.app.zip": "e7e26bcf77bf4dea089f321ffd1a4e0932672e03c20ea9b825de6f93c06088ae" + } +} diff --git a/docs/migration-tests.md b/docs/migration-tests.md new file mode 100644 index 00000000..bc8ce2a8 --- /dev/null +++ b/docs/migration-tests.md @@ -0,0 +1,81 @@ +# Migration tests + +The routine migration suite has four cases per platform. Every case starts with savings and spending: + +| Source | Restore into a clean installation | Install current app on top | +| ----------------------- | --------------------------------- | --------------------------- | +| React Native 1.1.6 | `@migration_rn_restore` | `@migration_rn_upgrade` | +| Previous native release | `@migration_native_restore` | `@migration_native_upgrade` | + +All apps and tests use remote **regtest**. The native baseline is pinned per platform in `config/migration-baselines.json` (currently 2.5.0), not selected from GitHub's latest release. The app workflows retain nightly, manual, and release-PR triggers. Manual `previous_native_version` overrides the pinned tag. Routine runs do not prepare or run passphrase/legacy-address wallets. + +## Run locally + +Use a dedicated emulator or simulator, with source and target apps built for regtest. Keep the current target in `aut/bitkit_e2e.apk` or `aut/Bitkit.app`. + +```sh +python3 scripts/migration-matrix.py android +python3 scripts/download-migration-app.py android native 2.5.0 +BACKEND=regtest PREVIOUS_NATIVE_VERSION=2.5.0 \ + PREVIOUS_NATIVE_APP_PATH="$PWD/aut/previous-native/bitkit_e2e.apk" \ + ANDROID_UDID="$MIGRATION_ANDROID_SERIAL" ANDROID_SERIAL="$MIGRATION_ANDROID_SERIAL" \ + APPIUM_PORT=4725 ANDROID_SYSTEM_PORT=8205 \ + npm run e2e:android -- --mochaOpts.grep '@migration_native_restore' +``` + +Set `MIGRATION_ANDROID_SERIAL` to the newly created emulator's serial. WDIO starts Appium on the selected `APPIUM_PORT`; choose free ports distinct from other active test runs. Both Android variables are required: the WDIO session uses `ANDROID_UDID`, while shell-based helpers use `ANDROID_SERIAL`. + +For iOS, download with `ios native 2.5.0`, then run directly against the newly created simulator: + +```sh +BACKEND=regtest PREVIOUS_NATIVE_VERSION=2.5.0 \ + PREVIOUS_NATIVE_APP_PATH="$PWD/aut/previous-native/Bitkit.app" \ + SIMULATOR_UDID="$MIGRATION_SIMULATOR_UDID" SIMULATOR_NAME="$MIGRATION_SIMULATOR_NAME" \ + SIMULATOR_OS_VERSION="$MIGRATION_IOS_VERSION" APPIUM_PORT=4727 IOS_WDA_LOCAL_PORT=8105 \ + npm run e2e:ios -- --mochaOpts.grep '@migration_native_restore' +``` + +Set the three `MIGRATION_*` simulator variables to that dedicated device's values. Prefer direct npm commands for isolated local runs: `ci_run_ios.sh` resolves a device by name, and `ci_run_android.sh` calls adb, so a WDIO UDID alone does not isolate those wrappers. Use `@migration_native_upgrade` for install-on-top. Native iOS creates wallets directly on iOS and never depends on Android preparation. + +Download RN sources with `python3 scripts/download-migration-app.py android rn v1.1.6` (or `ios rn v1.1.6`). Android RN cases prepare their own wallets. RN iOS still needs wallets created on Android because RN iOS cannot reliably be driven through the creation flow with Appium. The reusable `migration-wallet-setup.yml` handles that in CI. The RN iOS restore case skips downloading the unused RN iOS app; its Android source provenance remains in the matching `migration-setup-logs` artifact. It verifies balances and activity but does not assert Android tag metadata because cross-platform mnemonic restore does not currently recover those tags. RN iOS install-on-top and both Android RN cases continue to verify tags. + +For RN iOS local runs, prepare each wallet on the dedicated Android emulator with `BACKEND=regtest MIGRATION_SETUP_WALLET=1 ANDROID_UDID="$MIGRATION_ANDROID_SERIAL" ANDROID_SERIAL="$MIGRATION_ANDROID_SERIAL" APPIUM_PORT=4725 ANDROID_SYSTEM_PORT=8205 npm run e2e:android -- --mochaOpts.grep '@migration_setup_standard'`. Load the resulting `artifacts/migration_setup_standard.env` into the environment before running the chosen RN iOS case. Treat these files as secrets and never print the seed in diagnostics. Use a fresh setup wallet for each case and retry. + +Native verification checks separate savings and spending balances, recovered activity and tags, state after relaunch, and receipt of a small Lightning payment. The payment demonstrates incoming Lightning usability; it does not claim outbound payment coverage. + +Source preparation verifies backup completion after funding and metadata changes. Native builds expose `AllSynced`; RN release builds require successful `Latest Backup` statuses for every expected category, including Tags and Connections when spending exists. RN checks these before exporting a wallet or installing the target. + +## Retries and extended coverage + +Native and Android RN cases create new wallets inside every test attempt. iOS RN has two Android preparation jobs in routine runs, one for restore and one for upgrade. Each prepares **three independent wallets**, one per possible consumer attempt, with up to three setup attempts per wallet. This costs extra setup time but prevents a partial migration from modifying the backup used by its retry. Setup completion markers include the wallet number. + +Use **Re-run all jobs** when rerunning a failed iOS RN workflow. Wallet artifact names include the workflow run attempt: rerunning only consumer jobs intentionally fails to download old wallets, instead of reusing potentially modified backups. Each attempt loads only its own wallet environment. Wallet artifacts have one-day retention. + +Set manual dispatch input `extended_rn=true` to add RN 1.1.6 passphrase (`@migration_3`) and legacy-address (`@migration_4`) cases. Locally set `MIGRATION_EXTENDED=true` and select the corresponding tag. These are retained targeted coverage, excluded from the four routine cases. + +The first two test failures allow retries; the third failure fails its job. The `migration-result` job requires every expected job to succeed, including setup and plan resolution. Skipped required jobs count as failure. Slack's overall migration result uses this gate. Source metadata and test diagnostics are uploaded even on success to preserve retry evidence. + +## Advance the baseline + +1. Archive the released regtest builds using `/archive-release` after validating that release. +2. Verify the archived APK and simulator zip install on CI devices, including Android package/signing compatibility and iOS simulator architecture/runtime compatibility. +3. Update the relevant platform in `config/migration-baselines.json` to its exact release tag. Do not advance automatically when an archive is uploaded. +4. Record the release asset SHA-256 digests in `config/migration-checksums.json`; the downloader verifies known checksums before installing/extracting. Overrides without a recorded checksum still log the calculated checksum. +5. Run the four cases on that platform and review failures before merging the baseline update. + +The downloader fails on missing/empty/malformed assets or checksum mismatches. Previous native apps live under `aut/previous-native/`, so the target cannot be overwritten. `artifacts/migration-source.json` records source URL/tag/checksum, target revision, and E2E revision. It contains no wallet seed. + +## Coordinated rollout and known failures + +Land the E2E changes (including the reusable setup workflow) before enabling the new app workflows. GitHub reusable workflow references use `@main`; companion branches select the E2E scripts/tests but do not change which reusable workflow definition GitHub executes. Before rollout, validation of that definition requires an explicit temporary reference to the companion branch. Keep matching companion branch names across repositories for test-code resolution. + +Android restore may expose [bitkit-android #1342](https://github.com/synonymdev/bitkit-android/issues/1342). Preserve failing restore assertions and report the issue separately from infrastructure failures; do not skip the case or accept missing funds to get a passing run. + +Static helper checks: + +```sh +python3 -m unittest discover -s scripts -p 'test_migration_tools.py' +bash -n scripts/prepare-migration-wallets.sh +``` + +Device passes and hosted workflow passes are separate evidence. Local runs cannot prove GitHub artifact transfer or job dependency execution. diff --git a/scripts/build-android-apk.sh b/scripts/build-android-apk.sh index 55ebb598..cb270246 100755 --- a/scripts/build-android-apk.sh +++ b/scripts/build-android-apk.sh @@ -49,6 +49,7 @@ echo "Building Android APK (BACKEND=$BACKEND, E2E_BACKEND=$E2E_BACKEND, TREZOR_B pushd "$ANDROID_ROOT" >/dev/null E2E=true \ E2E_BACKEND="$E2E_BACKEND" \ + GEO=false \ TREZOR_BRIDGE="$TREZOR_BRIDGE" \ TREZOR_BRIDGE_URL="$TREZOR_BRIDGE_URL" \ ./gradlew "$GRADLE_TASK" --no-daemon --stacktrace diff --git a/scripts/build-ios-sim.sh b/scripts/build-ios-sim.sh index 7ce2388f..cb5ed294 100755 --- a/scripts/build-ios-sim.sh +++ b/scripts/build-ios-sim.sh @@ -42,7 +42,9 @@ XCODE_EXTRA_ARGS+=( "E2E_NETWORK=$E2E_NETWORK" "TREZOR_BRIDGE=$TREZOR_BRIDGE" "TREZOR_BRIDGE_URL=$TREZOR_BRIDGE_URL" - "SWIFT_ACTIVE_COMPILATION_CONDITIONS=\$(inherited) E2E_BUILD" + # Archived E2E builds run on hosted US runners. Do not inherit the Debug + # configuration's CHECK_GEOBLOCK flag or spending setup is unavailable. + "SWIFT_ACTIVE_COMPILATION_CONDITIONS=DEBUG E2E_BUILD" ) if [[ -n "$TREZOR_ELECTRUM_URL" ]]; then diff --git a/scripts/download-migration-app.py b/scripts/download-migration-app.py new file mode 100644 index 00000000..1bbc97fa --- /dev/null +++ b/scripts/download-migration-app.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Download a migration source, keeping previous native builds separate from targets.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +RELEASES = "https://github.com/synonymdev/bitkit-e2e-tests/releases/download" + + +def download(platform, source, version): + if source == "rn": + if version != "v1.1.6": + raise ValueError("Only React Native v1.1.6 is supported") + elif not re.fullmatch(r"\d+\.\d+\.\d+(?:[-.][A-Za-z0-9]+)*", version): + raise ValueError(f"Invalid native release tag: {version!r}") + if source == "native": + tag = version + asset = {"android": "bitkit_e2e.apk", "ios": "Bitkit.app.zip"}[platform] + destination = ROOT / "aut" / "previous-native" + else: + tag = "migration-rn-regtest" + asset = { + "android": f"bitkit_rn_regtest_{version}.apk", + "ios": f"bitkit_rn_regtest_ios_{version}.zip", + }[platform] + destination = ROOT / "aut" + destination.mkdir(parents=True, exist_ok=True) + url = f"{RELEASES}/{tag}/{asset}" + with tempfile.TemporaryDirectory(dir=destination) as staging: + archive = Path(staging) / asset + subprocess.run(["curl", "--fail", "--location", "--retry", "3", "--connect-timeout", "30", "--max-time", "900", "--output", str(archive), url], check=True) + if not archive.stat().st_size: + raise ValueError(f"Empty migration asset: {url}") + checksum = hashlib.sha256() + with archive.open("rb") as data: + for chunk in iter(lambda: data.read(1024 * 1024), b""): + checksum.update(chunk) + digest = checksum.hexdigest() + checksums = json.loads((ROOT / "config/migration-checksums.json").read_text()) + expected = checksums.get(tag, {}).get(asset) + if expected and digest != expected: + raise ValueError(f"Checksum mismatch for {url}: expected {expected}, received {digest}") + if platform == "android": + with zipfile.ZipFile(archive) as apk: + if "AndroidManifest.xml" not in apk.namelist(): + raise ValueError(f"Not an Android APK: {url}") + app = destination / ("bitkit_e2e.apk" if source == "native" else "bitkit_rn_regtest.apk") + shutil.move(str(archive), app) + else: + bundle = "Bitkit.app" if source == "native" else f"bitkit_rn_regtest_ios_{version}.app" + with zipfile.ZipFile(archive) as zipped: + for entry in zipped.namelist(): + if Path(entry).is_absolute() or ".." in Path(entry).parts: + raise ValueError(f"Unsafe archive entry: {entry}") + if f"{bundle}/Info.plist" not in zipped.namelist(): + raise ValueError(f"Archive does not contain {bundle}/Info.plist") + # ditto preserves executable permissions and bundle symlinks on macOS. + subprocess.run(["ditto", "-x", "-k", str(archive), staging], check=True) + app = destination / ("Bitkit.app" if source == "native" else "bitkit_rn_regtest_ios.app") + if app.exists(): + shutil.rmtree(app) + shutil.move(str(Path(staging) / bundle), app) + metadata = {"platform": platform, "source": source, "version": version, "url": url, "sha256": digest, + "app_path": str(app), "target_revision": os.getenv("GITHUB_SHA", "local"), + "e2e_revision": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()} + artifacts = ROOT / "artifacts" + artifacts.mkdir(exist_ok=True) + (artifacts / "migration-source.json").write_text(json.dumps(metadata, indent=2) + "\n") + print(json.dumps(metadata, indent=2)) + if source == "native" and os.getenv("GITHUB_ENV"): + with open(os.environ["GITHUB_ENV"], "a") as env: + env.write(f"PREVIOUS_NATIVE_APP_PATH={app}\nPREVIOUS_NATIVE_VERSION={version}\n") + return app + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("platform", choices=("android", "ios")) + parser.add_argument("source", choices=("rn", "native")) + parser.add_argument("version") + args = parser.parse_args() + try: + download(args.platform, args.source, args.version) + except (ValueError, OSError, subprocess.CalledProcessError, zipfile.BadZipFile) as error: + parser.exit(1, f"Migration source download failed: {error}\n") diff --git a/scripts/migration-matrix.py b/scripts/migration-matrix.py new file mode 100644 index 00000000..dc1d64cf --- /dev/null +++ b/scripts/migration-matrix.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Resolve an explicit native baseline and the migration cases for CI.""" +import argparse +import json +import os +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] + + +def migration_plan(platform, override="", extended=False): + version = override.strip() or json.loads((ROOT / "config/migration-baselines.json").read_text())[platform] + if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-.][A-Za-z0-9]+)*", version): + raise ValueError(f"Invalid native release tag: {version!r}") + rn = [ + {"name": "rn_restore", "source": "rn", "version": "v1.1.6", "setup_type": "standard", "grep": "@migration_rn_restore"}, + {"name": "rn_upgrade", "source": "rn", "version": "v1.1.6", "setup_type": "standard", "grep": "@migration_rn_upgrade"}, + ] + if extended: + rn += [ + {"name": "rn_passphrase", "source": "rn", "version": "v1.1.6", "setup_type": "passphrase", "grep": "@migration_3"}, + {"name": "rn_sweep", "source": "rn", "version": "v1.1.6", "setup_type": "sweep", "grep": "@migration_4"}, + ] + native = [ + {"name": f"native_{method}", "source": "native", "version": version, "grep": f"@migration_native_{method}"} + for method in ("restore", "upgrade") + ] + return {"version": version, "rn": rn, "native": native, "all": rn + native} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("platform", choices=("android", "ios")) + args = parser.parse_args() + plan = migration_plan(args.platform, os.getenv("PREVIOUS_NATIVE_VERSION", ""), os.getenv("EXTENDED_RN", "false") == "true") + print(json.dumps(plan, indent=2)) + if os.getenv("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + for key, value in plan.items(): + output.write(f"{key}={json.dumps(value, separators=(',', ':')) if isinstance(value, list) else value}\n") diff --git a/scripts/prepare-migration-wallets.sh b/scripts/prepare-migration-wallets.sh new file mode 100644 index 00000000..a757c08a --- /dev/null +++ b/scripts/prepare-migration-wallets.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Each iOS consumer retry gets a different wallet; migrating can modify its backup. +set -euo pipefail +setup_type="${1:?Expected standard, passphrase, or sweep}" +case "$setup_type" in standard|passphrase|sweep) ;; *) echo "Unknown setup type: $setup_type" >&2; exit 1 ;; esac + +for wallet in 1 2 3; do + env_file="artifacts/migration_setup_${setup_type}.env" + prepared=false + for attempt in 1 2 3; do + rm -f "$env_file" + # A failed RN gesture can leave Android's notification shade above the app. + # Collapse it before starting the next independent Appium session. + adb shell cmd statusbar collapse >/dev/null 2>&1 || true + if MIGRATION_SETUP_WALLET="$wallet" ATTEMPT="wallet-${wallet}-try-${attempt}" \ + ./ci_run_android.sh --mochaOpts.grep "@migration_setup_${setup_type}"; then + test -s "$env_file" + mkdir -p "artifacts/wallet-attempt-${wallet}" + cp "$env_file" "artifacts/wallet-attempt-${wallet}/" + prepared=true + break + fi + done + if [[ "$prepared" != true ]]; then + echo "Failed to prepare independent wallet $wallet after 3 attempts" >&2 + exit 1 + fi +done diff --git a/scripts/test_migration_tools.py b/scripts/test_migration_tools.py new file mode 100644 index 00000000..577a1d9b --- /dev/null +++ b/scripts/test_migration_tools.py @@ -0,0 +1,119 @@ +"""Migration planning and archive failure checks; no devices or network required.""" +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch +import zipfile + + +def load_script(name): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +matrix = load_script("migration-matrix") +downloader = load_script("download-migration-app") + + +class MigrationPlanTests(unittest.TestCase): + def test_routine_has_exactly_four_cases_and_one_rn_version(self): + for platform in ("android", "ios"): + plan = matrix.migration_plan(platform) + self.assertEqual([case["grep"] for case in plan["all"]], [ + "@migration_rn_restore", "@migration_rn_upgrade", + "@migration_native_restore", "@migration_native_upgrade", + ]) + self.assertEqual({case["version"] for case in plan["rn"]}, {"v1.1.6"}) + self.assertEqual(plan["version"], "2.5.0") + + def test_override_and_extended_are_explicit(self): + plan = matrix.migration_plan("ios", "2.4.0", True) + self.assertEqual(len(plan["all"]), 6) + self.assertEqual({case["version"] for case in plan["native"]}, {"2.4.0"}) + self.assertEqual([case["grep"] for case in plan["rn"]][2:], ["@migration_3", "@migration_4"]) + + def test_invalid_tag_rejected(self): + for version in ("latest", "v2.5.0", "../2.5.0", "2.5.0\nEVIL=1", "2.5.0'; exit 1"): + with self.assertRaises(ValueError): + matrix.migration_plan("android", version) + + +class DownloadTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + (self.root / "config").mkdir() + (self.root / "config/migration-checksums.json").write_text("{}") + self.root_patch = patch.object(downloader, "ROOT", self.root) + self.root_patch.start() + self.addCleanup(self.root_patch.stop) + + def apk_download(self, args, **kwargs): + archive = Path(args[args.index("--output") + 1]) + with zipfile.ZipFile(archive, "w") as apk: + apk.writestr("AndroidManifest.xml", "test manifest") + + def test_native_version_rejects_rn_prefix(self): + with self.assertRaisesRegex(ValueError, "Invalid native release tag"): + downloader.download("android", "native", "v2.5.0") + + def test_download_keeps_target_separate_and_records_provenance(self): + target = self.root / "aut/bitkit_e2e.apk" + target.parent.mkdir() + target.write_bytes(b"current target") + with patch.object(downloader.subprocess, "run", side_effect=self.apk_download), patch.object( + downloader.subprocess, "check_output", return_value="test-e2e-sha\n" + ), patch.dict(downloader.os.environ, {}, clear=True): + app = downloader.download("android", "native", "2.5.0") + self.assertEqual(target.read_bytes(), b"current target") + self.assertEqual(app, self.root / "aut/previous-native/bitkit_e2e.apk") + metadata = json.loads((self.root / "artifacts/migration-source.json").read_text()) + self.assertEqual(metadata["sha256"], hashlib.sha256(app.read_bytes()).hexdigest()) + self.assertEqual(metadata["e2e_revision"], "test-e2e-sha") + + def test_http_failure_has_no_fallback(self): + with patch.object(downloader.subprocess, "run", side_effect=subprocess.CalledProcessError(22, "curl")): + with self.assertRaises(subprocess.CalledProcessError): + downloader.download("android", "native", "2.5.0") + self.assertFalse((self.root / "aut/previous-native/bitkit_e2e.apk").exists()) + + def test_checksum_mismatch_rejected_before_install(self): + (self.root / "config/migration-checksums.json").write_text(json.dumps({"2.5.0": {"bitkit_e2e.apk": "wrong"}})) + with patch.object(downloader.subprocess, "run", side_effect=self.apk_download): + with self.assertRaisesRegex(ValueError, "Checksum mismatch"): + downloader.download("android", "native", "2.5.0") + self.assertFalse((self.root / "aut/previous-native/bitkit_e2e.apk").exists()) + + def test_ios_rejects_missing_bundle_and_traversal_without_touching_target(self): + target = self.root / "aut/Bitkit.app" + target.mkdir(parents=True) + (target / "Info.plist").write_text("current target") + for entries, error in (({"Other.app/Info.plist": "bad"}, "does not contain"), + ({"Bitkit.app/Info.plist": "plist", "../escape": "bad"}, "Unsafe archive entry")): + def download_zip(args, **kwargs): + with zipfile.ZipFile(args[args.index("--output") + 1], "w") as archive: + for name, content in entries.items(): + archive.writestr(name, content) + with patch.object(downloader.subprocess, "run", side_effect=download_zip): + with self.assertRaisesRegex(ValueError, error): + downloader.download("ios", "native", "2.4.0") + self.assertEqual((target / "Info.plist").read_text(), "current target") + self.assertFalse((self.root / "aut/previous-native/Bitkit.app").exists()) + + def test_bad_archive_rejected(self): + def download_html(args, **kwargs): + Path(args[args.index("--output") + 1]).write_text("not an APK") + with patch.object(downloader.subprocess, "run", side_effect=download_html): + with self.assertRaises(zipfile.BadZipFile): + downloader.download("android", "native", "2.5.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 14e174b9..1670e127 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -685,6 +685,10 @@ export async function dragOnElement( break; } + // Appium may drop gestures whose endpoint is outside the viewport. + endX = Math.max(1, Math.min(width - 1, endX)); + endY = Math.max(1, Math.min(height - 1, endY)); + await driver.performActions([ { type: 'pointer', @@ -737,16 +741,30 @@ export async function handleAndroidAlert( } } -export async function getSeed(): Promise { +export async function getSeed({ + readBeforeReveal = false, +}: { readBeforeReveal?: boolean } = {}): Promise { await openSettings('security'); await tap('BackupWallet'); + // Android 2.5.0 attaches the mnemonic to the reveal overlay, which disappears on reveal. + let seed = ''; + if (readBeforeReveal) { + const seedElement = elementById('SeedContainer'); + await seedElement.waitForDisplayed(); + seed = await getAccessibleText(seedElement); + if (![12, 24].includes(seed.trim().split(/\s+/).length)) { + throw new Error('Expected a 12/24-word mnemonic on the legacy reveal overlay'); + } + } + await tap('TapToReveal'); await sleep(1000); - const seedElement = await elementById('SeedContainer'); - const seed = await getAccessibleText(seedElement); - console.info({ seed }); + if (!readBeforeReveal) { + const seedElement = await elementById('SeedContainer'); + seed = await getAccessibleText(seedElement); + } if (!seed) throw new Error('Could not read seed from "SeedContainer"'); // close the modal @@ -796,7 +814,7 @@ export async function restoreWallet( reinstall?: boolean; } = {} ) { - console.info('→ Restoring wallet with seed:', seed); + console.info('→ Restoring wallet from recovery phrase'); // Let cloud state flush - carried over from Detox await sleep(5000); @@ -944,9 +962,7 @@ export async function waitForTextToDisappear(texts: string[], timeout: number) { * @param retryTap - Optional callback to retry the address type tap if feedback missed * @returns true if toast was observed, false if best-effort fallback was used */ -async function assertAddressTypeSwitchFeedback( - retryTap?: () => Promise -): Promise { +async function assertAddressTypeSwitchFeedback(retryTap?: () => Promise): Promise { // First try: wait for Updated toast (the primary success signal) let toastSeen = await waitForToastBestEffort('AddressTypeSettingsUpdatedToast', { timeout: 12_000, @@ -1098,9 +1114,11 @@ export async function switchAndFundEachAddressType({ export async function transferSavingsToSpending({ amountSats, waitForSync, + triggerBackgroundPaymentsIntro = false, }: { amountSats?: number; waitForSync?: () => Promise; + triggerBackgroundPaymentsIntro?: boolean; } = {}) { try { await elementById('ActivitySavings').waitForDisplayed({ timeout: 5_000 }); @@ -1135,9 +1153,25 @@ export async function transferSavingsToSpending({ await elementById('SpendingAmountContinue').waitForEnabled(); await tap('SpendingAmountContinue'); await sleep(1000); - await elementById('GRAB').waitForDisplayed(); - await dragOnElement('GRAB', 'right', 0.95); - await sleep(1500); + const confirmHandle = elementById('GRAB'); + await confirmHandle.waitForDisplayed(); + const startPosition = await confirmHandle.getLocation(); + for (let attempt = 1; attempt <= 3; attempt++) { + await dragOnElement('GRAB', 'right', 0.95); + if (!(await confirmHandle.isDisplayed())) break; + const position = await confirmHandle.getLocation(); + // A moved handle can mean the payment is processing: never submit it again. + if (position.x > startPosition.x + 5) break; + if (attempt < 3) { + console.info(`→ Transfer gesture did not move the handle; retrying (${attempt}/3)`); + await sleep(500); + } + } + await confirmHandle.waitForDisplayed({ + reverse: true, + timeout: 60_000, + timeoutMsg: 'Transfer confirmation did not finish; no blocks mined', + }); await mineBlocks(1); if (waitForSync) { @@ -1163,7 +1197,7 @@ export async function transferSavingsToSpending({ console.info('→ SpendingBalanceReadyToast not found, continuing...'); } - await dismissBackgroundPaymentsTimedSheet({ triggerTimedSheet: false }); + await dismissBackgroundPaymentsTimedSheet({ triggerTimedSheet: triggerBackgroundPaymentsIntro }); await dismissQuickPayIntro({ triggerTimedSheet: true }); await tap('ActivitySavings'); @@ -1401,10 +1435,7 @@ export async function waitForToast( */ export async function waitForToastBestEffort( toastId: ToastId, - { - timeout = 10_000, - pollingInterval = 200, - }: { timeout?: number; pollingInterval?: number } = {} + { timeout = 10_000, pollingInterval = 200 }: { timeout?: number; pollingInterval?: number } = {} ): Promise { const el = elementById(toastId); let toastSeen = false; @@ -1648,9 +1679,8 @@ export async function dismissBackupTimedSheet({ export async function dismissQuickPayIntro({ triggerTimedSheet = false, }: { triggerTimedSheet?: boolean } = {}) { - if (triggerTimedSheet) { - await doTriggerTimedSheet(); - } + const sheetId = driver.isAndroid ? 'QuickpayIntro-button' : 'QuickpayIntroDescription'; + await triggerTimedSheetUnlessPresent(sheetId, triggerTimedSheet); if (driver.isAndroid) { // TODO: it's temp, change on Android to match iOS testID diff --git a/test/helpers/native-migration.ts b/test/helpers/native-migration.ts new file mode 100644 index 00000000..f138dcc9 --- /dev/null +++ b/test/helpers/native-migration.ts @@ -0,0 +1,260 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + acknowledgeReceivedPayment, + completeOnboarding, + confirmInputOnKeyboard, + doNavigationClose, + elementById, + expectTextWithin, + expectSavingsBalance, + expectSpendingBalance, + getReceiveAddress, + getSavingsBalance, + getSeed, + getSpendingBalance, + getTotalBalance, + receiveOnchainFunds, + restoreWallet, + swipeFullScreen, + tap, + transferSavingsToSpending, + typeText, + waitForBackup, +} from './actions'; +import { getAppId, getBackend } from './constants'; +import type { ElectrumClient } from './electrum'; +import { payInvoice } from './regtest'; +import { getNativeAppPath, grantIOSCameraPermission, reinstallAppFromPath } from './setup'; + +const FUNDING_SATS = 200_000; +const SPENDING_SATS = 50_000; +const PAYMENT_SATS = 1_000; +const DEPOSIT_TAG = 'e2e'; + +export type MigrationBalances = { savings: number; spending: number; total: number }; + +type NativeWallet = { seed: string; balances: MigrationBalances }; + +export async function readMigrationBalances(): Promise { + return { + savings: await getSavingsBalance(), + spending: await getSpendingBalance(), + total: await getTotalBalance(), + }; +} + +export async function expectMigrationBalances(expected: MigrationBalances) { + let actual: MigrationBalances | undefined; + await elementById('TotalBalance-primary').waitForDisplayed({ timeout: 180_000 }); + try { + await driver.waitUntil( + async () => { + actual = await readMigrationBalances(); + return ( + actual.savings === expected.savings && + actual.spending === expected.spending && + actual.total === expected.total + ); + }, + { timeout: 180_000, interval: 2_000, timeoutMsg: 'Migration balances did not recover' } + ); + } catch (error) { + throw new Error( + `Migration balances: expected ${JSON.stringify(expected)}, last observed ${JSON.stringify(actual)}`, + { cause: error } + ); + } +} + +/** Every invocation starts with a new wallet, including CI retries. */ +export async function prepareNativeMigrationWallet( + electrum: ElectrumClient, + method: 'restore' | 'upgrade' +): Promise { + if (getBackend() !== 'regtest') { + throw new Error('Native migration archives use regtest. Run with BACKEND=regtest.'); + } + recordStage(method, 'preparing-source'); + const source = process.env.PREVIOUS_NATIVE_APP_PATH; + if (!source || !fs.existsSync(source)) { + throw new Error( + 'PREVIOUS_NATIVE_APP_PATH must point to the downloaded previous native release.' + ); + } + if (fs.realpathSync(source) === fs.realpathSync(getNativeAppPath())) { + throw new Error('Previous native source and current target must be separate artifacts.'); + } + console.info( + `→ Preparing native migration from ${process.env.PREVIOUS_NATIVE_VERSION ?? 'explicit source'}: ${path.resolve(source)}` + ); + await reinstallAppFromPath(source, getAppId(), { strictKeychainReset: true }); + await completeOnboarding(); + await receiveOnchainFunds({ sats: FUNDING_SATS, verifyBalances: true }); + await electrum.waitForSync(); + + // Tag the sole deposit before creating the transfer; row order is unambiguous here. + await tap('ActivitySavings'); + await tap('Activity-1'); + await tap('ActivityTag'); + await typeText('TagInput', DEPOSIT_TAG); + await tap('ActivityTagsSubmit'); + if (driver.isAndroid) await confirmInputOnKeyboard(); + await returnToWalletHome(); + + await transferSavingsToSpending({ + amountSats: SPENDING_SATS, + waitForSync: electrum.waitForSync, + triggerBackgroundPaymentsIntro: true, + }); + await expectSpendingBalance(SPENDING_SATS, { timeout: 180_000 }); + await expectSavingsBalance(0, { condition: 'gt' }); + let balances = await readMigrationBalances(); + try { + await driver.waitUntil( + async () => { + balances = await readMigrationBalances(); + return ( + balances.savings > 0 && + balances.spending === SPENDING_SATS && + balances.total === balances.savings + balances.spending + ); + }, + { timeout: 30_000, interval: 1_000, timeoutMsg: 'Source balances did not settle' } + ); + } catch (error) { + throw new Error(`Source wallet funding incomplete: ${JSON.stringify(balances)}`, { + cause: error, + }); + } + // Reading the seed marks backup complete, so do this after first-funding reminders. + const seed = await getSeed({ + readBeforeReveal: + driver.isAndroid && process.env.PREVIOUS_NATIVE_VERSION?.replace(/^v/, '') === '2.5.0', + }); + await waitForBackup(); + recordStage(method, 'source-backed-up', balances); + return { seed, balances }; +} + +async function returnToWalletHome() { + for (let attempt = 0; attempt < 4; attempt += 1) { + const savingsVisible = await elementById('ActivitySavings') + .isDisplayed() + .catch(() => false); + const spendingVisible = await elementById('ActivitySpending') + .isDisplayed() + .catch(() => false); + if (savingsVisible && spendingVisible) return; + + const back = elementById('NavigationBack'); + if (!(await back.isDisplayed().catch(() => false))) break; + await back.click(); + await driver.pause(500); + } + + throw new Error('Could not return to the wallet home after tagging the migration deposit'); +} + +export async function installNativeMigrationTarget(method: 'restore' | 'upgrade', seed: string) { + recordStage(method, 'installing-target'); + await driver.terminateApp(getAppId()); + if (method === 'restore') { + await reinstallAppFromPath(getNativeAppPath(), getAppId(), { strictKeychainReset: true }); + // Require fresh onboarding: retained Keychain data must never satisfy a restore test. + await elementById('Continue').waitForDisplayed({ timeout: 60_000 }); + await restoreWallet(seed, { reinstall: false }); + } else { + await driver.installApp(getNativeAppPath()); + grantIOSCameraPermission(); + await driver.activateApp(getAppId()); + // Native upgrades need not display the RN-specific MIGRATING screen. + await elementById('TotalBalance-primary').waitForDisplayed({ timeout: 180_000 }); + } +} + +async function verifyNativeHistory() { + await tap('ActivityShowAll'); + await tap('Tab-received'); + await tap('TagsPrompt'); + const tag = elementById(`Tag-${DEPOSIT_TAG}`); + await tag.waitForDisplayed({ timeout: 60_000 }); + let previousPosition = await tag.getLocation(); + let stableSamples = 0; + await driver.waitUntil( + async () => { + const position = await tag.getLocation(); + stableSamples = + position.x === previousPosition.x && position.y === previousPosition.y + ? stableSamples + 1 + : 0; + previousPosition = position; + return stableSamples >= 3; + }, + { timeout: 10_000, interval: 250, timeoutMsg: 'Tag selector did not finish opening' } + ); + await tag.click(); + await elementById(`Tag-${DEPOSIT_TAG}-delete`).waitForDisplayed({ timeout: 10_000 }); + await expectTextWithin('Activity-1', '200 000'); + await expectTextWithin('Activity-1', '+'); + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + await tap(`Tag-${DEPOSIT_TAG}-delete`); + await tap('Tab-other'); + await expectTextWithin('Activity-1', '-'); + await doNavigationClose(); +} + +export async function verifyNativeMigration( + balances: MigrationBalances, + method: 'restore' | 'upgrade' +) { + await expectMigrationBalances(balances); + recordStage(method, 'target-recovered', await readMigrationBalances()); + await verifyNativeHistory(); + + // A live payment checks channel usability, beyond a cached spending balance. + // The regtest helper pays into the wallet; outbound payment is covered separately. + const invoice = await getReceiveAddress('lightning'); + await swipeFullScreen('down'); + await payInvoice(invoice, PAYMENT_SATS); + await acknowledgeReceivedPayment(); + const afterPayment = { + savings: balances.savings, + spending: balances.spending + PAYMENT_SATS, + total: balances.total + PAYMENT_SATS, + }; + await expectMigrationBalances(afterPayment); + recordStage(method, 'payment-received', await readMigrationBalances()); + await waitForBackup(); + await driver.terminateApp(getAppId()); + await driver.activateApp(getAppId()); + await expectMigrationBalances(afterPayment); + recordStage(method, 'relaunch-verified', await readMigrationBalances()); + await verifyNativeHistory(); + await tap('ActivityShowAll'); + await tap('Tab-received'); + // The only new received transaction is the live Lightning payment. + await expectTextWithin('Activity-1', '1 000'); + await expectTextWithin('Activity-1', '+'); + await doNavigationClose(); +} + +function recordStage(method: string, stage: string, balances?: MigrationBalances) { + const directory = path.join(process.cwd(), 'artifacts'); + fs.mkdirSync(directory, { recursive: true }); + const event = { + time: new Date().toISOString(), + platform: driver.isIOS ? 'ios' : 'android', + method, + stage, + attempt: process.env.ATTEMPT ?? 'local', + sourceVersion: process.env.PREVIOUS_NATIVE_VERSION, + sourcePath: process.env.PREVIOUS_NATIVE_APP_PATH, + balances, + }; + fs.appendFileSync( + path.join(directory, `migration-native-${method}.jsonl`), + `${JSON.stringify(event)}\n` + ); + console.info('→ Native migration:', event); +} diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 77b53c04..d9c30bb5 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { sleep } from './actions'; @@ -6,7 +6,7 @@ import { getAppId, getAppPath } from './constants'; function getIosSimulatorUdidForSimctl(): string { try { - let udid = + const udid = (driver.capabilities as Record)['appium:udid']?.toString() ?? (driver.capabilities as Record).udid?.toString() ?? (driver.capabilities as Record).deviceUDID?.toString() ?? @@ -30,6 +30,7 @@ export function grantIOSCameraPermission(appIdParam?: string) { if (typeof driver === 'undefined' || !driver.isIOS) return; const appId = appIdParam ?? getAppId(); const udid = getIosSimulatorUdidForSimctl(); + if (!udid) { console.warn('⚠ grantIOSCameraPermission: could not resolve simulator UDID'); return; @@ -86,10 +87,14 @@ export function getNativeAppPath(): string { return appPath; } -export async function reinstallAppFromPath(appPath: string, appId: string = getAppId()) { +export async function reinstallAppFromPath( + appPath: string, + appId: string = getAppId(), + { strictKeychainReset = false }: { strictKeychainReset?: boolean } = {} +) { console.info(`→ Reinstalling app from: ${appPath}`); await driver.removeApp(appId); - resetBootedIOSKeychain(); + resetBootedIOSKeychain({ strict: strictKeychainReset }); await driver.installApp(appPath); grantIOSCameraPermission(appId); await driver.activateApp(appId); @@ -100,7 +105,7 @@ export async function reinstallAppFromPath(appPath: string, appId: string = getA * (Wallet data is stored in iOS Keychain and persists even after app uninstall * unless the whole simulator is reset or keychain is reset specifically) */ -export function resetBootedIOSKeychain() { +export function resetBootedIOSKeychain({ strict = false }: { strict?: boolean } = {}) { if (!driver.isIOS) return; let udid = ''; @@ -109,8 +114,24 @@ export function resetBootedIOSKeychain() { (driver.capabilities as Record)['appium:udid']?.toString() ?? (driver.capabilities as Record).udid?.toString() ?? (driver.capabilities as Record).deviceUDID?.toString() ?? + process.env.SIMULATOR_UDID ?? ''; - } catch {} + } catch { + // A strict reset below rejects an unresolved device. + } + + if (udid === 'auto') udid = process.env.SIMULATOR_UDID ?? ''; + + if (strict) { + if (!/^[0-9a-f-]{36}$/i.test(udid)) { + throw new Error( + 'Clean migration requires an explicit iOS simulator UDID to reset its keychain.' + ); + } + execFileSync('xcrun', ['simctl', 'keychain', udid, 'reset'], { stdio: 'pipe' }); + console.info(`→ Reset iOS simulator keychain for ${udid}`); + return; + } if (!udid) { console.warn( diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index a68bfc17..0ca38a05 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -4,6 +4,7 @@ import { acknowledgeReceivedPayment, confirmInputOnKeyboard, dismissBackupTimedSheet, + dismissBackgroundPaymentsTimedSheet, doNavigationClose, dragOnElement, elementById, @@ -12,7 +13,6 @@ import { expectTextWithin, getAccessibleText, getReceiveAddress, - getUriFromQRCode, handleAndroidAlert, pasteIOSText, restoreWallet, @@ -22,6 +22,12 @@ import { typeText, waitForSetupWalletScreenFinish, } from '../helpers/actions'; +import { + expectMigrationBalances, + installNativeMigrationTarget, + prepareNativeMigrationWallet, + verifyNativeMigration, +} from '../helpers/native-migration'; import { ciIt } from '../helpers/suite'; import { getNativeAppPath, @@ -32,13 +38,7 @@ import { } from '../helpers/setup'; import { getAppId } from '../helpers/constants'; import initElectrum, { ElectrumClient } from '../helpers/electrum'; -import { - deposit, - ensureLocalFunds, - getExternalAddress, - mineBlocks, - payInvoice, -} from '../helpers/regtest'; +import { deposit, ensureLocalFunds, getExternalAddress, mineBlocks } from '../helpers/regtest'; // Module-level electrum client (set in before hook) let electrumClient: ElectrumClient; @@ -73,7 +73,7 @@ const IOS_RN_BALANCE = process.env.RN_BALANCE // TEST SUITE // ============================================================================ -describe('@migration - Migration from legacy RN app to native app', () => { +describe('Wallet migration', () => { before(async () => { await ensureLocalFunds(); electrumClient = await initElectrum(); @@ -83,97 +83,106 @@ describe('@migration - Migration from legacy RN app to native app', () => { await electrumClient?.stop(); }); + for (const method of ['restore', 'upgrade'] as const) { + ciIt( + `@migration_native_${method} - Native ${process.env.PREVIOUS_NATIVE_VERSION ?? 'source'} ${method}`, + async () => { + const wallet = await prepareNativeMigrationWallet(electrumClient, method); + await installNativeMigrationTarget(method, wallet.seed); + await verifyNativeMigration(wallet.balances, method); + } + ); + } + // -------------------------------------------------------------------------- // Migration Setup: Prepare legacy RN wallets on Android for iOS runs // -------------------------------------------------------------------------- - ciIt('@migration_setup_standard - Prepare legacy RN wallet (Android only)', async () => { - if (driver.isIOS) { - throw new Error('Migration setup should run on Android only.'); - } - - const { mnemonic, balance } = await setupLegacyWallet({ returnSeed: true }); - writeMigrationEnvFile({ - fileName: 'migration_setup_standard.env', - mnemonicVar: 'RN_MNEMONIC', - balanceVar: 'RN_BALANCE', - mnemonic, - balance, - }); - // Wait for backup propagation (tags, activity metadata) before env artifact upload - console.info('→ Waiting 40 seconds to ensure backups (incl. tags)...'); - await sleep(40_000); - }); - - ciIt( - '@migration_setup_passphrase - Prepare legacy wallet with passphrase (Android only)', - async () => { - if (driver.isIOS) { - throw new Error('Migration setup should run on Android only.'); + if (process.env.MIGRATION_SETUP_WALLET) { + ciIt( + `@migration_setup_standard - Prepare legacy RN wallet ${process.env.MIGRATION_SETUP_WALLET ?? '1'} (Android only)`, + async () => { + if (driver.isIOS) { + throw new Error('Migration setup should run on Android only.'); + } + + const { mnemonic, balance } = await setupLegacyWallet({ returnSeed: true }); + writeMigrationEnvFile({ + fileName: 'migration_setup_standard.env', + mnemonicVar: 'RN_MNEMONIC', + balanceVar: 'RN_BALANCE', + mnemonic, + balance, + }); } + ); - const { mnemonic, balance } = await setupLegacyWallet({ - returnSeed: true, - passphrase: TEST_PASSPHRASE, - }); - writeMigrationEnvFile({ - fileName: 'migration_setup_passphrase.env', - mnemonicVar: 'RN_MNEMONIC', - balanceVar: 'RN_BALANCE', - mnemonic, - balance, - }); - // Wait for backup propagation (tags, activity metadata) before env artifact upload - console.info('→ Waiting 40 seconds to ensure backups (incl. tags)...'); - await sleep(40_000); - } - ); - - ciIt('@migration_setup_sweep - Prepare legacy sweep wallet (Android only)', async () => { - if (driver.isIOS) { - throw new Error('Migration setup should run on Android only.'); - } - - const { mnemonic, balance } = await setupWalletWithLegacyFunds({ returnSeed: true }); - writeMigrationEnvFile({ - fileName: 'migration_setup_sweep.env', - mnemonicVar: 'RN_MNEMONIC', - balanceVar: 'RN_BALANCE', - mnemonic, - balance, - }); - // Wait for backup propagation before env artifact upload - console.info('→ Waiting 40 seconds to ensure backups...'); - await sleep(40_000); - }); + ciIt( + `@migration_setup_passphrase - Prepare legacy wallet ${process.env.MIGRATION_SETUP_WALLET ?? '1'} with passphrase (Android only)`, + async () => { + if (driver.isIOS) { + throw new Error('Migration setup should run on Android only.'); + } + + const { mnemonic, balance } = await setupLegacyWallet({ + returnSeed: true, + passphrase: TEST_PASSPHRASE, + }); + writeMigrationEnvFile({ + fileName: 'migration_setup_passphrase.env', + mnemonicVar: 'RN_MNEMONIC', + balanceVar: 'RN_BALANCE', + mnemonic, + balance, + }); + } + ); - ciIt('@migration_ios - setupLegacyWallet on iOS', async () => { - // Setup wallet in RN app - const { mnemonic, balance } = await setupLegacyWallet({ returnSeed: true }); - console.info(`→ MNEMONIC: ${mnemonic}`); - console.info(`→ BALANCE: ${balance}`); - }); + ciIt( + `@migration_setup_sweep - Prepare legacy sweep wallet ${process.env.MIGRATION_SETUP_WALLET ?? '1'} (Android only)`, + async () => { + if (driver.isIOS) { + throw new Error('Migration setup should run on Android only.'); + } + + const { mnemonic, balance } = await setupWalletWithLegacyFunds({ returnSeed: true }); + writeMigrationEnvFile({ + fileName: 'migration_setup_sweep.env', + mnemonicVar: 'RN_MNEMONIC', + balanceVar: 'RN_BALANCE', + mnemonic, + balance, + }); + } + ); + } // -------------------------------------------------------------------------- // Migration Scenario 1: Uninstall RN, install Native, restore mnemonic // -------------------------------------------------------------------------- - ciIt('@migration_1 - Uninstall RN, install Native, restore mnemonic', async () => { + ciIt('@migration_rn_restore - Uninstall RN, install Native, restore mnemonic', async () => { let mnemonic: string | undefined; let balance: number; if (driver.isIOS) { - mnemonic = IOS_RN_MNEMONIC!; + if ( + !IOS_RN_MNEMONIC || + ![12, 24].includes(IOS_RN_MNEMONIC.trim().split(/\s+/).length) || + !Number.isFinite(IOS_RN_BALANCE) || + IOS_RN_BALANCE! <= 0 + ) { + throw new Error( + 'iOS RN restore requires a 12/24-word RN_MNEMONIC and positive RN_BALANCE from a fresh Android setup wallet.' + ); + } + mnemonic = IOS_RN_MNEMONIC; balance = IOS_RN_BALANCE!; } else { ({ mnemonic, balance } = await setupLegacyWallet({ returnSeed: true })); - // Tags/activity metadata are written AFTER getRnMnemonic()'s backup wait. - // Give remote backup time before wipe — otherwise migration_1 Tag-* asserts flake. - console.info('→ Waiting for RN metadata backup before uninstall...'); - await sleep(30_000); } // Uninstall RN app console.info('→ Removing legacy RN app...'); await driver.removeApp(getAppId()); - resetBootedIOSKeychain(); + resetBootedIOSKeychain({ strict: true }); // Install native app console.info(`→ Installing native app from: ${getNativeAppPath()}`); @@ -193,19 +202,25 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info('→ Waiting briefly for metadata restore before tag checks...'); await sleep(15_000); - // Verify migration - await verifyMigration(balance); + // Verify migration and state persisted by the target. + await verifyMigration(balance, { verifyTags: !driver.isIOS }); + await driver.terminateApp(getAppId()); + await driver.activateApp(getAppId()); + // RN migration can schedule this intro for the next launch. + await dismissBackgroundPaymentsTimedSheet(); + await verifyMigration(balance, { verifyTags: !driver.isIOS }); }); // -------------------------------------------------------------------------- // Migration Scenario 2: Install native on top of RN (upgrade) // -------------------------------------------------------------------------- - ciIt('@migration_2 - Install native on top of RN (upgrade)', async () => { + ciIt('@migration_rn_upgrade - Install native on top of RN (upgrade)', async () => { // Setup wallet in RN app const { balance } = await setupLegacyWallet(); // Install native app ON TOP of RN (upgrade) console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); + await driver.terminateApp(getAppId()); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); await driver.activateApp(getAppId()); @@ -213,51 +228,61 @@ describe('@migration - Migration from legacy RN app to native app', () => { // Handle migration flow await handleMigrationFlow({ withSweep: false }); - // Verify migration + // Verify migration and state persisted by the target. await verifyMigration(balance); - }); - - // -------------------------------------------------------------------------- - // Migration Scenario 3: Install native on top of RN with passphrase (upgrade) - // -------------------------------------------------------------------------- - ciIt('@migration_3 - Install native on top of RN with passphrase (upgrade)', async () => { - // Setup wallet in RN app WITH passphrase - const { balance } = await setupLegacyWallet({ passphrase: TEST_PASSPHRASE }); - - // Install native app ON TOP of RN (upgrade) - console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); - await driver.installApp(getNativeAppPath()); - grantIOSCameraPermission(); + await driver.terminateApp(getAppId()); await driver.activateApp(getAppId()); - - // Handle migration flow - await handleMigrationFlow({ withSweep: false }); - - // Verify migration + // RN migration can schedule this intro for the next launch. + await dismissBackgroundPaymentsTimedSheet(); await verifyMigration(balance); }); // -------------------------------------------------------------------------- - // Migration Scenario 4: Migration with sweep (legacy p2pkh addresses) - // This scenario tests migration when wallet has funds on legacy addresses, - // which triggers a sweep flow during migration. + // Migration Scenario 3: Install native on top of RN with passphrase (upgrade) // -------------------------------------------------------------------------- - ciIt('@migration_4 - Migration (legacy p2pkh addresses)', async () => { - // Setup wallet with funds on legacy addresses (triggers sweep on migration) - const { balance } = await setupWalletWithLegacyFunds(); - - // Install native app ON TOP of RN (upgrade) - console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); - await driver.installApp(getNativeAppPath()); - grantIOSCameraPermission(); - await driver.activateApp(getAppId()); - - // Handle migration flow - await handleMigrationFlow({ withSweep: false }); + if (process.env.MIGRATION_EXTENDED === 'true') { + ciIt('@migration_3 - Install native on top of RN with passphrase (upgrade)', async () => { + // Setup wallet in RN app WITH passphrase + const { balance } = await setupLegacyWallet({ passphrase: TEST_PASSPHRASE }); + + // Install native app ON TOP of RN (upgrade) + console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); + await driver.installApp(getNativeAppPath()); + grantIOSCameraPermission(); + await driver.activateApp(getAppId()); + + // Handle migration flow + await handleMigrationFlow({ withSweep: false }); + + // Verify migration and state persisted by the target. + await verifyMigration(balance); + await driver.terminateApp(getAppId()); + await driver.activateApp(getAppId()); + await verifyMigration(balance); + }); - // Verify migration completed (balance should be preserved after sweep, minus fees) - await verifyMigrationWithSweep(balance); - }); + // -------------------------------------------------------------------------- + // Migration Scenario 4: Migration with sweep (legacy p2pkh addresses) + // This scenario tests migration when wallet has funds on legacy addresses, + // which triggers a sweep flow during migration. + // -------------------------------------------------------------------------- + ciIt('@migration_4 - Migration (legacy p2pkh addresses)', async () => { + // Setup wallet with funds on legacy addresses (triggers sweep on migration) + const { balance } = await setupWalletWithLegacyFunds(); + + // Install native app ON TOP of RN (upgrade) + console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); + await driver.installApp(getNativeAppPath()); + grantIOSCameraPermission(); + await driver.activateApp(getAppId()); + + // Handle migration flow + await handleMigrationFlow({ withSweep: false }); + + // Verify migration completed (balance should be preserved after sweep, minus fees) + await verifyMigrationWithSweep(balance); + }); + } }); // ============================================================================ @@ -319,7 +344,6 @@ async function setupLegacyWallet( console.info( `=== iOS: Restoring RN wallet from mnemonic (prepared by Android)${passphrase ? ' with passphrase' : ''} ===` ); - console.info(`→ Mnemonic: ${IOS_RN_MNEMONIC}`); console.info(`→ Expected balance: ${IOS_RN_BALANCE} sats`); // Install RN app and restore wallet @@ -347,7 +371,6 @@ async function setupLegacyWallet( let mnemonic: string | undefined; if (returnSeed) { mnemonic = await getRnMnemonic(); - console.info(`→ Legacy RN wallet mnemonic: ${mnemonic}`); } // Set legacy address type if requested (before funding) @@ -377,10 +400,10 @@ async function setupLegacyWallet( // Get final balance before migration const balance = await getRnTotalBalance(); + await waitForRnBackup(); console.info('=== Legacy wallet setup complete ==='); // Output for iOS CI to capture - console.info(`\nexport RN_MNEMONIC="${mnemonic}"`); console.info(`export RN_BALANCE="${balance}"\n`); return { mnemonic, balance }; @@ -412,7 +435,6 @@ function writeMigrationEnvFile({ fs.writeFileSync(filePath, contents, 'utf8'); console.info(`→ Wrote migration env file: ${filePath}`); - console.info(`\nexport ${mnemonicVar}="${mnemonic}"`); console.info(`export ${balanceVar}="${balance}"\n`); } @@ -444,7 +466,6 @@ async function setupWalletWithLegacyFunds( ); } console.info('=== iOS: Restoring RN sweep wallet from mnemonic (prepared by Android) ==='); - console.info(`→ Mnemonic: ${IOS_RN_MNEMONIC}`); console.info(`→ Expected balance: ${IOS_RN_BALANCE} sats`); await installLegacyRnApp(); @@ -464,7 +485,6 @@ async function setupWalletWithLegacyFunds( let mnemonic: string | undefined; if (returnSeed) { mnemonic = await getRnMnemonic(); - console.info(`→ Legacy RN sweep wallet mnemonic: ${mnemonic}`); } // 1. Fund wallet on native segwit (works with Blocktank) @@ -482,6 +502,7 @@ async function setupWalletWithLegacyFunds( // Get final balance before migration const balance = await getRnTotalBalance(); + await waitForRnBackup(false); console.info('=== Legacy funds setup complete ==='); return { balance, mnemonic }; @@ -598,7 +619,7 @@ async function verifyMigrationWithSweep(expectedBalance: number): Promise async function installLegacyRnApp(): Promise { console.info(`→ Installing legacy RN app from: ${getRnAppPath()}`); - await reinstallAppFromPath(getRnAppPath()); + await reinstallAppFromPath(getRnAppPath(), getAppId(), { strictKeychainReset: true }); } async function createLegacyRnWallet(options: { passphrase?: string } = {}): Promise { @@ -893,42 +914,6 @@ async function transferToSpendingRN(sats: number, existingBalance = 0): Promise< console.info(`→ Created spending balance with ${sats} sats`); } -// @ts-expect-error - Kept for future use -async function createCJIT(sats: number): Promise { - await tap('Receive'); - await tap('ReceiveInstantlySwitch'); - - // Enter amount - await sleep(500); - const satsStr = String(sats); - for (const digit of satsStr) { - await tap(`N${digit}`); - } - await tap('ReceiveAmountContinue'); - await sleep(1000); - await tap('ReceiveConnectContinue'); - await sleep(2000); - const address = await getUriFromQRCode(); - await sleep(5000); - const tx = await payInvoice(address); - console.info(`→ Created CJIT invoice and paid: ${tx}`); - await sleep(2000); - await swipeFullScreenRN('down'); - // Mine blocks periodically to progress the channel opening - console.info('→ Mining blocks to confirm channel...'); - for (let i = 0; i < 10; i++) { - await mineBlocks(1); - // Check if spending balance shows the transferred amount (transfer complete) - try { - await elementById('TransferSuccess-button').waitForDisplayed(); - await sleep(1000); - break; - } catch { - console.info('→ Transfer successful screen did not appear, waiting...'); - } - } -} - /** * Tag the latest (most recent) transaction in the activity list. * @@ -945,10 +930,12 @@ async function tagLatestTransaction(tag: string): Promise { try { await elementById('TotalBalance').waitForDisplayed({ timeout: 10_000 }); } catch { - console.info('→ TotalBalance not immediately visible, scrolling down to find it...'); - await swipeFullScreenRN('down'); - await swipeFullScreenRN('down'); - await elementById('TotalBalance').waitForDisplayed({ timeout: 10_000 }); + console.info('→ TotalBalance not immediately visible, reopening the RN home screen...'); + // A downward full-screen gesture can open Android's notification shade and + // poison every subsequent retry. Relaunching returns the wallet to Home. + await driver.terminateApp(getAppId()); + await driver.activateApp(getAppId()); + await elementById('TotalBalance').waitForDisplayed({ timeout: 30_000 }); } // Try to find ActivityShort-1, scroll if needed @@ -1060,13 +1047,55 @@ async function assertRnSpendingBalanceVisible(expectedSats: number): Promise { + await tap('HeaderMenu'); + await tap('DrawerSettings'); + await tap('BackupSettings'); + const categories = [ + ...(requireConnections ? ['Connections'] : []), + 'Connection Receipts', + 'Transaction Log', + 'Boosts & Transfers', + 'Settings', + 'Widgets', + 'Tags', + 'Contacts', + ]; + const statuses: Record = {}; + // RN release builds omit the E2E-only AllSynced badge. Each visible row uses + // Latest Backup only when it is not running and synced >= required. + try { + await driver.waitUntil( + async () => { + for (const category of categories) { + const status = $( + `//android.widget.TextView[@text="${category}"]/following-sibling::android.widget.TextView[1]` + ); + statuses[category] = (await status.isExisting()) ? await status.getText() : 'missing'; + } + return categories.every((category) => statuses[category].startsWith('Latest Backup: ')); + }, + { timeout: 90_000, interval: 2_000, timeoutMsg: 'RN source backups did not finish syncing' } + ); + } catch (error) { + throw new Error(`RN backup statuses: ${JSON.stringify(statuses)}`, { cause: error }); + } + await driver.back(); + await driver.back(); + await elementById('TotalBalance').waitForDisplayed(); + console.info('→ RN source backups confirmed:', statuses); } /** @@ -1099,7 +1128,6 @@ async function getRnMnemonic(): Promise { const seed = await getAccessibleText(seedElement); if (!seed) throw new Error('Could not read seed from "SeedContaider"'); - console.info(`→ RN mnemonic retrieved: ${seed}`); // Close mnemonic sheet using back button - more reliable than swipe for RN await dismissSheetRN(); // Wait for backup to be performed @@ -1115,7 +1143,6 @@ async function getRnMnemonic(): Promise { return seed; } - /** * Open the activity tag filter and wait for a specific tag chip. * Retries because migration_1 mnemonic restore syncs balance before tag metadata. @@ -1123,9 +1150,9 @@ async function getRnMnemonic(): Promise { async function openTagFilter(tag: string): Promise { const tagId = `Tag-${tag}`; for (let attempt = 1; attempt <= 5; attempt++) { + await tap('TagsPrompt'); + await sleep(500); try { - await tap('TagsPrompt'); - await sleep(500); await elementById(tagId).waitForDisplayed({ timeout: 12_000 }); return; } catch (error) { @@ -1133,17 +1160,25 @@ async function openTagFilter(tag: string): Promise { `→ ${tagId} not ready in TagsPrompt (attempt ${attempt}/5); waiting for metadata sync...`, error ); - try { + if (driver.isIOS) { + await swipeFullScreen('down'); + } else { await driver.back(); - } catch { - // sheet may already be closed } - await sleep(5_000); + const retryWaitStarted = Date.now(); + await elementById('TagsPrompt').waitForDisplayed({ + timeout: 5_000, + timeoutMsg: 'Tag selector did not close before metadata retry', + }); + await sleep(Math.max(0, 5_000 - (Date.now() - retryWaitStarted))); } } await tap('TagsPrompt'); await sleep(500); - await elementById(tagId).waitForDisplayed({ timeout: 30_000 }); + await elementById(tagId).waitForDisplayed({ + timeout: 30_000, + timeoutMsg: `Migrated tag ${tagId} did not restore after metadata retries`, + }); } // ============================================================================ @@ -1154,20 +1189,17 @@ async function openTagFilter(tag: string): Promise { * Verify migration was successful * @param expectedBalance - The balance from the RN app before migration */ -async function verifyMigration(expectedBalance: number): Promise { +async function verifyMigration( + expectedBalance: number, + { verifyTags = true }: { verifyTags?: boolean } = {} +): Promise { console.info('=== Verifying migration ==='); - // Verify we have balance (should match what we funded) - const totalBalanceEl = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); - const balanceText = await totalBalanceEl.getText(); - const actualBalance = parseInt(balanceText.replace(/\s/g, ''), 10); - console.info(`→ Total balance: ${actualBalance} sats (expected: ${expectedBalance})`); - - // Verify balance matches - if (actualBalance !== expectedBalance) { - throw new Error(`Balance mismatch! Expected ${expectedBalance}, got ${actualBalance}`); - } - console.info('→ Balance migrated successfully'); + await expectMigrationBalances({ + total: expectedBalance, + spending: TRANSFER_TO_SPENDING_SATS, + savings: expectedBalance - TRANSFER_TO_SPENDING_SATS, + }); // Go to activity list to verify transactions exist await tap('ActivityShowAll'); @@ -1193,22 +1225,24 @@ async function verifyMigration(expectedBalance: number): Promise { await expectTextWithin('Activity-1', '-'); // Transfer shows here await elementById('Activity-2').waitForDisplayed({ reverse: true }); - // filter by receive tag (metadata restore can lag Electrum balance after mnemonic restore) - await tap('Tab-all'); - await openTagFilter(TAG_RECEIVED); - await tap(`Tag-${TAG_RECEIVED}`); - await expectTextWithin('Activity-1', '+'); // Only received tx has this tag - await elementById('Activity-2').waitForDisplayed({ reverse: true }); - await tap(`Tag-${TAG_RECEIVED}-delete`); - - // filter by send tag - await openTagFilter(TAG_SENT); - await tap(`Tag-${TAG_SENT}`); - await expectTextWithin('Activity-1', '-'); // Only sent tx has this tag (not Transfer) - await elementById('Activity-2').waitForDisplayed({ reverse: true }); - await tap(`Tag-${TAG_SENT}-delete`); - - console.info('→ Activity tags migrated successfully'); + if (verifyTags) { + // filter by receive tag (metadata restore can lag Electrum balance after mnemonic restore) + await tap('Tab-all'); + await openTagFilter(TAG_RECEIVED); + await tap(`Tag-${TAG_RECEIVED}`); + await expectTextWithin('Activity-1', '+'); // Only received tx has this tag + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + await tap(`Tag-${TAG_RECEIVED}-delete`); + + // filter by send tag + await openTagFilter(TAG_SENT); + await tap(`Tag-${TAG_SENT}`); + await expectTextWithin('Activity-1', '-'); // Only sent tx has this tag (not Transfer) + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + await tap(`Tag-${TAG_SENT}-delete`); + + console.info('→ Activity tags migrated successfully'); + } console.info('→ Transaction history migrated successfully'); await doNavigationClose(); diff --git a/wdio.conf.ts b/wdio.conf.ts index dc12f667..bdb546b7 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import { grantIOSCameraPermission } from './test/helpers/setup'; +const appiumPort = Number(process.env.APPIUM_PORT ?? '4723'); const isAndroid = process.env.PLATFORM === 'android'; const androidUdid = process.env.ANDROID_UDID; const androidDeviceName = process.env.ANDROID_DEVICE_NAME || 'Pixel_6'; @@ -28,7 +29,7 @@ export const config: WebdriverIO.Config = { runner: 'local', tsConfigPath: './tsconfig.json', - port: 4723, + port: appiumPort, // // ================== // Specify Test Files @@ -78,6 +79,9 @@ export const config: WebdriverIO.Config = { ? { platformName: 'Android', 'appium:automationName': 'UiAutomator2', + ...(process.env.ANDROID_SYSTEM_PORT + ? { 'appium:systemPort': Number(process.env.ANDROID_SYSTEM_PORT) } + : {}), ...(androidUdid ? { 'appium:udid': androidUdid } : {}), 'appium:deviceName': androidDeviceName, 'appium:platformVersion': androidPlatformVersion, @@ -90,6 +94,9 @@ export const config: WebdriverIO.Config = { : { platformName: 'iOS', 'appium:automationName': 'XCUITest', + ...(process.env.IOS_WDA_LOCAL_PORT + ? { 'appium:wdaLocalPort': Number(process.env.IOS_WDA_LOCAL_PORT) } + : {}), 'appium:udid': process.env.SIMULATOR_UDID || 'auto', 'appium:deviceName': iosDeviceName, ...(iosPlatformVersion ? { 'appium:platformVersion': iosPlatformVersion } : {}), @@ -163,7 +170,7 @@ export const config: WebdriverIO.Config = { // Services take over a specific job you don't want to take care of. They enhance // your test setup with almost no effort. Unlike plugins, they don't add new // commands. Instead, they hook themselves up into the test process. - services: ['appium'], + services: [['appium', { args: { port: appiumPort } }]], // Framework you want to run your specs with. // The following are supported: Mocha, Jasmine, and Cucumber