diff --git a/AGENTS.md b/AGENTS.md index 0094909..6f1d73b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,3 +142,4 @@ Implication for feature work: - The tests expect built artifacts in `./aut`. - Use `ciIt()` in specs (not `it()`) to enable CI retry-skipping behavior. - Keep Android/iOS platform differences behind helpers in `test/helpers/`. +- Prefer extracting shared flows into `test/helpers/` over copying logic between specs. Keep helpers small and reuse existing ones before adding new test code. diff --git a/scripts/build-android-apk.sh b/scripts/build-android-apk.sh index 2120bb5..7d6dfa1 100755 --- a/scripts/build-android-apk.sh +++ b/scripts/build-android-apk.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash -# Build the Bitkit Android dev debug APK from ../bitkit-android and copy into aut/ +# Build the Bitkit Android debug APK from ../bitkit-android and copy into aut/ # # Inputs/roots: # - E2E root: this repo (bitkit-e2e-tests) # - Android root: ../bitkit-android (resolved relative to this script) # # Output: -# - Copies dev debug APK -> aut/bitkit_e2e.apk +# - Copies dev/regtest debug APK -> aut/bitkit_e2e.apk +# - Copies mainnet debug APK -> aut/bitkit_e2e_mainnet.apk # # Requirements: # - Android SDK/NDK as required by the project, Gradle wrapper @@ -14,6 +15,7 @@ # Usage: # ./scripts/build-android-apk.sh # BACKEND=regtest ./scripts/build-android-apk.sh +# BACKEND=mainnet ./scripts/build-android-apk.sh set -euo pipefail E2E_ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -23,16 +25,22 @@ BACKEND="${BACKEND:-local}" E2E_BACKEND="local" GRADLE_TASK="assembleDevDebug" APK_FLAVOR_DIR="dev/debug" +OUT_FILENAME="bitkit_e2e.apk" if [[ "$BACKEND" == "regtest" ]]; then E2E_BACKEND="network" elif [[ "$BACKEND" == "local" ]]; then E2E_BACKEND="local" +elif [[ "$BACKEND" == "mainnet" ]]; then + E2E_BACKEND="network" + GRADLE_TASK="assembleMainnetDebug" + APK_FLAVOR_DIR="mainnet/debug" + OUT_FILENAME="bitkit_e2e_mainnet.apk" else echo "ERROR: Unsupported BACKEND value: $BACKEND" >&2 exit 1 fi -echo "Building Android APK (BACKEND=$BACKEND, E2E_BACKEND=$E2E_BACKEND)..." +echo "Building Android APK (BACKEND=$BACKEND, E2E_BACKEND=$E2E_BACKEND, GRADLE_TASK=$GRADLE_TASK)..." pushd "$ANDROID_ROOT" >/dev/null E2E=true E2E_BACKEND="$E2E_BACKEND" ./gradlew "$GRADLE_TASK" --no-daemon --stacktrace @@ -50,5 +58,5 @@ fi OUT="$E2E_ROOT/aut" mkdir -p "$OUT" -cp -f "$APK_PATH" "$OUT/bitkit_e2e.apk" -echo "Android APK copied to: $OUT/bitkit_e2e.apk (from $(basename "$APK_PATH"))" +cp -f "$APK_PATH" "$OUT/$OUT_FILENAME" +echo "Android APK copied to: $OUT/$OUT_FILENAME (from $(basename "$APK_PATH"))" diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 68f302a..c7fe896 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -733,7 +733,7 @@ export async function restoreWallet( // Wait for Get Started const getStarted = await elementById('GetStartedButton'); - await getStarted.waitForDisplayed({ timeout: 120000 }); + await getStarted.waitForDisplayed({ timeout: 180000 }); await tap('GetStartedButton'); await sleep(1000); if (expectAndroidAlert) { diff --git a/test/helpers/mainnet.ts b/test/helpers/mainnet.ts new file mode 100644 index 0000000..f12ba87 --- /dev/null +++ b/test/helpers/mainnet.ts @@ -0,0 +1,54 @@ +import { + doNavigationClose, + elementById, + expectTextWithin, + sleep, + tap, +} from './actions'; + +const WALLET_SYNC_TIMEOUT_MS = 90_000; +const APP_STATUS_ROW_TIMEOUT_MS = 90_000; + +type WaitForMainnetWalletReadyOptions = { + logPrefix: string; +}; + +export async function waitForMainnetWalletReady({ + logPrefix, +}: WaitForMainnetWalletReadyOptions): Promise { + console.info(`→ [${logPrefix}] Waiting for wallet home screen...`); + await elementById('TotalBalance-primary').waitForDisplayed({ timeout: WALLET_SYNC_TIMEOUT_MS }); + + const stabilizeMs = resolveLnStabilizeDelayMs(); + console.info( + `→ [${logPrefix}] Home screen ready, letting LN node stabilize (${stabilizeMs / 1000}s)...` + ); + await sleep(stabilizeMs); + + console.info(`→ [${logPrefix}] Verify app status is ready`); + await tap('HeaderMenu'); + await tap('DrawerAppStatus'); + + await expectTextWithin('Status-internet', 'Connected', { timeout: APP_STATUS_ROW_TIMEOUT_MS }); + await expectTextWithin('Status-electrum', 'Connected', { timeout: APP_STATUS_ROW_TIMEOUT_MS }); + await expectTextWithin('Status-lightning_node', 'Running', { + timeout: APP_STATUS_ROW_TIMEOUT_MS, + }); + await expectTextWithin('Status-lightning_connection', 'Open', { + timeout: APP_STATUS_ROW_TIMEOUT_MS, + }); + + await doNavigationClose(); + console.info(`→ [${logPrefix}] App status verified`); +} + +function resolveLnStabilizeDelayMs(): number { + const fromEnv = process.env.LN_STABILIZE_DELAY_MS; + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return process.env.CI ? 45_000 : 10_000; +} diff --git a/test/helpers/probe.ts b/test/helpers/probe.ts new file mode 100644 index 0000000..e484f58 --- /dev/null +++ b/test/helpers/probe.ts @@ -0,0 +1,304 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { getAppId } from './constants'; + +export type ProbeTargetType = 'lightningAddress' | 'lnurlCallback'; + +export type ProbeTarget = { + name: string; + type: ProbeTargetType; + required?: boolean; + amountMsat?: number; + amountsMsat?: number[]; + address?: string; + url?: string; +}; + +export type ProbeResult = { + targetName: string; + targetType: ProbeTargetType; + amountMsat: number; + amountSats: number; + required: boolean; + attempt: number; + retries: number; + invoiceFetched: boolean; + success: boolean; + durationMs: number; + bolt11?: string; + rawProviderResult?: string; + error?: string; +}; + +type LnurlPayResponse = { + callback?: string; + minSendable?: number; + maxSendable?: number; + status?: string; + reason?: string; +}; + +type LnurlInvoiceResponse = { + pr?: string; + status?: string; + reason?: string; +}; + +const DEFAULT_PROBE_TIMEOUT_SECONDS = 90; + +export function resolveProbeTargets(): ProbeTarget[] { + const raw = process.env.PROBE_TARGETS_JSON; + if (!raw) { + throw new Error('Missing PROBE_TARGETS_JSON env var'); + } + + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error('PROBE_TARGETS_JSON must be a JSON array'); + } + + return parsed.map(parseProbeTarget); +} + +export function expandProbeTargetAmounts(target: ProbeTarget): number[] { + const amounts = target.amountsMsat ?? (target.amountMsat ? [target.amountMsat] : []); + if (amounts.length === 0) { + throw new Error(`Probe target '${target.name}' must define amountMsat or amountsMsat`); + } + + return amounts.map((amountMsat) => { + if (!Number.isInteger(amountMsat) || amountMsat <= 0) { + throw new Error(`Probe target '${target.name}' has invalid amountMsat '${amountMsat}'`); + } + if (amountMsat % 1000 !== 0) { + throw new Error( + `Probe target '${target.name}' amountMsat must be whole sats: '${amountMsat}'` + ); + } + return amountMsat; + }); +} + +export async function fetchBolt11ForProbe( + target: ProbeTarget, + amountMsat: number +): Promise { + const callback = + target.type === 'lightningAddress' ? await fetchLightningAddressCallback(target) : target.url; + + if (!callback) { + throw new Error(`Probe target '${target.name}' is missing LNURL callback URL`); + } + + const url = new URL(callback); + url.searchParams.set('amount', amountMsat.toString()); + + const response = await fetchJson(url.toString()); + if (response.status?.toUpperCase() === 'ERROR') { + throw new Error(response.reason ?? `LNURL invoice request failed for '${target.name}'`); + } + if (!response.pr) { + throw new Error(`LNURL invoice response for '${target.name}' did not include pr`); + } + + return response.pr; +} + +export function runProbeCommand(target: ProbeTarget, amountMsat: number, bolt11: string): string { + const amountSats = amountMsat / 1000; + const method = process.env.PROBE_CONTENT_METHOD ?? 'probeInvoice'; + const timeoutSeconds = + parsePositiveIntEnv('PROBE_TIMEOUT_SECONDS') ?? DEFAULT_PROBE_TIMEOUT_SECONDS; + const payload = { + targetName: target.name, + bolt11, + amountMsat, + amountSats, + timeoutSeconds, + }; + const command = [ + 'content', + 'call', + '--uri', + shellQuote(`content://${getAppId()}.devtools`), + '--method', + shellQuote(method), + '--arg', + shellQuote(JSON.stringify(payload)), + ].join(' '); + + return execFileSync('adb', ['shell', command], { + encoding: 'utf8', + timeout: (timeoutSeconds + 10) * 1000, + }); +} + +export function parseProbeCommandSuccess(raw: string): boolean { + const result = extractContentCallResult(raw); + if (!result) return false; + + const parsed: unknown = JSON.parse(result); + if (typeof parsed !== 'object' || parsed === null) return false; + + if ('success' in parsed) return parsed.success === true; + if ('type' in parsed && typeof parsed.type === 'string') { + return parsed.type === 'Success' || parsed.type.endsWith('.ProbeSuccess'); + } + + return false; +} + +export function summarizeProbeCommandFailure(raw: string): string { + const result = extractContentCallResult(raw); + if (result) { + try { + const parsed: unknown = JSON.parse(result); + if (typeof parsed === 'object' && parsed !== null && 'message' in parsed) { + const message = parsed.message; + if (typeof message === 'string' && message.length > 0) return message; + } + } catch { + return 'Probe command returned an unparseable result'; + } + } + + const adbError = raw.match(/\[ERROR\]\s*(.+)/); + return adbError?.[1]?.trim() || 'Probe command returned a failed result'; +} + +export function writeProbeArtifacts(results: ProbeResult[]): void { + const artifactsDir = resolveArtifactsDir(); + fs.mkdirSync(artifactsDir, { recursive: true }); + + const jsonPath = path.join(artifactsDir, 'probe-results.json'); + const reportPath = path.join(artifactsDir, 'probe-report.md'); + const report = renderProbeReport(results); + + fs.writeFileSync(jsonPath, `${JSON.stringify(results, null, 2)}\n`); + fs.writeFileSync(reportPath, report); + + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n## Attempt ${resolveAttempt()}\n\n${report}\n`); + } +} + +export function renderProbeReport(results: ProbeResult[]): string { + const failedRequired = results.filter((it) => it.required && !it.success); + const lines = [ + '# Lightning Probe Report', + '', + `Required failures: ${failedRequired.length}`, + '', + '| Target | Amount sats | Required | Invoice | Probe | Retries | Duration ms | Error |', + '| --- | ---: | --- | --- | --- | ---: | ---: | --- |', + ]; + + for (const result of results) { + lines.push( + `| ${[ + result.targetName, + result.amountSats.toString(), + result.required ? 'yes' : 'no', + result.invoiceFetched ? 'ok' : 'failed', + result.success ? '✅' : '❌', + result.retries.toString(), + result.durationMs.toString(), + sanitizeMarkdownCell(result.error ?? ''), + ].join(' | ')} |` + ); + } + + return `${lines.join('\n')}\n`; +} + +function parseProbeTarget(value: unknown): ProbeTarget { + if (typeof value !== 'object' || value === null) { + throw new Error('Each probe target must be an object'); + } + + const target = value as Partial; + if (!target.name || typeof target.name !== 'string') { + throw new Error('Each probe target must define a string name'); + } + if (target.type !== 'lightningAddress' && target.type !== 'lnurlCallback') { + throw new Error(`Probe target '${target.name}' has unsupported type '${target.type}'`); + } + if (target.type === 'lightningAddress' && !target.address) { + throw new Error(`Probe target '${target.name}' must define address`); + } + if (target.type === 'lnurlCallback' && !target.url) { + throw new Error(`Probe target '${target.name}' must define url`); + } + + return { + name: target.name, + type: target.type, + required: target.required ?? true, + amountMsat: target.amountMsat, + amountsMsat: target.amountsMsat, + address: target.address, + url: target.url, + }; +} + +async function fetchLightningAddressCallback(target: ProbeTarget): Promise { + const address = target.address ?? ''; + const [username, domain] = address.split('@'); + if (!username || !domain) { + throw new Error(`Invalid Lightning Address for '${target.name}': '${address}'`); + } + + const metadataUrl = `https://${domain}/.well-known/lnurlp/${encodeURIComponent(username)}`; + const response = await fetchJson(metadataUrl); + if (response.status?.toUpperCase() === 'ERROR') { + throw new Error(response.reason ?? `LNURL metadata request failed for '${target.name}'`); + } + if (!response.callback) { + throw new Error(`LNURL metadata for '${target.name}' did not include callback`); + } + + return response.callback; +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url}`); + } + + return (await response.json()) as T; +} + +function extractContentCallResult(raw: string): string | null { + return raw.match(/result=(\{[\s\S]*\})\}\]\s*$/)?.[1] ?? null; +} + +function parsePositiveIntEnv(name: string): number | null { + const raw = process.env[name]; + if (!raw) return null; + + const value = Number.parseInt(raw, 10); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Invalid ${name} value: ${raw}`); + } + return value; +} + +function resolveArtifactsDir(): string { + const attempt = process.env.ATTEMPT; + return attempt ? path.join('artifacts', `attempt-${attempt}`) : 'artifacts'; +} + +function resolveAttempt(): string { + return process.env.ATTEMPT ?? '1'; +} + +function sanitizeMarkdownCell(value: string): string { + return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim(); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/test/specs/mainnet/ln.e2e.ts b/test/specs/mainnet/ln.e2e.ts index 4717221..9268145 100644 --- a/test/specs/mainnet/ln.e2e.ts +++ b/test/specs/mainnet/ln.e2e.ts @@ -6,27 +6,13 @@ import { restoreWallet, tap, sleep, - expectTextWithin, - doNavigationClose, } from '../../helpers/actions'; +import { waitForMainnetWalletReady } from '../../helpers/mainnet'; import { ciIt } from '../../helpers/suite'; const PAYMENT_TIMEOUT_MS = 90_000; -const WALLET_SYNC_TIMEOUT_MS = 90_000; -const APP_STATUS_ROW_TIMEOUT_MS = 90_000; const SCREEN_TRANSITION_TIMEOUT_MS = 30_000; -function resolveLnStabilizeDelayMs(): number { - const fromEnv = process.env.LN_STABILIZE_DELAY_MS; - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10); - if (Number.isFinite(parsed) && parsed >= 0) { - return parsed; - } - } - return process.env.CI ? 45_000 : 10_000; -} - const ERROR_TOASTS = ['PaymentFailedToast', 'ExpiredLightningToast', 'InsufficientSpendingToast']; type MainnetLnSuiteConfig = { @@ -72,29 +58,6 @@ function resolveMainnetLnReceiver(config: MainnetLnSuiteConfig): MainnetLnReceiv }; } -async function waitForWalletReady(): Promise { - console.info('→ [LN] Waiting for wallet home screen...'); - await elementById('TotalBalance-primary').waitForDisplayed({ timeout: WALLET_SYNC_TIMEOUT_MS }); - const stabilizeMs = resolveLnStabilizeDelayMs(); - console.info(`→ [LN] Home screen ready, letting LN node stabilize (${stabilizeMs / 1000}s)...`); - await sleep(stabilizeMs); - console.info('→ [LN] Verify app status is ready'); - await tap('HeaderMenu'); - await tap('DrawerAppStatus'); - - await expectTextWithin('Status-internet', 'Connected', { timeout: APP_STATUS_ROW_TIMEOUT_MS }); - await expectTextWithin('Status-electrum', 'Connected', { timeout: APP_STATUS_ROW_TIMEOUT_MS }); - await expectTextWithin('Status-lightning_node', 'Running', { - timeout: APP_STATUS_ROW_TIMEOUT_MS, - }); - await expectTextWithin('Status-lightning_connection', 'Open', { - timeout: APP_STATUS_ROW_TIMEOUT_MS, - }); - - await doNavigationClose(); - console.info('→ [LN] App status verified'); -} - async function waitForAmountScreen(): Promise { console.info('→ [LN] Waiting for amount entry screen...'); await elementById('N0').waitForDisplayed({ timeout: SCREEN_TRANSITION_TIMEOUT_MS }); @@ -145,7 +108,7 @@ async function sendPaymentToLnAddress(receiver: MainnetLnReceiver): Promise= 0) { + return parsed; + } + } + return DEFAULT_PROBE_DELAY_MS; +} + +function resolveProbeRetries(): number { + return resolveNonNegativeIntEnv('PROBE_RETRIES') ?? DEFAULT_PROBE_RETRIES; +} + +function resolveProbeRetryDelayMs(): number { + return resolveNonNegativeIntEnv('PROBE_RETRY_DELAY_MS') ?? DEFAULT_PROBE_RETRY_DELAY_MS; +} + +async function runProbe(target: ProbeTarget, amountMsat: number): Promise { + const startedAt = Date.now(); + const amountSats = amountMsat / 1000; + const baseResult = { + targetName: target.name, + targetType: target.type, + amountMsat, + amountSats, + required: target.required ?? true, + attempt: Number.parseInt(process.env.ATTEMPT ?? '1', 10), + }; + + let bolt11: string | undefined; + try { + console.info(`→ [Probe] Fetching invoice for '${target.name}' (${amountSats} sats)...`); + bolt11 = await fetchBolt11ForProbe(target, amountMsat); + } catch (error) { + return { + ...baseResult, + retries: 0, + invoiceFetched: false, + success: false, + durationMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + }; + } + + const maxRetries = resolveProbeRetries(); + const retryDelayMs = resolveProbeRetryDelayMs(); + let lastRawProviderResult = ''; + let lastError = 'Probe command returned a failed result'; + + for (let retry = 0; retry <= maxRetries; retry++) { + try { + console.info( + `→ [Probe] Probing '${target.name}' (${amountSats} sats), attempt ${retry + 1}/${ + maxRetries + 1 + }...` + ); + const rawProviderResult = runProbeCommand(target, amountMsat, bolt11); + const success = parseProbeCommandSuccess(rawProviderResult); + + if (success) { + return { + ...baseResult, + retries: retry, + invoiceFetched: true, + success: true, + durationMs: Date.now() - startedAt, + bolt11, + rawProviderResult, + }; + } + + lastRawProviderResult = rawProviderResult; + lastError = summarizeProbeCommandFailure(rawProviderResult); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + + if (retry < maxRetries && retryDelayMs > 0) { + console.info(`→ [Probe] Retrying '${target.name}' in ${retryDelayMs / 1000}s...`); + await sleep(retryDelayMs); + } + } + + return { + ...baseResult, + retries: maxRetries, + invoiceFetched: true, + success: false, + durationMs: Date.now() - startedAt, + bolt11, + rawProviderResult: lastRawProviderResult, + error: lastError, + }; +} + +describe('@probe_mainnet - Lightning probe smoke', () => { + let probeSeed: string; + let targets: ProbeTarget[]; + + before(() => { + probeSeed = resolveEnvValue('PROBE_SEED'); + targets = resolveProbeTargets(); + }); + + ciIt('@probe_mainnet_1 - Can probe configured mainnet LNURL targets', async () => { + const results: ProbeResult[] = []; + + try { + console.info('→ [Probe] Restoring probe wallet...'); + await restoreWallet(probeSeed, { + expectBackupSheet: false, + reinstall: false, + expectAndroidAlert: false, + }); + await waitForMainnetWalletReady({ logPrefix: 'Probe' }); + + const probes = targets.flatMap((target) => + expandProbeTargetAmounts(target).map((amountMsat) => ({ target, amountMsat })) + ); + const probeDelayMs = resolveProbeDelayMs(); + const probeRetries = resolveProbeRetries(); + console.info(`→ [Probe] Probe retries configured: ${probeRetries}`); + + for (const [index, { target, amountMsat }] of probes.entries()) { + const result = await runProbe(target, amountMsat); + results.push(result); + console.info( + `→ [Probe] ${result.targetName} ${result.amountSats} sats: ${ + result.success ? '✅ success' : `❌ failed (${result.error ?? 'unknown'})` + }` + ); + + if (index < probes.length - 1 && probeDelayMs > 0) { + console.info(`→ [Probe] Waiting ${probeDelayMs / 1000}s before next probe...`); + await sleep(probeDelayMs); + } + } + } finally { + writeProbeArtifacts(results); + } + + const failedRequired = results.filter((it) => it.required && !it.success); + if (failedRequired.length > 0) { + throw new Error( + `Required probe targets failed: ${failedRequired + .map((it) => `${it.targetName}:${it.amountSats}`) + .join(', ')}` + ); + } + }); +}); + +function resolveNonNegativeIntEnv(name: string): number | null { + const raw = process.env[name]; + if (!raw) return null; + + const parsed = Number.parseInt(raw, 10); + if (Number.isInteger(parsed) && parsed >= 0) { + return parsed; + } + throw new Error(`Invalid ${name} value: ${raw}`); +} diff --git a/wdio.conf.ts b/wdio.conf.ts index 3d1bfb4..0be8d05 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -79,6 +79,7 @@ export const config: WebdriverIO.Config = { 'appium:platformVersion': androidPlatformVersion, 'appium:app': androidApp, 'appium:autoGrantPermissions': true, + 'appium:newCommandTimeout': 300, // 'appium:waitForIdleTimeout': 1000, } : {