From 9ac6ad1cd26b4674bfcd3226dbb6bfdc3cdef411 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 12 May 2026 12:41:40 +0200 Subject: [PATCH 1/9] feat: add mainnet probe tests --- test/helpers/probe.ts | 276 ++++++++++++++++++++++++++++++++ test/specs/mainnet/probe.e2e.ts | 153 ++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 test/helpers/probe.ts create mode 100644 test/specs/mainnet/probe.e2e.ts diff --git a/test/helpers/probe.ts b/test/helpers/probe.ts new file mode 100644 index 0000000..bae4765 --- /dev/null +++ b/test/helpers/probe.ts @@ -0,0 +1,276 @@ +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; + 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, + }; + + return execFileSync( + 'adb', + [ + 'shell', + 'content', + 'call', + '--uri', + `content://${getAppId()}.devtools`, + '--method', + method, + '--arg', + JSON.stringify(payload), + ], + { 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) return parsed.type === 'Success' || parsed.type === 'ProbeSuccess'; + + return false; +} + +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${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 | 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 ? 'ok' : 'failed', + 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 { + const match = raw.match(/result=({.*})\}?]?\s*$/s); + return match?.[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 sanitizeMarkdownCell(value: string): string { + return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim(); +} diff --git a/test/specs/mainnet/probe.e2e.ts b/test/specs/mainnet/probe.e2e.ts new file mode 100644 index 0000000..e6e47bc --- /dev/null +++ b/test/specs/mainnet/probe.e2e.ts @@ -0,0 +1,153 @@ +import { + doNavigationClose, + elementById, + expectTextWithin, + restoreWallet, + sleep, + tap, +} from '../../helpers/actions'; +import { + expandProbeTargetAmounts, + fetchBolt11ForProbe, + parseProbeCommandSuccess, + resolveProbeTargets, + runProbeCommand, + writeProbeArtifacts, + type ProbeResult, + type ProbeTarget, +} from '../../helpers/probe'; +import { ciIt } from '../../helpers/suite'; + +const WALLET_SYNC_TIMEOUT_MS = 90_000; +const APP_STATUS_ROW_TIMEOUT_MS = 90_000; + +function resolveEnvValue(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing ${name} env var`); + } + return value; +} + +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; +} + +async function waitForWalletReady(): Promise { + console.info('→ [Probe] Waiting for wallet home screen...'); + await elementById('TotalBalance-primary').waitForDisplayed({ timeout: WALLET_SYNC_TIMEOUT_MS }); + const stabilizeMs = resolveLnStabilizeDelayMs(); + console.info( + `→ [Probe] Home screen ready, letting LN node stabilize (${stabilizeMs / 1000}s)...` + ); + await sleep(stabilizeMs); + console.info('→ [Probe] 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('→ [Probe] App status verified'); +} + +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), + }; + + try { + console.info(`→ [Probe] Fetching invoice for '${target.name}' (${amountSats} sats)...`); + const bolt11 = await fetchBolt11ForProbe(target, amountMsat); + + console.info(`→ [Probe] Probing '${target.name}' (${amountSats} sats)...`); + const rawProviderResult = runProbeCommand(target, amountMsat, bolt11); + const success = parseProbeCommandSuccess(rawProviderResult); + + return { + ...baseResult, + invoiceFetched: true, + success, + durationMs: Date.now() - startedAt, + bolt11, + rawProviderResult, + error: success ? undefined : 'Probe command returned a failed result', + }; + } catch (error) { + return { + ...baseResult, + invoiceFetched: false, + success: false, + durationMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +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 waitForWalletReady(); + + for (const target of targets) { + for (const amountMsat of expandProbeTargetAmounts(target)) { + const result = await runProbe(target, amountMsat); + results.push(result); + console.info( + `→ [Probe] ${result.targetName} ${result.amountSats} sats: ${ + result.success ? 'success' : `failed (${result.error ?? 'unknown'})` + }` + ); + } + } + } 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(', ')}` + ); + } + }); +}); From e968e5ed70e89eafe8b3facfb64d1dad31d33fcf Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 12 May 2026 17:30:07 +0200 Subject: [PATCH 2/9] feat: adjust probing and add delay --- test/helpers/probe.ts | 60 ++++++++++++++++++++++----------- test/specs/mainnet/probe.e2e.ts | 41 ++++++++++++++++------ 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/test/helpers/probe.ts b/test/helpers/probe.ts index bae4765..4df195d 100644 --- a/test/helpers/probe.ts +++ b/test/helpers/probe.ts @@ -117,22 +117,21 @@ export function runProbeCommand(target: ProbeTarget, amountMsat: number, bolt11: amountSats, timeoutSeconds, }; - - return execFileSync( - 'adb', - [ - 'shell', - 'content', - 'call', - '--uri', - `content://${getAppId()}.devtools`, - '--method', - method, - '--arg', - JSON.stringify(payload), - ], - { encoding: 'utf8', timeout: (timeoutSeconds + 10) * 1000 } - ); + 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 { @@ -143,11 +142,31 @@ export function parseProbeCommandSuccess(raw: string): boolean { if (typeof parsed !== 'object' || parsed === null) return false; if ('success' in parsed) return parsed.success === true; - if ('type' in parsed) return parsed.type === 'Success' || parsed.type === 'ProbeSuccess'; + 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 }); @@ -251,8 +270,7 @@ async function fetchJson(url: string): Promise { } function extractContentCallResult(raw: string): string | null { - const match = raw.match(/result=({.*})\}?]?\s*$/s); - return match?.[1] ?? null; + return raw.match(/result=(\{[\s\S]*\})\}\]\s*$/)?.[1] ?? null; } function parsePositiveIntEnv(name: string): number | null { @@ -274,3 +292,7 @@ function resolveArtifactsDir(): string { 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/probe.e2e.ts b/test/specs/mainnet/probe.e2e.ts index e6e47bc..ffafba6 100644 --- a/test/specs/mainnet/probe.e2e.ts +++ b/test/specs/mainnet/probe.e2e.ts @@ -12,6 +12,7 @@ import { parseProbeCommandSuccess, resolveProbeTargets, runProbeCommand, + summarizeProbeCommandFailure, writeProbeArtifacts, type ProbeResult, type ProbeTarget, @@ -20,6 +21,7 @@ import { ciIt } from '../../helpers/suite'; const WALLET_SYNC_TIMEOUT_MS = 90_000; const APP_STATUS_ROW_TIMEOUT_MS = 90_000; +const DEFAULT_PROBE_DELAY_MS = 10_000; function resolveEnvValue(name: string): string { const value = process.env[name]; @@ -40,6 +42,17 @@ function resolveLnStabilizeDelayMs(): number { return process.env.CI ? 45_000 : 10_000; } +function resolveProbeDelayMs(): number { + const fromEnv = process.env.PROBE_DELAY_MS; + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return DEFAULT_PROBE_DELAY_MS; +} + async function waitForWalletReady(): Promise { console.info('→ [Probe] Waiting for wallet home screen...'); await elementById('TotalBalance-primary').waitForDisplayed({ timeout: WALLET_SYNC_TIMEOUT_MS }); @@ -92,7 +105,7 @@ async function runProbe(target: ProbeTarget, amountMsat: number): Promise { }); await waitForWalletReady(); - for (const target of targets) { - for (const amountMsat of expandProbeTargetAmounts(target)) { - const result = await runProbe(target, amountMsat); - results.push(result); - console.info( - `→ [Probe] ${result.targetName} ${result.amountSats} sats: ${ - result.success ? 'success' : `failed (${result.error ?? 'unknown'})` - }` - ); + const probes = targets.flatMap((target) => + expandProbeTargetAmounts(target).map((amountMsat) => ({ target, amountMsat })) + ); + const probeDelayMs = resolveProbeDelayMs(); + + 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 { From fe4a708534e03c3d836a8f6b6c7cee7cf79e74fd Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 13 May 2026 12:17:29 +0200 Subject: [PATCH 3/9] update build script --- scripts/build-android-apk.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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"))" From c32bcd1f1ccfaca7943f2d4d74b933a953bce199 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 13 May 2026 13:19:45 +0200 Subject: [PATCH 4/9] new command timeout wdio for android --- wdio.conf.ts | 1 + 1 file changed, 1 insertion(+) 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, } : { From e2d642f6722e4e8e623b653a19cdfca9223f0a4f Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 13 May 2026 13:20:03 +0200 Subject: [PATCH 5/9] chore: result log update --- test/specs/mainnet/probe.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/specs/mainnet/probe.e2e.ts b/test/specs/mainnet/probe.e2e.ts index ffafba6..8d1b545 100644 --- a/test/specs/mainnet/probe.e2e.ts +++ b/test/specs/mainnet/probe.e2e.ts @@ -149,7 +149,7 @@ describe('@probe_mainnet - Lightning probe smoke', () => { results.push(result); console.info( `→ [Probe] ${result.targetName} ${result.amountSats} sats: ${ - result.success ? 'success' : `failed (${result.error ?? 'unknown'})` + result.success ? '✅ success' : `❌ failed (${result.error ?? 'unknown'})` }` ); From a08f8611fed75b1a9c04592d08050f2fd3e8afb5 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 13 May 2026 14:21:19 +0200 Subject: [PATCH 6/9] chore: refactor --- AGENTS.md | 1 + test/helpers/mainnet.ts | 54 +++++++++++++++++++++++++++++++++ test/specs/mainnet/ln.e2e.ts | 41 ++----------------------- test/specs/mainnet/probe.e2e.ts | 50 ++---------------------------- 4 files changed, 60 insertions(+), 86 deletions(-) create mode 100644 test/helpers/mainnet.ts 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/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/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 process.env.CI ? 45_000 : 10_000; -} - function resolveProbeDelayMs(): number { const fromEnv = process.env.PROBE_DELAY_MS; if (fromEnv) { @@ -53,31 +34,6 @@ function resolveProbeDelayMs(): number { return DEFAULT_PROBE_DELAY_MS; } -async function waitForWalletReady(): Promise { - console.info('→ [Probe] Waiting for wallet home screen...'); - await elementById('TotalBalance-primary').waitForDisplayed({ timeout: WALLET_SYNC_TIMEOUT_MS }); - const stabilizeMs = resolveLnStabilizeDelayMs(); - console.info( - `→ [Probe] Home screen ready, letting LN node stabilize (${stabilizeMs / 1000}s)...` - ); - await sleep(stabilizeMs); - console.info('→ [Probe] 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('→ [Probe] App status verified'); -} - async function runProbe(target: ProbeTarget, amountMsat: number): Promise { const startedAt = Date.now(); const amountSats = amountMsat / 1000; @@ -137,7 +93,7 @@ describe('@probe_mainnet - Lightning probe smoke', () => { reinstall: false, expectAndroidAlert: false, }); - await waitForWalletReady(); + await waitForMainnetWalletReady({ logPrefix: 'Probe' }); const probes = targets.flatMap((target) => expandProbeTargetAmounts(target).map((amountMsat) => ({ target, amountMsat })) From 4b2a49d6fc9c2f76698f62a01a836d208951342f Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 13 May 2026 17:06:09 +0200 Subject: [PATCH 7/9] fix: include attempt in the report --- test/helpers/probe.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/helpers/probe.ts b/test/helpers/probe.ts index 4df195d..135259a 100644 --- a/test/helpers/probe.ts +++ b/test/helpers/probe.ts @@ -179,7 +179,7 @@ export function writeProbeArtifacts(results: ProbeResult[]): void { fs.writeFileSync(reportPath, report); if (process.env.GITHUB_STEP_SUMMARY) { - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${report}\n`); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n## Attempt ${resolveAttempt()}\n\n${report}\n`); } } @@ -201,7 +201,7 @@ export function renderProbeReport(results: ProbeResult[]): string { result.amountSats.toString(), result.required ? 'yes' : 'no', result.invoiceFetched ? 'ok' : 'failed', - result.success ? 'ok' : 'failed', + result.success ? '✅' : '❌', result.durationMs.toString(), sanitizeMarkdownCell(result.error ?? ''), ].join(' | ')} |` @@ -289,6 +289,10 @@ function resolveArtifactsDir(): string { 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(); } From d5ef152ef7a92e32e436b7138d18d00f5a820ad2 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 13 May 2026 17:38:58 +0200 Subject: [PATCH 8/9] update timeout --- test/helpers/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From d615b24ff970a35950234997c037406f9874f278 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 13 May 2026 17:51:19 +0200 Subject: [PATCH 9/9] feat: probe retries --- test/helpers/probe.ts | 6 ++- test/specs/mainnet/probe.e2e.ts | 91 +++++++++++++++++++++++++++------ 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/test/helpers/probe.ts b/test/helpers/probe.ts index 135259a..e484f58 100644 --- a/test/helpers/probe.ts +++ b/test/helpers/probe.ts @@ -23,6 +23,7 @@ export type ProbeResult = { amountSats: number; required: boolean; attempt: number; + retries: number; invoiceFetched: boolean; success: boolean; durationMs: number; @@ -190,8 +191,8 @@ export function renderProbeReport(results: ProbeResult[]): string { '', `Required failures: ${failedRequired.length}`, '', - '| Target | Amount sats | Required | Invoice | Probe | Duration ms | Error |', - '| --- | ---: | --- | --- | --- | ---: | --- |', + '| Target | Amount sats | Required | Invoice | Probe | Retries | Duration ms | Error |', + '| --- | ---: | --- | --- | --- | ---: | ---: | --- |', ]; for (const result of results) { @@ -202,6 +203,7 @@ export function renderProbeReport(results: ProbeResult[]): string { result.required ? 'yes' : 'no', result.invoiceFetched ? 'ok' : 'failed', result.success ? '✅' : '❌', + result.retries.toString(), result.durationMs.toString(), sanitizeMarkdownCell(result.error ?? ''), ].join(' | ')} |` diff --git a/test/specs/mainnet/probe.e2e.ts b/test/specs/mainnet/probe.e2e.ts index 3804655..163692e 100644 --- a/test/specs/mainnet/probe.e2e.ts +++ b/test/specs/mainnet/probe.e2e.ts @@ -14,6 +14,8 @@ import { import { ciIt } from '../../helpers/suite'; const DEFAULT_PROBE_DELAY_MS = 10_000; +const DEFAULT_PROBE_RETRIES = 2; +const DEFAULT_PROBE_RETRY_DELAY_MS = 5_000; function resolveEnvValue(name: string): string { const value = process.env[name]; @@ -34,6 +36,14 @@ function resolveProbeDelayMs(): number { 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; @@ -46,32 +56,70 @@ async function runProbe(target: ProbeTarget, amountMsat: number): Promise 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', () => { @@ -99,6 +147,8 @@ describe('@probe_mainnet - Lightning probe smoke', () => { 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); @@ -128,3 +178,14 @@ describe('@probe_mainnet - Lightning probe smoke', () => { } }); }); + +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}`); +}