Compare commits

...

23 Commits

Author SHA1 Message Date
Ivan Vershigora
26866c21d8
Merge branch 'master' into bio-e2e 2026-05-14 17:57:58 +01:00
Ivan Vershigora
18c48c1293
Merge branch 'master' into bio-e2e 2026-05-13 13:14:58 +01:00
Ivan Vershigora
83873db543
fix: freez 2026-05-11 11:43:24 +01:00
Ivan Vershigora
031992297f
fix: import timeout 2026-05-11 11:43:24 +01:00
Ivan Vershigora
72d93f268e
fix: goGack on ios 2026-05-11 11:43:24 +01:00
Ivan Vershigora
18cb13f7d5
TST: testID on FrozenBadge + memo label, target by id in manage-UTXO
`by.text('Freeze')` was flaky on iOS — the badge text could be
occluded or laid out before Detox flushed. Add `testID` support to
`Badge`, set `FrozenBadge` / `ChangeBadge` IDs from `CoinControl`, and
add `OutputMemoLabel` to the row memo Text so the manage-UTXO e2e
asserts the badge by id (with `waitFor` to absorb late layout) instead
of by visible text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:24 +01:00
Ivan Vershigora
54e4b45876
TST: add testID to Freeze switch in CoinControl output sheet
The freeze switch in CoinControlOutputSheet was tapped via
`by.type('UISwitchModernVisualElement')` in e2e, which is fragile
across iOS versions. Add an explicit `FreezeSwitch` testID and target
it from the manage-UTXO test, so the badge assertion reliably finds
"Freeze" after toggling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:24 +01:00
Ivan Vershigora
e300b4d113
fix: BIP84 wallet UTXO 2nd relaunch + tighter alert-text-field matcher
manage-UTXO test does TWO newInstance launches; both need bio-unlock now that
biometric stays enabled across tests.

typeTextIntoAlertInput tries multiple iOS class names in order — private
`_UIAlertControllerTextField` (pre-26) then public `UIAlertControllerTextField`
then generic `UITextField`. zpub watch-only test now passes again in isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:24 +01:00
Ivan Vershigora
3cdf9fdab3
fix: BIP84 wallet tests resolve bio unlock on relaunch
Tests after the basic-wallet-details bio test inherit biometric-enabled
state. Each newInstance launch hits UnlockWith with auto-Face ID — these
tests now match bio on relaunch so the wallet text becomes visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:24 +01:00
Ivan Vershigora
4a8cf0b145
TST: remove bio-debugging diagnostic try/catches
Cleaning up the temporary screenshot-on-fail blocks that helped diagnose
the iOS 26 retry-dialog issue. Multisig vault test still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:24 +01:00
Ivan Vershigora
06830bd766
fix: bio reject path on iOS 26 sim taps Cancel on retry dialog
iOS 26 sim shows a system "Face Not Recognised / Try Again / Cancel" retry
dialog after the first applesimutils nonmatch — Detox can't reach those
system-overlay buttons. failBiometric now shells `idb ui tap` at the dialog's
Cancel coords so the simplePrompt promise actually rejects and JS onError
handlers run.

Demote useExtendedNavigation.ts's biometric-failure log from console.error
(which fires LogBox notification toasts) to console.log — bio rejection is
expected user input, not an app error.

Multisig vault test gains scroll-to-visible for Vault Key 2/3 and a
post-bio-rejection OK-tap to dismiss the residual "Canceled by another
authentication" alert that occludes the screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:24 +01:00
Ivan Vershigora
bb0ca7311e
fix: Yes-delete bio test reopens alert + dismisses post-match residual alert
tapGatedByBiometric gains an optional `reopen` callback for gates that close
themselves on bio rejection (iOS alerts). Helper also re-foregrounds the app
on its specific UDID after the match — applesimutils signals on iOS-26 sim
sometimes background BlueWallet to home, and `simctl launch booted` was
ambiguous when Detox boots multiple sims.

For the 'Yes, delete' case the back-to-back simplePrompt calls leave a
'Canceled by another authentication' residual alert; the test taps OK to
dismiss it before checking the empty WalletsList state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:24 +01:00
Ivan Vershigora
b5182530a4
TST: drop it.skip from previously-skipped e2e tests
All 8 tests run again. The TODO comments stay as documentation of known
flake reasons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:23 +01:00
Ivan Vershigora
6dd82c9ad9
TST: re-skip pre-existing flakes after attempted unskip
manage-UTXO and the two purge-txs tests still fail with `Imported HD SegWit`
text not visible after relaunch — state pollution from earlier tests in the
file. zpub watch-only test passes in isolation but full-run hits a different
hang (tapReturnKey on CustomAmountDescription with a layer-animation pending).
multisig-setup-from-UR still fails on AddressInput visibility.

All four are non-bio. Leaving them skipped with TODOs documenting what's been
tried.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:23 +01:00
Ivan Vershigora
f568b5cb6f
TST: typeTextIntoAlertInput fallback for iOS 26
iOS 26 sim dropped the private `_UIAlertControllerTextField` class. Fall back to
public `UITextField` when the private name doesn't match. Re-enables the
'can import zpub as watch-only, import psbt, and then scan signed psbt' test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:23 +01:00
Ivan Vershigora
6864f2d0c5
TST: skip flaky e2e tests with TODOs
Skipping 8 tests with TODO context:

  Bio-related (3) — `tapGatedByBiometric` re-tap path doesn't recover on iOS 26
  sim for two gate types:
    - Alert buttons (Yes, delete): bio rejection closes the iOS alert, so the
      second tap has no element to hit. Needs a `reopen` callback.
    - Navigation gates (useExtendedNavigation requiresBiometrics list, e.g.
      WalletExport, ViewEditMultisigCosigners, ExportMultisigCoordinationSetupRoot):
      something in the rejection flow leaves the app unable to complete the
      second navigation. The bio-smoke + biometric-gates tests pass — the helper
      itself works for non-navigation gates.

  Pre-existing flakes (5) — unrelated to bio work; visibility/element-not-found
  on send screen, wallet relaunch text, alert text-field type. These were
  failing in earlier full-suite runs before the bio refactor too.

Also tap-helper now best-effort foregrounds the app between fail and re-tap to
mitigate AppleSimulatorUtils#65 (single nonmatch backgrounds the app on iOS 26).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:23 +01:00
Ivan Vershigora
5b8f7a76c6
TST: bio reject-then-match for every gate
tapGatedByBiometric now always exercises rejection-then-match (drop the 'match'
shortcut option). Each gate's RN simplePrompt() promise rejection path runs.

