Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26866c21d8 | ||
|
|
18c48c1293 | ||
|
|
83873db543 | ||
|
|
031992297f | ||
|
|
72d93f268e | ||
|
|
18cb13f7d5 | ||
|
|
54e4b45876 | ||
|
|
e300b4d113 | ||
|
|
3cdf9fdab3 | ||
|
|
4a8cf0b145 | ||
|
|
06830bd766 | ||
|
|
bb0ca7311e | ||
|
|
b5182530a4 | ||
|
|
6dd82c9ad9 | ||
|
|
f568b5cb6f | ||
|
|
6864f2d0c5 | ||
|
|
5b8f7a76c6 | ||
|
|
fadabbe9b2 | ||
|
|
c3ddb42867 | ||
|
|
94137dddce | ||
|
|
e3b1c3ef62 | ||
|
|
7cdb710416 | ||
|
|
19a6e4faa5 |
@ -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>
|
||||
);
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
88
patches/detox+20.51.0.patch
Normal file
88
patches/detox+20.51.0.patch
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -44,6 +44,7 @@ const CoinControlOutputSheet: React.FC = () => {
|
||||
const switchValue = useMemo(
|
||||
() => ({
|
||||
value: frozen,
|
||||
testID: 'FreezeSwitch',
|
||||
onValueChange: async (value: boolean) => {
|
||||
if (!wallet) return;
|
||||
setFrozen(value);
|
||||
|
||||
@ -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]}
|
||||
|
||||
71
tests/e2e/bio-smoke.spec.js
Normal file
71
tests/e2e/bio-smoke.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user