mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-02 02:13:48 -05:00
Add shared logout flow component, update tests (#1404)
This commit is contained in:
@@ -8,6 +8,7 @@ import { useApiUrl } from '@/utils/ApiUrlUtility';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useLogout } from '@/hooks/useLogout';
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
@@ -18,9 +19,6 @@ import { InlineSkeletonLoader } from '@/components/ui/InlineSkeletonLoader';
|
||||
import { TitleContainer } from '@/components/ui/TitleContainer';
|
||||
import { UsernameDisplay } from '@/components/ui/UsernameDisplay';
|
||||
import { useApp } from '@/context/AppContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
|
||||
/**
|
||||
* Settings screen.
|
||||
@@ -31,9 +29,7 @@ export default function SettingsScreen() : React.ReactNode {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { getAuthMethodDisplayKey, shouldShowAutofillReminder } = useApp();
|
||||
const { getAutoLockTimeout, getClipboardClearTimeout } = useApp();
|
||||
const { clearAuthUserInitiated } = useAuth();
|
||||
const { isDirty } = useDb();
|
||||
const webApi = useWebApi();
|
||||
const { logoutUserInitiated } = useLogout();
|
||||
const { loadApiUrl, getDisplayUrl } = useApiUrl();
|
||||
const scrollY = useRef(new Animated.Value(0)).current;
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
@@ -112,56 +108,6 @@ export default function SettingsScreen() : React.ReactNode {
|
||||
}, [getAutoLockTimeout, getAuthMethodDisplayKey, setIsFirstLoad, loadApiUrl, getClipboardClearTimeout, t])
|
||||
);
|
||||
|
||||
/**
|
||||
* Perform the actual logout - revokes tokens and clears auth.
|
||||
*/
|
||||
const performLogout = async () : Promise<void> => {
|
||||
try {
|
||||
await webApi.revokeTokens();
|
||||
} catch (error) {
|
||||
console.error('Error revoking tokens:', error);
|
||||
// Continue with logout even if revoke fails
|
||||
}
|
||||
await clearAuthUserInitiated();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the logout button press.
|
||||
* Shows warning if there are unsynced changes, otherwise shows normal confirmation.
|
||||
*/
|
||||
const handleLogout = async () : Promise<void> => {
|
||||
if (isDirty) {
|
||||
// Show warning about unsynced changes
|
||||
Alert.alert(
|
||||
t('logout.unsyncedChangesTitle'),
|
||||
t('logout.unsyncedChangesWarning'),
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('logout.logoutAnyway'),
|
||||
style: 'destructive',
|
||||
onPress: performLogout,
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// Show normal confirmation dialog
|
||||
Alert.alert(
|
||||
t('auth.logout'),
|
||||
t('auth.confirmLogout'),
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('auth.logout'),
|
||||
style: 'destructive',
|
||||
onPress: performLogout,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the vault unlock press.
|
||||
*/
|
||||
@@ -547,7 +493,7 @@ export default function SettingsScreen() : React.ReactNode {
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={handleLogout}
|
||||
onPress={logoutUserInitiated}
|
||||
>
|
||||
<View style={styles.settingItemIcon}>
|
||||
<Ionicons name="log-out" size={20} color={colors.primary} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useLogout } from '@/hooks/useLogout';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
import Logo from '@/assets/images/logo.svg';
|
||||
@@ -25,7 +26,8 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
* Unlock screen.
|
||||
*/
|
||||
export default function UnlockScreen() : React.ReactNode {
|
||||
const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams, logout } = useApp();
|
||||
const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams } = useApp();
|
||||
const { logoutUserInitiated, logoutForced } = useLogout();
|
||||
const dbContext = useDb();
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -48,12 +50,12 @@ export default function UnlockScreen() : React.ReactNode {
|
||||
const getKeyDerivationParams = useCallback(async () : Promise<{ salt: string; encryptionType: string; encryptionSettings: string } | null> => {
|
||||
const params = await getEncryptionKeyDerivationParams();
|
||||
if (!params) {
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
// No params means corrupted state - force logout without confirmation
|
||||
await logoutForced();
|
||||
return null;
|
||||
}
|
||||
return params;
|
||||
}, [logout, getEncryptionKeyDerivationParams]);
|
||||
}, [logoutForced, getEncryptionKeyDerivationParams]);
|
||||
|
||||
useEffect(() => {
|
||||
getKeyDerivationParams();
|
||||
@@ -140,7 +142,8 @@ export default function UnlockScreen() : React.ReactNode {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof VaultVersionIncompatibleError) {
|
||||
await logout(t(error.message));
|
||||
// Vault version incompatible - force logout without confirmation
|
||||
await logoutForced();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,18 +158,6 @@ export default function UnlockScreen() : React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the logout.
|
||||
*/
|
||||
const handleLogout = async () : Promise<void> => {
|
||||
/*
|
||||
* Clear any stored tokens or session data
|
||||
* This will be handled by the auth context
|
||||
*/
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the biometrics retry.
|
||||
*/
|
||||
@@ -415,7 +406,7 @@ export default function UnlockScreen() : React.ReactNode {
|
||||
|
||||
<RobustPressable
|
||||
style={styles.logoutButton}
|
||||
onPress={handleLogout}
|
||||
onPress={logoutUserInitiated}
|
||||
testID="logout-button"
|
||||
>
|
||||
<ThemedText style={styles.logoutButtonText}>{t('auth.logout')}</ThemedText>
|
||||
|
||||
114
apps/mobile-app/hooks/useLogout.ts
Normal file
114
apps/mobile-app/hooks/useLogout.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
|
||||
type UseLogoutReturn = {
|
||||
/**
|
||||
* User-initiated logout (e.g., user clicks logout button).
|
||||
* Shows confirmation dialog and warns about unsynced changes.
|
||||
* Clears ALL data including vault.
|
||||
*/
|
||||
logoutUserInitiated: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Forced logout (e.g., corrupted state, incompatible vault version).
|
||||
* Logs out immediately without confirmation dialog.
|
||||
* Clears ALL data including vault.
|
||||
*/
|
||||
logoutForced: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for handling logout across the app.
|
||||
* Provides consistent logout behavior with:
|
||||
* - Warning about unsynced changes (isDirty check)
|
||||
* - Confirmation dialog for user-initiated logout
|
||||
* - Token revocation
|
||||
* - Complete auth and vault data clearance
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const { logoutUserInitiated, logoutForced } = useLogout();
|
||||
*
|
||||
* // For user clicking logout button:
|
||||
* await logoutUserInitiated();
|
||||
*
|
||||
* // For error scenarios requiring immediate logout:
|
||||
* await logoutForced();
|
||||
* ```
|
||||
*/
|
||||
export function useLogout(): UseLogoutReturn {
|
||||
const { t } = useTranslation();
|
||||
const { clearAuthUserInitiated } = useAuth();
|
||||
const { isDirty } = useDb();
|
||||
const webApi = useWebApi();
|
||||
|
||||
/**
|
||||
* Perform the actual logout - revokes tokens and clears auth.
|
||||
* Internal function used by both logout methods.
|
||||
*/
|
||||
const performLogout = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await webApi.revokeTokens();
|
||||
} catch (error) {
|
||||
console.error('Error revoking tokens:', error);
|
||||
// Continue with logout even if revoke fails
|
||||
}
|
||||
await clearAuthUserInitiated();
|
||||
router.replace('/login');
|
||||
}, [webApi, clearAuthUserInitiated]);
|
||||
|
||||
/**
|
||||
* Forced logout - logs out immediately without confirmation.
|
||||
* Use for error scenarios like corrupted state or incompatible vault version.
|
||||
*/
|
||||
const logoutForced = useCallback(async (): Promise<void> => {
|
||||
await performLogout();
|
||||
}, [performLogout]);
|
||||
|
||||
/**
|
||||
* User-initiated logout - shows confirmation dialog.
|
||||
* Shows warning if there are unsynced changes, otherwise shows normal confirmation.
|
||||
*/
|
||||
const logoutUserInitiated = useCallback(async (): Promise<void> => {
|
||||
if (isDirty) {
|
||||
// Show warning about unsynced changes
|
||||
Alert.alert(
|
||||
t('logout.unsyncedChangesTitle'),
|
||||
t('logout.unsyncedChangesWarning'),
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('logout.logoutAnyway'),
|
||||
style: 'destructive',
|
||||
onPress: performLogout,
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// Show normal confirmation dialog
|
||||
Alert.alert(
|
||||
t('auth.logout'),
|
||||
t('auth.confirmLogout'),
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('auth.logout'),
|
||||
style: 'destructive',
|
||||
onPress: performLogout,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}, [isDirty, t, performLogout]);
|
||||
|
||||
return {
|
||||
logoutUserInitiated,
|
||||
logoutForced,
|
||||
};
|
||||
}
|
||||
@@ -178,12 +178,11 @@ final class AliasVaultUITests: XCTestCase {
|
||||
)
|
||||
|
||||
let usernameInput = app.findTextField(testID: "username-input")
|
||||
usernameInput.tapNoIdle()
|
||||
usernameInput.typeText(testUser.username)
|
||||
usernameInput.clearAndTypeTextNoIdle(testUser.username)
|
||||
|
||||
let passwordInput = app.findTextField(testID: "password-input")
|
||||
passwordInput.tapNoIdle()
|
||||
passwordInput.typeText(testUser.password)
|
||||
passwordInput.typeTextNoIdle(testUser.password)
|
||||
|
||||
app.hideKeyboardIfVisible()
|
||||
|
||||
@@ -267,11 +266,11 @@ final class AliasVaultUITests: XCTestCase {
|
||||
XCTContext.runActivity(named: "Fill item details") { _ in
|
||||
let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input")
|
||||
itemNameInput.tapNoIdle()
|
||||
itemNameInput.typeText(uniqueName)
|
||||
itemNameInput.typeTextNoIdle(uniqueName)
|
||||
|
||||
let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input")
|
||||
serviceUrlInput.tapNoIdle()
|
||||
serviceUrlInput.typeText("https://example.com")
|
||||
serviceUrlInput.typeTextNoIdle("https://example.com")
|
||||
|
||||
let addEmailButton = app.findElement(testID: "add-email-button")
|
||||
app.scrollToElement(addEmailButton)
|
||||
@@ -279,13 +278,13 @@ final class AliasVaultUITests: XCTestCase {
|
||||
|
||||
let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input")
|
||||
loginEmailInput.tapNoIdle()
|
||||
loginEmailInput.typeText("e2e-test@example.com")
|
||||
loginEmailInput.typeTextNoIdle("e2e-test@example.com")
|
||||
|
||||
let loginUsernameInput = app.findAndScrollToTextField(testID: "login-username-input")
|
||||
if loginUsernameInput.exists {
|
||||
app.scrollToElement(loginUsernameInput)
|
||||
loginUsernameInput.tapNoIdle()
|
||||
loginUsernameInput.typeText("e2euser")
|
||||
loginUsernameInput.typeTextNoIdle("e2euser")
|
||||
}
|
||||
|
||||
app.hideKeyboardIfVisible()
|
||||
@@ -456,11 +455,11 @@ final class AliasVaultUITests: XCTestCase {
|
||||
|
||||
let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input")
|
||||
itemNameInput.tapNoIdle()
|
||||
itemNameInput.typeText(uniqueName)
|
||||
itemNameInput.typeTextNoIdle(uniqueName)
|
||||
|
||||
let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input")
|
||||
serviceUrlInput.tapNoIdle()
|
||||
serviceUrlInput.typeText("https://offline-test.example.com")
|
||||
serviceUrlInput.typeTextNoIdle("https://offline-test.example.com")
|
||||
|
||||
let addEmailButton = app.findElement(testID: "add-email-button")
|
||||
app.scrollToElement(addEmailButton)
|
||||
@@ -468,7 +467,7 @@ final class AliasVaultUITests: XCTestCase {
|
||||
|
||||
let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input")
|
||||
loginEmailInput.tapNoIdle()
|
||||
loginEmailInput.typeText("offline-test@example.com")
|
||||
loginEmailInput.typeTextNoIdle("offline-test@example.com")
|
||||
|
||||
app.hideKeyboardIfVisible()
|
||||
|
||||
@@ -675,11 +674,11 @@ final class AliasVaultUITests: XCTestCase {
|
||||
|
||||
let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input")
|
||||
itemNameInput.tapNoIdle()
|
||||
itemNameInput.typeText(uniqueName)
|
||||
itemNameInput.typeTextNoIdle(uniqueName)
|
||||
|
||||
let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input")
|
||||
serviceUrlInput.tapNoIdle()
|
||||
serviceUrlInput.typeText("https://rpo-test.example.com")
|
||||
serviceUrlInput.typeTextNoIdle("https://rpo-test.example.com")
|
||||
|
||||
let addEmailButton = app.findElement(testID: "add-email-button")
|
||||
app.scrollToElement(addEmailButton)
|
||||
@@ -687,7 +686,7 @@ final class AliasVaultUITests: XCTestCase {
|
||||
|
||||
let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input")
|
||||
loginEmailInput.tapNoIdle()
|
||||
loginEmailInput.typeText("rpo-test@example.com")
|
||||
loginEmailInput.typeTextNoIdle("rpo-test@example.com")
|
||||
|
||||
app.hideKeyboardIfVisible()
|
||||
|
||||
@@ -885,11 +884,11 @@ final class AliasVaultUITests: XCTestCase {
|
||||
|
||||
let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input")
|
||||
itemNameInput.tapNoIdle()
|
||||
itemNameInput.typeText(uniqueName)
|
||||
itemNameInput.typeTextNoIdle(uniqueName)
|
||||
|
||||
let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input")
|
||||
serviceUrlInput.tapNoIdle()
|
||||
serviceUrlInput.typeText("https://forced-logout-test.example.com")
|
||||
serviceUrlInput.typeTextNoIdle("https://forced-logout-test.example.com")
|
||||
|
||||
let addEmailButton = app.findElement(testID: "add-email-button")
|
||||
app.scrollToElement(addEmailButton)
|
||||
@@ -897,7 +896,7 @@ final class AliasVaultUITests: XCTestCase {
|
||||
|
||||
let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input")
|
||||
loginEmailInput.tapNoIdle()
|
||||
loginEmailInput.typeText("forced-logout-test@example.com")
|
||||
loginEmailInput.typeTextNoIdle("forced-logout-test@example.com")
|
||||
|
||||
app.hideKeyboardIfVisible()
|
||||
|
||||
@@ -1020,12 +1019,11 @@ final class AliasVaultUITests: XCTestCase {
|
||||
XCTContext.runActivity(named: "Step 6: Re-login with same credentials") { _ in
|
||||
// Clear username field and enter credentials
|
||||
let usernameInput = app.findTextField(testID: "username-input")
|
||||
usernameInput.tapNoIdle()
|
||||
usernameInput.clearAndTypeTextNoIdle(testUser.username)
|
||||
|
||||
let passwordInput = app.findTextField(testID: "password-input")
|
||||
passwordInput.tapNoIdle()
|
||||
passwordInput.typeText(testUser.password)
|
||||
passwordInput.typeTextNoIdle(testUser.password)
|
||||
|
||||
app.hideKeyboardIfVisible()
|
||||
|
||||
@@ -1121,11 +1119,31 @@ final class AliasVaultUITests: XCTestCase {
|
||||
/// - Parameter testUser: The test user to login with
|
||||
@MainActor
|
||||
private func loginWithTestUser(_ testUser: TestUser) {
|
||||
sleep(1) // Allow app to settle
|
||||
|
||||
// Wait for app to settle and reach a known state (unlock, login, or items screen)
|
||||
// Use longer initial timeout since app may still be loading after launch
|
||||
let unlockScreen = app.findElement(testID: "unlock-screen")
|
||||
if unlockScreen.waitForExistenceNoIdle(timeout: 3) {
|
||||
// We're on unlock screen - logout to start fresh with test user
|
||||
let loginScreen = app.findElement(testID: "login-screen")
|
||||
let itemsScreen = app.findElement(testID: "items-screen")
|
||||
|
||||
// Wait up to 15 seconds for any of the expected screens to appear
|
||||
var screenFound = false
|
||||
let startTime = Date()
|
||||
let maxWaitTime: TimeInterval = 15
|
||||
|
||||
while !screenFound && Date().timeIntervalSince(startTime) < maxWaitTime {
|
||||
if unlockScreen.exists || loginScreen.exists || itemsScreen.exists {
|
||||
screenFound = true
|
||||
} else {
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
if !screenFound {
|
||||
print("[Helper] Warning: No expected screen found after \(maxWaitTime)s, proceeding anyway")
|
||||
}
|
||||
|
||||
// Handle unlock screen - logout to start fresh with test user
|
||||
if unlockScreen.exists {
|
||||
print("[Helper] Unlock screen detected - logging out to login fresh with test user")
|
||||
|
||||
let logoutButton = app.findElement(testID: "logout-button")
|
||||
@@ -1140,26 +1158,23 @@ final class AliasVaultUITests: XCTestCase {
|
||||
}
|
||||
|
||||
// Wait for login screen
|
||||
let loginScreen = app.findElement(testID: "login-screen")
|
||||
_ = loginScreen.waitForExistenceNoIdle(timeout: 10)
|
||||
}
|
||||
|
||||
// Check if we're on login screen
|
||||
let loginScreen = app.findElement(testID: "login-screen")
|
||||
if loginScreen.waitForExistenceNoIdle(timeout: 3) {
|
||||
if loginScreen.waitForExistenceNoIdle(timeout: 2) {
|
||||
performLogin(with: testUser)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we're already on items screen (already logged in as correct user)
|
||||
let itemsScreen = app.findElement(testID: "items-screen")
|
||||
if itemsScreen.waitForExistenceNoIdle(timeout: 3) {
|
||||
if itemsScreen.waitForExistenceNoIdle(timeout: 2) {
|
||||
print("[Helper] Already on items screen, assuming correct user is logged in")
|
||||
return
|
||||
}
|
||||
|
||||
// Unknown state - try to navigate to login
|
||||
print("[Helper] Unknown app state, waiting for login or items screen")
|
||||
// Unknown state - log warning but continue (test will fail with appropriate error if needed)
|
||||
print("[Helper] Unknown app state after waiting, test may fail")
|
||||
}
|
||||
|
||||
/// Unlocks the vault if the unlock screen is displayed.
|
||||
@@ -1169,7 +1184,7 @@ final class AliasVaultUITests: XCTestCase {
|
||||
@MainActor
|
||||
private func unlockVaultIfNeeded(with testUser: TestUser) {
|
||||
let unlockScreen = app.findElement(testID: "unlock-screen")
|
||||
guard unlockScreen.waitForExistenceNoIdle(timeout: 3) else {
|
||||
guard unlockScreen.waitForExistenceNoIdle(timeout: 1) else {
|
||||
// Not on unlock screen, nothing to do
|
||||
return
|
||||
}
|
||||
@@ -1178,16 +1193,16 @@ final class AliasVaultUITests: XCTestCase {
|
||||
|
||||
// Enter password in unlock screen
|
||||
let passwordInput = app.findTextField(testID: "unlock-password-input")
|
||||
if passwordInput.waitForExistenceNoIdle(timeout: 5) {
|
||||
if passwordInput.waitForExistenceNoIdle(timeout: 1) {
|
||||
passwordInput.tapNoIdle()
|
||||
passwordInput.typeText(testUser.password)
|
||||
passwordInput.typeTextNoIdle(testUser.password)
|
||||
}
|
||||
|
||||
app.hideKeyboardIfVisible()
|
||||
|
||||
// Tap unlock button
|
||||
let unlockButton = app.findElement(testID: "unlock-button")
|
||||
if unlockButton.waitForExistenceNoIdle(timeout: 3) {
|
||||
if unlockButton.waitForExistenceNoIdle(timeout: 1) {
|
||||
unlockButton.tapNoIdle()
|
||||
}
|
||||
|
||||
@@ -1259,7 +1274,7 @@ final class AliasVaultUITests: XCTestCase {
|
||||
let passwordInput = app.findTextField(testID: "password-input")
|
||||
if passwordInput.waitForExistenceNoIdle(timeout: 3) {
|
||||
passwordInput.tapNoIdle()
|
||||
passwordInput.typeText(testUser.password)
|
||||
passwordInput.typeTextNoIdle(testUser.password)
|
||||
}
|
||||
|
||||
app.hideKeyboardIfVisible()
|
||||
|
||||
@@ -22,17 +22,52 @@ extension XCUIElement {
|
||||
}
|
||||
|
||||
/// Clear text field and enter new text without waiting for idle.
|
||||
/// Waits for keyboard to be ready before typing to prevent missing characters.
|
||||
@MainActor
|
||||
func clearAndTypeTextNoIdle(_ text: String) {
|
||||
guard let currentValue = self.value as? String, !currentValue.isEmpty else {
|
||||
func clearAndTypeTextNoIdle(_ text: String, app: XCUIApplication? = nil) {
|
||||
// Tap to focus
|
||||
self.tapNoIdle()
|
||||
|
||||
// Wait for keyboard to appear (critical for preventing missing characters)
|
||||
let application = app ?? XCUIApplication()
|
||||
let keyboardReady = application.keyboards.firstMatch.waitForExistenceNoIdle(timeout: 3)
|
||||
if !keyboardReady {
|
||||
// Retry tap if keyboard didn't appear
|
||||
Thread.sleep(forTimeInterval: 0.2)
|
||||
self.tapNoIdle()
|
||||
self.typeText(text)
|
||||
return
|
||||
_ = application.keyboards.firstMatch.waitForExistenceNoIdle(timeout: 2)
|
||||
}
|
||||
|
||||
self.tapNoIdle()
|
||||
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
||||
self.typeText(deleteString)
|
||||
// Small delay to ensure keyboard is fully ready
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
|
||||
// Clear existing text if any
|
||||
if let currentValue = self.value as? String, !currentValue.isEmpty {
|
||||
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
||||
self.typeText(deleteString)
|
||||
}
|
||||
|
||||
// Type the new text
|
||||
self.typeText(text)
|
||||
}
|
||||
|
||||
/// Type text with keyboard wait to prevent missing characters.
|
||||
/// Use this instead of raw typeText() for more reliable input.
|
||||
@MainActor
|
||||
func typeTextNoIdle(_ text: String, app: XCUIApplication? = nil) {
|
||||
// Wait for keyboard to appear
|
||||
let application = app ?? XCUIApplication()
|
||||
let keyboardReady = application.keyboards.firstMatch.waitForExistenceNoIdle(timeout: 3)
|
||||
if !keyboardReady {
|
||||
// Tap to focus if keyboard not visible
|
||||
self.tapNoIdle()
|
||||
_ = application.keyboards.firstMatch.waitForExistenceNoIdle(timeout: 2)
|
||||
}
|
||||
|
||||
// Small delay to ensure keyboard is fully ready
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
|
||||
// Type the text
|
||||
self.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user