Also:
- simplify perms map: faceid/camera/notifications no-op on android, just always
  pass them rather than guarding with isIOS.
- bio-smoke gains a third case: relaunch + reject-then-match toggle off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:43:23 +01:00
Ivan Vershigora
fadabbe9b2
fix: wip 2026-05-11 11:43:23 +01:00
Ivan Vershigora
c3ddb42867
fix: wip 2026-05-11 11:43:23 +01:00
Ivan Vershigora
94137dddce
fix: wip 2026-05-11 11:43:23 +01:00
Ivan Vershigora
e3b1c3ef62
fix: wip 2026-05-11 11:43:23 +01:00
Ivan Vershigora
7cdb710416
fix: wip 2026-05-11 11:43:23 +01:00
Ivan Vershigora
19a6e4faa5
TST: e2e coverage for biometric flows 2026-05-11 11:43:23 +01:00
12 changed files with 650 additions and 70 deletions

View File

@ -5,11 +5,12 @@ export interface BadgeProps {
value?: string | number | React.ReactNode;
badgeStyle?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
testID?: string;
}
const Badge: React.FC<BadgeProps> = ({ value, badgeStyle, textStyle }) => {
const Badge: React.FC<BadgeProps> = ({ value, badgeStyle, textStyle, testID }) => {
return (
<View style={[styles.badge, badgeStyle]}>
<View testID={testID} style={[styles.badge, badgeStyle]}>
{typeof value === 'string' || typeof value === 'number' ? <Text style={[styles.text, textStyle]}>{value}</Text> : value}
</View>
);

View File

@ -47,7 +47,10 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
setSharedCosigner,
walletsInitialized,
} = useStorage();
const appState = useRef<AppStateStatus>(AppState.currentState);
// True only if the app actually went to 'background' (another app foregrounded). Lets us
// distinguish a real resume from active→inactive→active blips (biometric, Control Center,
// notifications) which would otherwise trigger the iOS paste permission prompt.
const wasInBackground = useRef<boolean>(false);
const clipboardContent = useRef<undefined | string>(undefined);
const navigation = useExtendedNavigation();
@ -274,7 +277,15 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
async (nextAppState: AppStateStatus | undefined) => {
if (!shouldActivateListeners || wallets.length === 0) return;
if ((appState.current.match(/inactive|background/) && nextAppState === 'active') || nextAppState === undefined) {
if (nextAppState === 'background') {
wasInBackground.current = true;
}
// Real resume from another app (tolerates the intermediate 'inactive' iOS emits);
// `undefined` is the initial mount call.
const isResume = nextAppState === 'active' && wasInBackground.current;
if (isResume || nextAppState === undefined) {
if (isResume) wasInBackground.current = false;
updateExchangeRate();
const processed = await processPushNotifications();
if (processed) return;
@ -308,9 +319,6 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
}
clipboardContent.current = clipboard;
}
if (nextAppState) {
appState.current = nextAppState;
}
},
[processPushNotifications, showClipboardAlert, wallets, shouldActivateListeners],
);

View File

@ -97,7 +97,8 @@ export const useExtendedNavigation = <T extends NavigationProp<ParamListBase>>()
proceedWithNavigation();
return;
} else {
console.error('Biometric authentication failed');
// User-cancelled / failed Face ID isn't an app error — log without firing LogBox.
console.log('Biometric authentication failed; navigation aborted');
return;
}
}

View File

@ -0,0 +1,88 @@
diff --git a/node_modules/detox/src/devices/common/drivers/ios/tools/AppleSimUtils.js b/node_modules/detox/src/devices/common/drivers/ios/tools/AppleSimUtils.js
index f536ea9..d74ea17 100644
--- a/node_modules/detox/src/devices/common/drivers/ios/tools/AppleSimUtils.js
+++ b/node_modules/detox/src/devices/common/drivers/ios/tools/AppleSimUtils.js
@@ -311,14 +311,24 @@ class AppleSimUtils {
return;
}
- const options = {
- args: `--byId ${udid} --match${matchType}`,
- retries: 1,
- statusLogs: {
- trying: `Trying to match ${matchType}...`,
- successful: `Matched ${matchType}!`
- },
- };
+ const isIOS26Plus = (await this._getMajorIOSVersion(udid)) >= 26;
+ const options = isIOS26Plus
+ ? {
+ args: `--booted --biometricMatch`,
+ retries: 1,
+ statusLogs: {
+ trying: `Trying to match ${matchType}...`,
+ successful: `Matched ${matchType}!`
+ }
+ }
+ : {
+ args: `--byId ${udid} --match${matchType}`,
+ retries: 1,
+ statusLogs: {
+ trying: `Trying to match ${matchType}...`,
+ successful: `Matched ${matchType}!`
+ }
+ };
await this._execAppleSimUtils(options);
}
@@ -327,14 +337,24 @@ class AppleSimUtils {
return;
}
- const options = {
- args: `--byId ${udid} --unmatch${matchType}`,
- retries: 1,
- statusLogs: {
- trying: `Trying to unmatch ${matchType}...`,
- successful: `Unmatched ${matchType}!`
- },
- };
+ const isIOS26Plus = (await this._getMajorIOSVersion(udid)) >= 26;
+ const options = isIOS26Plus
+ ? {
+ args: `--booted --biometricNonmatch`,
+ retries: 1,
+ statusLogs: {
+ trying: `Trying to unmatch ${matchType}...`,
+ successful: `Unmatched ${matchType}!`
+ }
+ }
+ : {
+ args: `--byId ${udid} --unmatch${matchType}`,
+ retries: 1,
+ statusLogs: {
+ trying: `Trying to unmatch ${matchType}...`,
+ successful: `Unmatched ${matchType}!`
+ }
+ };
await this._execAppleSimUtils(options);
}
@@ -344,13 +364,15 @@ class AppleSimUtils {
}
const toggle = yesOrNo === 'YES';
+ const isIOS26Plus = (await this._getMajorIOSVersion(udid)) >= 26;
+ const byIdOrBooted = isIOS26Plus ? `--booted` : `--byId ${udid}`;
const options = {
- args: `--byId ${udid} --biometricEnrollment ${yesOrNo}`,
+ args: `${byIdOrBooted} --biometricEnrollment ${yesOrNo}`,
retries: 1,
statusLogs: {
trying: `Turning ${toggle ? 'on' : 'off'} biometric enrollment...`,
successful: toggle ? 'Activated!' : 'Deactivated!'
- },
+ }
};
await this._execAppleSimUtils(options);
}

View File

@ -27,6 +27,7 @@ const FrozenBadge: React.FC = () => {
const { colors } = useTheme();
return (
<Badge
testID="FrozenBadge"
value={loc.cc.freeze}
badgeStyle={[styles.badge, { backgroundColor: colors.redBG }]}
textStyle={[styles.badgeText, { color: colors.redText }]}
@ -38,6 +39,7 @@ const ChangeBadge: React.FC = () => {
const { colors } = useTheme();
return (
<Badge
testID="ChangeBadge"
value={loc.cc.change}
badgeStyle={[styles.badge, { backgroundColor: colors.buttonDisabledBackgroundColor }]}
textStyle={[styles.badgeText, { color: colors.alternativeTextColor }]}
@ -135,7 +137,7 @@ const OutputList: React.FC<TOutputListProps> = ({
/>
<View style={styles.itemContent}>
<Text style={oStyles.amount}>{amount}</Text>
<Text style={oStyles.memo} numberOfLines={1} ellipsizeMode="middle">
<Text testID="OutputMemoLabel" style={oStyles.memo} numberOfLines={1} ellipsizeMode="middle">
{memo || address}
</Text>
</View>

View File

@ -44,6 +44,7 @@ const CoinControlOutputSheet: React.FC = () => {
const switchValue = useMemo(
() => ({
value: frozen,
testID: 'FreezeSwitch',
onValueChange: async (value: boolean) => {
if (!wallet) return;
setFrozen(value);

View File

@ -159,6 +159,7 @@ const EncryptStorage = () => {
value: biometricEnabled,
onValueChange: onUseBiometricSwitch,
disabled: state.currentLoadingSwitch !== null,
testID: 'BiometricSwitch',
}}
isLoading={state.currentLoadingSwitch === 'biometric' && state.isLoading}
containerStyle={[styles.row, styleHooks.root]}

View File

@ -0,0 +1,71 @@
// Minimal bio test for fast iteration: launch the app fresh with Face ID enrolled, navigate
// to Settings → Security, flip the BiometricSwitch, resolve the Face ID prompt with a match,
// confirm the switch ends up ON. No wallet creation, no other flows.
//
// Run just this file:
// source ../env.sh && npx detox test -c ios.debug tests/e2e/bio-smoke.spec.js --loglevel info
import { by, device, element } from 'detox';
import {
enableBiometric,
matchBiometric,
setupBiometricEnrollment,
tapGatedByBiometric,
terminateBootedApp,
waitForId,
waitForSwitchValue,
} from './helperz';
console.warn = console.log = (...args) => {
process.stdout.write('\n\t\t' + args.map(String).join('') + '\n');
};
describe('BlueWallet UI Tests - bio smoke', () => {
it('triggers Face ID from Settings and toggles biometric on', async () => {
if (device.getPlatform() !== 'ios') return;
await device.clearKeychain();
await setupBiometricEnrollment();
await device.launchApp({ delete: true, permissions: { faceid: 'YES' } });
await waitForId('WalletsList');
// enableBiometric internally asserts the switch flipped to ON via waitForSwitchValue;
// if it returns without throwing, the full flow worked.
await enableBiometric({ returnHome: true });
});
it('auto-triggers Face ID on relaunch and unlocks to WalletsList', async () => {
if (device.getPlatform() !== 'ios') return;
// Reuse state from the previous test (biometric is already enabled). On a fresh process,
// walletsInitialized starts false → UnlockWith renders → it sees biometric is enabled
// → Face ID prompt auto-triggers from useEffect.
const launchPromise = device.launchApp({ newInstance: true });
await matchBiometric();
await launchPromise;
await waitForId('WalletsList');
});
it('reject-then-match Face ID toggles biometric off', async () => {
if (device.getPlatform() !== 'ios') return;
// Continuing from previous test: app is open on WalletsList, biometric is ON.
await element(by.id('SettingsButton')).tap();
await element(by.id('SecurityButton')).tap();
await waitForId('BiometricSwitch');
// Switch is ON → tapping it triggers Face ID for the disable. tapGatedByBiometric default
// 'rejectThenMatch' does: tap → fail (RN promise rejects, switch stays ON) → re-tap →
// match (RN promise resolves, switch flips OFF).
await tapGatedByBiometric(by.id('BiometricSwitch'));
// tapGatedByBiometric re-enabled sync at the end; waitForSwitchValue uses expect() which
// blocks on idle, so disable around the assert.
await device.disableSynchronization();
await waitForSwitchValue('BiometricSwitch', false);
await device.enableSynchronization();
terminateBootedApp();
});
});

View File

@ -4,18 +4,24 @@ import { element, waitFor } from 'detox';
import {
confirmPasswordDialog,
enableBiometric,
expectToBeVisible,
extractTextFromElementById,
failBiometric,
goBack,
hashIt,
helperCreateWallet,
helperDeleteWallet,
matchBiometric,
scanText,
scrollUpOnHomeScreen,
setCustomFeeRate,
setupBiometricEnrollment,
sleep,
tapAndTapAgainIfElementIsNotVisible,
tapGatedByBiometric,
tapIfPresent,
tapIfTextPresent,
terminateBootedApp,
waitForId,
waitForKeyboardToClose,
waitForText,
@ -600,6 +606,8 @@ describe('BlueWallet UI Tests - no wallets', () => {
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
// TODO: pre-existing flake — `AddressInput` not found on send screen (regression
// unrelated to bio work).
it('can import multisig setup from UR, and create tx, and sign on hw devices', async () => {
const lockFile = '/tmp/travislock.' + hashIt('t6');
if (process.env.CI) {
@ -756,7 +764,7 @@ describe('BlueWallet UI Tests - no wallets', () => {
// wait for discovery to be completed
await waitFor(element(by.text("m/84'/0'/0'")))
.toBeVisible()
.withTimeout(300 * 1000);
.withTimeout(600 * 1000);
await expect(element(by.text("m/44'/0'/1'"))).toBeVisible();
await expect(element(by.text("m/49'/0'/0'"))).toBeVisible();
await expect(element(by.id('Loading'))).not.toBeVisible();
@ -770,7 +778,7 @@ describe('BlueWallet UI Tests - no wallets', () => {
await waitForKeyboardToClose();
await waitFor(element(by.text('Found'))) // wait for discovery to be completed
.toExist()
.withTimeout(300 * 1000);
.withTimeout(600 * 1000);
await element(by.text('Found')).tap();
await element(by.id('ImportButton')).tap();
await element(by.text('OK')).tap();
@ -874,28 +882,57 @@ describe('BlueWallet UI Tests - no wallets', () => {
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
it('can create wallet and delete wallet', async () => {
it('can create wallet, enable biometric, and delete wallet with biometric auth', async () => {
const lockFile = '/tmp/travislock.' + hashIt('t9');
if (process.env.CI) {
if (require('fs').existsSync(lockFile)) return console.warn('skipping', JSON.stringify('t8'), 'as it previously passed on Travis');
if (require('fs').existsSync(lockFile)) return console.warn('skipping', JSON.stringify('t9'), 'as it previously passed on Travis');
}
await device.clearKeychain();
await device.launchApp({ delete: true }); // reinstalling the app just for any case to clean up app's storage
await setupBiometricEnrollment();
await device.launchApp({ delete: true, permissions: { faceid: 'YES' } });
await waitForId('WalletsList');
await helperCreateWallet();
// nop
await helperDeleteWallet('cr34t3d');
await enableBiometric();
await element(by.text('cr34t3d')).tap();
await element(by.id('WalletDetails')).tap();
await waitFor(element(by.id('HeaderMenuButton')))
.toBeVisible()
.whileElement(by.id('WalletDetailsScroll'))
.scroll(500, 'down');
const openDeleteAlert = async () => {
await element(by.id('HeaderMenuButton')).tap();
await element(by.text('Delete')).tap();
await waitForText('Yes, delete');
};
await openDeleteAlert();
// Bio rejection dismisses the iOS alert; reopen it before the match retry.
await tapGatedByBiometric(by.text('Yes, delete'), { reopen: openDeleteAlert });
// Dismiss any "Canceled by another authentication" residual alert from the back-to-back
// simplePrompt calls before checking the empty WalletsList state.
await tapIfTextPresent('OK');
await waitForId('NoTransactionsMessage');
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
// TODO: tapGatedByBiometric on navigation-gated routes (useExtendedNavigation's
// requiresBiometrics list) leaves the app in an unrecoverable state on iOS 26 sim after
// the first applesimutils nonmatch — re-tap completes but navigation never finishes.
// Needs deeper investigation; smoke + bio-gates passes confirm the helper itself works
// on non-navigation gates.
it('can create 2of3 multisig vault with generated keys, manage cosigners and export coordination setup; forgetting seed/restoring seed does not change receive address', async () => {
const lockFile = '/tmp/travislock.' + hashIt('t10');
if (process.env.CI) {
if (require('fs').existsSync(lockFile)) return console.warn('skipping', JSON.stringify('t10'), 'as it previously passed on Travis');
}
await device.clearKeychain();
await device.launchApp({ delete: true, permissions: { camera: 'YES', notifications: 'YES' } });
await setupBiometricEnrollment();
await device.launchApp({ delete: true, permissions: { camera: 'YES', notifications: 'YES', faceid: 'YES' } });
await waitForId('WalletsList');
await enableBiometric();
await waitFor(element(by.id('CreateAWallet')))
.toBeVisible()
.whileElement(by.id('WalletsList'))
@ -944,12 +981,14 @@ describe('BlueWallet UI Tests - no wallets', () => {
await element(by.id('WalletDetails')).tap();
await waitForText('2 / 3 (native segwit)');
// test Export Coordination Setup, it has animated qrcode, that uses setInterval, so we need to disable synchronization
// Animated QR on the destination screen uses setInterval, so keep sync disabled afterwards.
await waitFor(element(by.id('MultisigCoordinationSetup')))
.toBeVisible()
.whileElement(by.id('WalletDetailsScroll'))
.scroll(150, 'down');
await element(by.id('MultisigCoordinationSetup')).tap();
// Bio-gated by useExtendedNavigation (route: ExportMultisigCoordinationSetupRoot).
await tapGatedByBiometric(by.id('MultisigCoordinationSetup'));
await tapIfTextPresent('OK'); // dismiss any "Canceled by another authentication" residual
await device.disableSynchronization();
await waitForId('ExportMultisigCoordinationSetupView');
await element(by.id('NavigationCloseButton')).atIndex(0).tap();
@ -967,14 +1006,18 @@ describe('BlueWallet UI Tests - no wallets', () => {
console.log('vaultReceiveAddress', vaultReceiveAddress);
// test View/Edit Cosigners
await waitFor(element(by.id('ViewEditCosigners')))
.toBeVisible()
.whileElement(by.id('WalletDetailsScroll'))
.scroll(100, 'down');
await element(by.id('ViewEditCosigners')).tap();
// Bio-gated by useExtendedNavigation (route: ViewEditMultisigCosigners).
await tapGatedByBiometric(by.id('ViewEditCosigners'));
await tapIfTextPresent('OK'); // dismiss any residual "Canceled by another authentication"
await waitForText('Vault Key 1');
await expect(element(by.text('Vault Key 2'))).toBeVisible();
await waitFor(element(by.text('Vault Key 2')))
.toBeVisible()
.whileElement(by.id('ViewEditMultisigCosignersFlatList'))
.scroll(100, 'down');
await waitFor(element(by.text('Vault Key 3')))
.toBeVisible()
.whileElement(by.id('ViewEditMultisigCosignersFlatList'))
@ -992,11 +1035,10 @@ describe('BlueWallet UI Tests - no wallets', () => {
.whileElement(by.id('ViewEditMultisigCosignersFlatList'))
.scroll(100, 'down');
// save changes
await waitFor(element(by.id('VaultCosignersSave')))
.toBeVisible()
.withTimeout(33000);
await element(by.id('VaultCosignersSave')).tap();
await tapGatedByBiometric(by.id('VaultCosignersSave'));
await waitForId('WalletsList');
// verify receive address remains unchanged after forgetting cosigner 3 seed
@ -1010,14 +1052,15 @@ describe('BlueWallet UI Tests - no wallets', () => {
const vaultReceiveAddressAfterCosignerSave = await extractTextFromElementById('AddressValue');
assert.strictEqual(vaultReceiveAddressAfterCosignerSave, vaultReceiveAddress);
// go back to manage keys, restore seed for cosigner 3, and save
// go back to manage keys, restore seed for cosigner 3, and save.
await goBack();
await element(by.id('WalletDetails')).tap();
await waitFor(element(by.id('ViewEditCosigners')))
.toBeVisible()
.whileElement(by.id('WalletDetailsScroll'))
.scroll(100, 'down');
await element(by.id('ViewEditCosigners')).tap();
// Bio-gated by useExtendedNavigation (route: ViewEditMultisigCosigners).
await tapGatedByBiometric(by.id('ViewEditCosigners'));
await waitFor(element(by.id('VaultCosignerImportMnemonics3')))
.toBeVisible()
.whileElement(by.id('ViewEditMultisigCosignersFlatList'))
@ -1031,7 +1074,7 @@ describe('BlueWallet UI Tests - no wallets', () => {
await waitFor(element(by.id('VaultCosignersSave')))
.toBeVisible()
.withTimeout(33000);
await element(by.id('VaultCosignersSave')).tap();
await tapGatedByBiometric(by.id('VaultCosignersSave'));
await waitForId('WalletsList');
// verify receive address remains unchanged after restoring cosigner 3 seed
@ -1105,3 +1148,115 @@ describe('BlueWallet UI Tests - no wallets', () => {
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
});
describe('BlueWallet UI Tests - biometric gates', () => {
// iOS-only today (Detox biometric APIs). Android helpers throw; gate each test.
it('biometric settings toggle is gated by biometric auth (enable + disable)', async () => {
if (device.getPlatform() !== 'ios') return;
const lockFile = '/tmp/travislock.' + hashIt('bio_toggle');
if (process.env.CI && require('fs').existsSync(lockFile)) {
return console.warn('skipping bio_toggle as it previously passed on Travis');
}
await device.clearKeychain();
await setupBiometricEnrollment();
await device.launchApp({ delete: true, permissions: { faceid: 'YES' } });
await waitForId('WalletsList');
await helperCreateWallet('bio_wallet');
await enableBiometric({ returnHome: false });
// Disable attempt with a failed Face ID → switch must stay ON.
await device.disableSynchronization();
await element(by.id('BiometricSwitch')).tap();
await failBiometric();
await expect(element(by.id('BiometricSwitch'))).toHaveToggleValue(true);
terminateBootedApp();
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
it('encrypted storage unlocks on relaunch via biometric', async () => {
if (device.getPlatform() !== 'ios') return;
const lockFile = '/tmp/travislock.' + hashIt('bio_unlock');
if (process.env.CI && require('fs').existsSync(lockFile)) {
return console.warn('skipping bio_unlock as it previously passed on Travis');
}
await device.clearKeychain();
await setupBiometricEnrollment();
await device.launchApp({ delete: true, permissions: { faceid: 'YES' } });
await waitForId('WalletsList');
await helperCreateWallet('bio_unlock_wallet');
await enableBiometric({ returnHome: false });
await element(by.id('EncyptedAndPasswordProtectedSwitch')).tap();
await waitForId('IUnderstandButton');
await element(by.id('IUnderstandButton')).tap();
await waitForId('PasswordInput');
await element(by.id('PasswordInput')).replaceText('biopass');
await element(by.id('PasswordInput')).tapReturnKey();
await waitForKeyboardToClose();
await element(by.id('ConfirmPasswordInput')).replaceText('biopass');
await element(by.id('ConfirmPasswordInput')).tapReturnKey();
await waitForKeyboardToClose();
await element(by.id('OKButton')).tap();
await tapIfPresent('OKButton');
const launchPromise = device.launchApp({ newInstance: true });
await matchBiometric();
await launchPromise;
await waitForId('WalletsList');
await expect(element(by.id('bio_unlock_wallet'))).toBeVisible();
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
it('shows keychain wipe alert after 10 failed password attempts (iOS)', async () => {
if (device.getPlatform() !== 'ios') return;
const lockFile = '/tmp/travislock.' + hashIt('bio_10fails');
if (process.env.CI && require('fs').existsSync(lockFile)) {
return console.warn('skipping bio_10fails as it previously passed on Travis');
}
await device.clearKeychain();
await device.launchApp({ delete: true });
await waitForId('WalletsList');
await helperCreateWallet('wipe_wallet');
await element(by.id('SettingsButton')).tap();
await element(by.id('SecurityButton')).tap();
await element(by.id('EncyptedAndPasswordProtectedSwitch')).tap();
await element(by.id('IUnderstandButton')).tap();
await element(by.id('PasswordInput')).clearText();
await element(by.id('PasswordInput')).replaceText('correctpass');
await element(by.id('PasswordInput')).tapReturnKey();
await waitForKeyboardToClose();
await element(by.id('ConfirmPasswordInput')).clearText();
await element(by.id('ConfirmPasswordInput')).replaceText('correctpass');
await element(by.id('ConfirmPasswordInput')).tapReturnKey();
await waitForKeyboardToClose();
await element(by.id('OKButton')).tap();
await tapIfPresent('OKButton');
await sleep(1000);
await device.launchApp({ newInstance: true });
await waitForId('PasswordInput');
for (let i = 0; i < 10; i++) {
await element(by.id('PasswordInput')).typeText('wrong\n');
await waitForKeyboardToClose();
}
await waitForText(
'You have attempted to enter your password 10 times. Would you like to reset your storage? This will remove all wallets and decrypt your storage.',
5000,
);
await tapIfTextPresent('Cancel');
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
});

View File

@ -4,21 +4,28 @@ import * as bitcoin from 'bitcoinjs-lib';
import { uint8ArrayToHex } from '../../blue_modules/uint8array-extras';
import {
countElements,
enableBiometric,
extractTextFromElementById,
failBiometric,
getSwitchValue,
goBack,
hashIt,
helperImportWallet,
matchBiometric,
scanText,
scrollUpOnHomeScreen,
setCustomFeeRate,
setupBiometricEnrollment,
sleep,
tapAndTapAgainIfElementIsNotVisible,
tapAndTapAgainIfTextIsNotVisible,
tapGatedByBiometric,
tapIfTextPresent,
terminateBootedApp,
typeTextIntoAlertInput,
waitForId,
waitForKeyboardToClose,
waitForSwitchValue,
waitForText,
} from './helperz';
@ -542,6 +549,9 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
// TODO: same iOS 26 navigation-gate quirk as multisig vault test —
// tapGatedByBiometric on WalletExport / XpubButton (both in useExtendedNavigation's
// requiresBiometrics) doesn't recover after rejection.
it('can do basic wallet-details operations', async () => {
const lockFile = '/tmp/travislock.' + hashIt('t_walletdetails');
if (process.env.CI) {
@ -554,6 +564,9 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
await device.launchApp({ newInstance: true });
await setupBiometricEnrollment();
await enableBiometric();
// go inside the wallet
await waitForText('Imported HD SegWit (BIP84 Bech32 Native)');
await element(by.text('Imported HD SegWit (BIP84 Bech32 Native)')).tap();
@ -579,29 +592,29 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
await expect(element(by.id('WalletLabel'))).toHaveText('Imported HD SegWit (BIP84 Bech32 Native)');
await element(by.id('WalletDetails')).tap();
// wallet export
await waitFor(element(by.id('WalletExport')))
.toBeVisible()
.whileElement(by.id('WalletDetailsScroll'))
.scroll(500, 'down');
await tapAndTapAgainIfElementIsNotVisible('WalletExport', 'WalletExportScroll');
await tapGatedByBiometric(by.id('WalletExport'));
await waitForId('WalletExportScroll');
await element(by.id('WalletExportScroll')).swipe('up', 'fast', 1);
await sleep(200); // bounce animation
await expect(element(by.id('Secret'))).toHaveText(process.env.HD_MNEMONIC_BIP84);
await goBack();
// XPUB
await waitFor(element(by.id('XpubButton')))
.toBeVisible()
.whileElement(by.id('WalletDetailsScroll'))
.scroll(500, 'down');
await tapAndTapAgainIfElementIsNotVisible('XpubButton', 'CopyTextToClipboard');
await tapGatedByBiometric(by.id('XpubButton'));
await waitForId('CopyTextToClipboard');
await goBack();
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
it('should handle URL successfully', async () => {
it('handles bitcoin URL deeplink and broadcast is blocked when biometric auth is rejected', async () => {
const lockFile = '/tmp/travislock.' + hashIt('t22');
if (process.env.CI) {
if (require('fs').existsSync(lockFile)) return console.warn('skipping', JSON.stringify('t22'), 'as it previously passed on Travis');
@ -610,28 +623,60 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped');
return;
}
const isIOS = device.getPlatform() === 'ios';
await setupBiometricEnrollment();
await device.launchApp({ newInstance: true });
await device.launchApp({
// Part 1: bitcoin: URL deeplink routes into the send flow with prefilled address+amount.
const launchPromise = device.launchApp({
newInstance: true,
url: 'bitcoin:BC1QH6TF004TY7Z7UN2V5NTU4MKF630545GVHS45U7?amount=0.0001&label=Yo',
});
if (isIOS) await matchBiometric();
await launchPromise;
await waitForId('chooseFee');
// Wait for the send screen to load after deep link
await waitForId('chooseFee');
// setting fee rate:
const feeRate = 2;
await setCustomFeeRate(feeRate);
await element(by.id('CreateTransactionButton')).tap();
// created. verifying:
await waitForId('TransactionValue');
await expect(element(by.id('TransactionValue'))).toHaveText('0.0001');
await expect(element(by.id('TransactionAddress'))).toHaveText('BC1QH6TF004TY7Z7UN2V5NTU4MKF630545GVHS45U7');
// Part 2 — iOS only: rebuild the tx manually, then verify that tapping
// "Send now" + a rejected Face ID leaves us on Confirm (broadcast blocked).
if (!isIOS) {
process.env.CI && require('fs').writeFileSync(lockFile, '1');
return;
}
const relaunchPromise = device.launchApp({ newInstance: true });
await matchBiometric();
await relaunchPromise;
await enableBiometric();
await scrollUpOnHomeScreen();
await waitForText('Imported HD SegWit (BIP84 Bech32 Native)');
await element(by.text('Imported HD SegWit (BIP84 Bech32 Native)')).tap();
await waitForId('SendButton');
await element(by.id('SendButton')).tap();
await element(by.id('AddressInput')).replaceText('bc1q063ctu6jhe5k4v8ka99qac8rcm2tzjjnuktyrl');
await element(by.id('BitcoinAmountInput')).replaceText('0.0001');
await element(by.id('chooseFee')).tap();
await element(by.id('feeCustomContainerButton')).tap();
await element(by.id('feeCustom')).typeText('1');
await element(by.id('feeCustom')).tapReturnKey();
await waitForKeyboardToClose();
await element(by.id('CreateTransactionButton')).tap();
await waitForId('TransactionValue');
await device.disableSynchronization();
await element(by.text('Send now')).tap();
await failBiometric();
await expect(element(by.id('TransactionValue'))).toBeVisible();
terminateBootedApp();
process.env.CI && require('fs').writeFileSync(lockFile, '1');
});
@ -645,7 +690,10 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
return;
}
await device.launchApp({ newInstance: true });
// The earlier wallet-details test enabled biometric; resolve UnlockWith on relaunch.
const launchPromise = device.launchApp({ newInstance: true });
if (device.getPlatform() === 'ios') await matchBiometric();
await launchPromise;
// go inside the wallet
await waitForText('Imported HD SegWit (BIP84 Bech32 Native)');
await element(by.text('Imported HD SegWit (BIP84 Bech32 Native)')).tap();
@ -670,7 +718,9 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
await waitForKeyboardToClose();
// Terminate and reopen the app to confirm the note is persisted
await device.launchApp({ newInstance: true });
const relaunchUtxo = device.launchApp({ newInstance: true });
if (device.getPlatform() === 'ios') await matchBiometric();
await relaunchUtxo;
await waitForText('Imported HD SegWit (BIP84 Bech32 Native)');
await element(by.text('Imported HD SegWit (BIP84 Bech32 Native)')).tap();
await waitForId('SendButton');
@ -698,15 +748,12 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
await element(by.id('OutputMemo')).typeText('Test2');
await element(by.id('OutputMemo')).tapReturnKey();
await waitForKeyboardToClose();
if (device.getPlatform() === 'ios') {
// FIXME. Add testId to freez switch
await element(by.type('UISwitchModernVisualElement')).tap(); // freeze switch
} else {
await element(by.type('android.widget.CompoundButton')).tap(); // freeze switch
}
await element(by.id('FreezeSwitch')).tap(); // freeze switch
await waitForSwitchValue('FreezeSwitch', true);
await element(by.id('CoinControlOutputDone')).tap();
await expect(element(by.text('Test2')).atIndex(0)).toBeVisible();
await expect(element(by.text('Freeze')).atIndex(0)).toBeVisible();
await waitFor(element(by.id('CoinControlOutputDone'))).not.toBeVisible();
await expect(element(by.id('OutputMemoLabel').and(by.text('Test2')))).toBeVisible();
await expect(element(by.id('FrozenBadge'))).toBeVisible();
// use frozen output to create tx using "Use coin" feature
await element(by.text('Test2')).atIndex(0).tap();
@ -771,7 +818,9 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
return;
}
await device.launchApp({ newInstance: true });
const launchPromise = device.launchApp({ newInstance: true });
if (device.getPlatform() === 'ios') await matchBiometric();
await launchPromise;
// go inside the wallet
await waitForText('Imported HD SegWit (BIP84 Bech32 Native)');
await element(by.text('Imported HD SegWit (BIP84 Bech32 Native)')).tap();
@ -814,7 +863,9 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
return;
}
await device.launchApp({ newInstance: true });
const launchPromise = device.launchApp({ newInstance: true });
if (device.getPlatform() === 'ios') await matchBiometric();
await launchPromise;
// go inside the wallet
await waitForText('Imported HD SegWit (BIP84 Bech32 Native)');
await element(by.text('Imported HD SegWit (BIP84 Bech32 Native)')).tap();
@ -836,7 +887,9 @@ describe('BlueWallet UI Tests - import BIP84 wallet', () => {
assert.strictEqual(await countElements('TransactionListItem'), 0);
// now, restarting the app:
await device.launchApp({ newInstance: true });
const relaunch = device.launchApp({ newInstance: true });
if (device.getPlatform() === 'ios') await matchBiometric();
await relaunch;
// ^^^ its supposed to refetch txs and balance
// asserting balance and txs loaded:

View File

@ -28,6 +28,9 @@ describe('BlueWallet UI Tests - import Watch-only wallet (zpub)', () => {
* 5. provide fully signed psbt (UR)
* 6. verify that we can see broadcast button and camera backdorr button is NOT visible
*/
// TODO: passes in isolation (after the iOS-26 typeTextIntoAlertInput fix), fails in full
// run because earlier test state in this file leaves a layer-animation pending that hangs
// `tapReturnKey` on CustomAmountDescription. State-pollution issue, not a bio regression.
it('can import zpub as watch-only, import psbt, and then scan signed psbt', async () => {
const lockFile = '/tmp/travislock.' + hashIt('t31');
if (process.env.CI) {

View File

@ -93,6 +93,24 @@ export async function getSwitchValue(switchId) {
}
}
// Detox's waitFor() doesn't expose toHaveToggleValue, so poll expect() until the switch reaches
// the expected state (or throw after timeoutMs).
export async function waitForSwitchValue(switchId, expectedValue, timeoutMs = 8000) {
const callsite = captureCallsite(waitForSwitchValue);
const deadline = Date.now() + timeoutMs;
let lastErr;
while (Date.now() < deadline) {
try {
await expect(element(by.id(switchId))).toHaveToggleValue(expectedValue);
return;
} catch (err) {
lastErr = err;
await sleep(250);
}
}
rethrowWithCallsite(lastErr || new Error(`Timed out waiting for ${switchId} == ${expectedValue}`), callsite);
}
export async function helperImportWallet(importText, walletType, expectedWalletLabel, expectedBalance, passphrase) {
await waitForId('WalletsList');
await waitFor(element(by.id('CreateAWallet')))
@ -333,30 +351,61 @@ export async function setCustomFeeRate(feeRate) {
}
export async function goBack() {
if (device.getPlatform() === 'ios') {
try {
await element(by.id('BackButton')).atIndex(0).tap();
} catch (_backError) {
try {
await element(by.id('NavigationCloseButton')).atIndex(0).tap();
} catch (_closeButtonError) {
try {
await element(by.label('Back')).atIndex(0).tap();
} catch (_backLabelError) {
await element(by.text('Close')).atIndex(0).tap();
}
}
}
} else {
if (device.getPlatform() !== 'ios') {
await device.pressBack();
return;
}
const callsite = captureCallsite(goBack);
// Race the candidate matchers in parallel — first one to become visible wins.
const candidates = [
['id=BackButton', by.id('BackButton')],
['id=NavigationCloseButton', by.id('NavigationCloseButton')],
['label=Back', by.label('Back')],
['text=Close', by.text('Close')],
];
const races = candidates.map(([, matcher]) =>
waitFor(element(matcher).atIndex(0))
.toBeVisible()
.withTimeout(20000)
.then(() => matcher),
);
let winner;
try {
winner = await Promise.any(races);
} catch (aggErr) {
const errs = (aggErr && aggErr.errors) || [];
const detail = errs.map((e, i) => `${candidates[i][0]}: ${e && e.message ? e.message.split('\n')[0] : String(e)}`).join('\n ');
rethrowWithCallsite(new Error('goBack: no back/close affordance visible after 20s.\n ' + detail), callsite);
return;
}
await element(winner).atIndex(0).tap();
}
export async function typeTextIntoAlertInput(text) {
if (device.getPlatform() === 'android') {
await element(by.type('android.widget.EditText')).replaceText(text);
} else {
await element(by.type('_UIAlertControllerTextField')).replaceText(text);
// Try multiple iOS class names: pre-26 was the private `_UIAlertControllerTextField`,
// iOS 26 dropped that. The public `UIAlertControllerTextField` works on some iOS versions.
// Last resort: any UITextField — but this picks ANY iOS UITextField including RN's
// RCTUITextField (which inherits from UITextField), so atIndex(0) can match the wrong
// field if an RN text input is on screen.
const candidates = ['_UIAlertControllerTextField', 'UIAlertControllerTextField', 'UITextField'];
let matched = false;
for (const className of candidates) {
try {
await element(by.type(className)).atIndex(0).replaceText(text);
matched = true;
break;
} catch (_) {
/* try next */
}
}
if (!matched) throw new Error('typeTextIntoAlertInput: no alert text field matched on iOS');
}
await sleep(1000);
}
@ -386,3 +435,150 @@ export async function waitForKeyboardToClose() {
}
await sleep(500);
}
// Bundle id used by simctl/applesimutils on iOS. Mirrors `javaPackageName` in package.json
// and the iOS Info.plist; if either is renamed, update here too.
const IOS_BUNDLE_ID = 'io.bluewallet.bluewallet';
// Force-kill the booted iOS simulator app. Used by tests that put the app into a state where
// graceful teardown would itself trigger a biometric prompt (and thus hang). No-op on non-iOS
// and silently swallows simctl errors so a missing/already-dead process doesn't fail teardown.
export function terminateBootedApp() {
if (device.getPlatform() !== 'ios') return;
try {
require('child_process').execSync(`xcrun simctl terminate booted ${IOS_BUNDLE_ID}`, { stdio: 'ignore' });
} catch (_) {
// intentionally ignored — best-effort teardown
}
}
// Biometric helpers. iOS-only today; android support pending ADB wiring.
export async function setupBiometricEnrollment() {
if (device.getPlatform() !== 'ios') return;
await device.setBiometricEnrollment(true);
}
// Resolving the iOS-sim Face ID prompt is a two-step dance with no reliable observability:
//
// 1. We can't detect when the prompt is on screen. The system overlay is drawn by a separate
// process; Detox's in-app `by.label()` can't reach it, and `idb ui describe-all` returns
// only the status bar layer in this iOS 26 sim setup (verified empirically — only XCUITest-
// based tools like mobile-mcp can see it). So we just sleep long enough for LAContext to
// have presented the prompt before firing the signal.
//
// 2. We can't use `device.matchFace()` because it post-awaits `waitForActive`, which hangs
// indefinitely on a still-busy main queue (wix/Detox#2981). We shell out to applesimutils
// directly and skip the wait — callers must rely on their own subsequent waitFor*/expect
// to re-sync app state.
//
// Tune BIOMETRIC_PROMPT_DELAY_MS up if a slower machine drops the signal (signal must land
// after the prompt is on screen). 2.5s gives margin over local-sim ~1s while staying well
// under iOS LAContext's ~30s prompt timeout.
const BIOMETRIC_PROMPT_DELAY_MS = 2500;
async function rawBiometric(flag) {
const { exec } = require('child_process');
await new Promise((resolve, reject) => {
exec(`applesimutils --booted ${flag}`, err => (err ? reject(err) : resolve()));
});
}
export async function matchBiometric() {
if (device.getPlatform() !== 'ios') throw new Error('matchBiometric: android not yet supported');
await sleep(BIOMETRIC_PROMPT_DELAY_MS);
await rawBiometric('--biometricMatch');
}
export async function failBiometric() {
if (device.getPlatform() !== 'ios') throw new Error('failBiometric: android not yet supported');
await sleep(BIOMETRIC_PROMPT_DELAY_MS);
await rawBiometric('--biometricNonmatch');
// iOS 26 sim shows a "Face Not Recognised / Try Again / Cancel" retry dialog after the
// first nonmatch. Tap Cancel via idb (Detox can't reach system-overlay buttons) so the
// simplePrompt promise actually rejects and JS-side onError handlers run.
await sleep(500);
try {
require('child_process').execSync(`idb ui tap --udid ${device.id} 200 513`, { stdio: 'ignore' });
} catch (_) {
/* best-effort — dialog may have auto-dismissed */
}
await sleep(500);
}
// Navigate Settings → Security and flip the biometric switch ON. Idempotent: skips the toggle
// if biometric is already enabled. iOS-only (android: no-op). With returnHome=true (default),
// goes back twice to return to WalletsList; pass false when the caller wants to stay in Security.
export async function enableBiometric({ returnHome = true } = {}) {
if (device.getPlatform() !== 'ios') return;
await element(by.id('SettingsButton')).tap();
await element(by.id('SecurityButton')).tap();
await waitForId('BiometricSwitch');
if (!(await getSwitchValue('BiometricSwitch'))) {
// Sync stays disabled across the whole post-tap flow: tapping the switch triggers
// simplePrompt() which pins the main queue in "busy" (Detox's idle-wait would hang on
// the tap), and even after the Face ID signal lands, post-auth RN work keeps the main
// queue non-idle for several seconds — long enough that an `expect()` from
// waitForSwitchValue would also block. Re-enable sync only after we've confirmed the
// switch value, by which point the app has settled.
await device.disableSynchronization();
await element(by.id('BiometricSwitch')).tap();
await matchBiometric();
await waitForSwitchValue('BiometricSwitch', true);
await device.enableSynchronization();
}
if (returnHome) {
await goBack();
await goBack();
}
}
// Brief wait after a Face ID rejection so the app's RN code can finish handling the rejected
// simplePrompt promise (close the prompt, restore button state) before we re-tap.
const BIOMETRIC_REJECTION_SETTLE_MS = 500;
/**
* Tap a button whose onPress triggers unlockWithBiometrics(), then exercise both the
* rejection and the match paths of the gate:
* 1. tap first simplePrompt fail Face ID RN promise rejects, app's onError/catch
* runs.
* 2. (optional `reopen` callback for gates that close on rejection e.g. iOS alerts that
* dismiss when the bio prompt rejects)
* 3. re-tap second simplePrompt match Face ID promise resolves, flow proceeds.
*
* Why disable synchronization: when an RN button's onPress chains into simplePrompt(), the
* native Face ID prompt pins the main dispatch queue in "busy". Detox's default idle-sync
* then waits forever for `tap()` to return. Disabling sync around the tap lets Detox send the
* action without waiting; we re-enable once the biometric is resolved.
*
* Non-iOS: falls through to a plain tap (android biometric support is pending).
*/
export async function tapGatedByBiometric(matcher, { reopen } = {}) {
const isIOS = device.getPlatform() === 'ios';
if (isIOS) await device.disableSynchronization();
await element(matcher).tap();
if (!isIOS) return;
await failBiometric();
await sleep(BIOMETRIC_REJECTION_SETTLE_MS);
// iOS-26 sim quirk (AppleSimulatorUtils#65): applesimutils --biometricNonmatch sometimes
// backgrounds the app to home. `simctl launch` (without --terminate-running-process) just
// foregrounds the existing app process — state preserved.
try {
require('child_process').execSync(`xcrun simctl launch ${device.id} ${IOS_BUNDLE_ID}`, { stdio: 'ignore' });
} catch (_) {
/* best-effort */
}
await sleep(500);
if (reopen) await reopen();
await element(matcher).tap();
await matchBiometric();
// Same iOS-26 sim quirk applies after match — foreground the app once more so the caller's
// subsequent waitFor*/expect can see the post-auth UI.
try {
require('child_process').execSync(`xcrun simctl launch ${device.id} ${IOS_BUNDLE_ID}`, { stdio: 'ignore' });
} catch (_) {
/* best-effort */
}
await sleep(500);
await device.enableSynchronization();
}