From 8bee44851f2f3fc6ed0c2a076df8d8a72f9634c5 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 13 Jan 2026 12:10:48 +0100 Subject: [PATCH] Add item create iOS maestro test (#1404) --- .../.maestro/flows/03-successful-login.yaml | 5 +- .../.maestro/flows/04-create-item.yaml | 101 ++++++++++++++++++ .../.maestro/utils/generate-unique-name.js | 11 ++ apps/mobile-app/app/(tabs)/items/[id].tsx | 24 ++++- apps/mobile-app/app/(tabs)/items/add-edit.tsx | 30 +++++- apps/mobile-app/app/login-settings.tsx | 16 ++- .../components/ServerSyncIndicator.tsx | 3 +- .../components/form/AdvancedPasswordField.tsx | 5 + .../components/form/EmailDomainField.tsx | 7 +- apps/mobile-app/components/form/FormField.tsx | 5 + .../components/form/HiddenField.tsx | 5 + apps/mobile-app/components/items/ItemCard.tsx | 1 + .../components/ui/HeaderBackButton.tsx | 57 ++++++++++ .../components/ui/RobustPressable.tsx | 4 + 14 files changed, 258 insertions(+), 16 deletions(-) create mode 100644 apps/mobile-app/.maestro/flows/04-create-item.yaml create mode 100644 apps/mobile-app/.maestro/utils/generate-unique-name.js create mode 100644 apps/mobile-app/components/ui/HeaderBackButton.tsx diff --git a/apps/mobile-app/.maestro/flows/03-successful-login.yaml b/apps/mobile-app/.maestro/flows/03-successful-login.yaml index d4ffc8dc1..1fddfb47b 100644 --- a/apps/mobile-app/.maestro/flows/03-successful-login.yaml +++ b/apps/mobile-app/.maestro/flows/03-successful-login.yaml @@ -45,8 +45,9 @@ appId: net.aliasvault.app - hideKeyboard -# Go back to login screen (cross-platform) -- runFlow: ../utils/go-back.yaml +# Go back to login screen +- tapOn: + id: "back-button" # Wait for login screen to be visible again - extendedWaitUntil: diff --git a/apps/mobile-app/.maestro/flows/04-create-item.yaml b/apps/mobile-app/.maestro/flows/04-create-item.yaml new file mode 100644 index 000000000..a6179ef1b --- /dev/null +++ b/apps/mobile-app/.maestro/flows/04-create-item.yaml @@ -0,0 +1,101 @@ +# Test 04: Create New Item +# Verifies item creation flow +# +# Prerequisites: User is logged in (run 03-successful-login.yaml first) +# Expected: New item is created and visible in vault + +appId: net.aliasvault.app +--- +# Generate unique item name using timestamp +- runScript: + file: ../utils/generate-unique-name.js + env: + PREFIX: "E2E Test" + +# Ensure we're on the items screen (assumes logged in) +- assertVisible: + id: "items-screen" + optional: true + +# Tap the FAB (Floating Action Button) to add new item +- tapOn: + id: "add-item-button" + +# Wait for add/edit screen to load +- extendedWaitUntil: + visible: + id: "add-edit-screen" + timeout: 10000 + +- takeScreenshot: "04-1-add-item-screen" + +# Enter item name (uses unique name generated by script) +- tapOn: + id: "item-name-input" +- inputText: ${output.UNIQUE_NAME} + +# Enter service URL +- tapOn: + id: "service-url-input" +- inputText: "https://example.com" + +# Add email field (not visible by default for Login type) +- tapOn: + id: "add-email-button" + +# Enter email +- tapOn: + id: "login-email-input" +- inputText: "e2e-test@example.com" + +# Enter username (optional - field may not be visible) +- tapOn: + id: "login-username-input" + optional: true +- inputText: + text: "e2euser" + optional: true + +- hideKeyboard + +- takeScreenshot: "04-2-item-filled" + +# Save the item +- tapOn: + id: "save-button" + +# Wait for item detail screen to load (app navigates here after save) +- extendedWaitUntil: + visible: + text: "Login credentials" + timeout: 10000 + +- takeScreenshot: "04-3-item-detail-screen" + +# Wait for back button to be ready +- extendedWaitUntil: + visible: "Wait_for_1_sec" + optional: true + timeout: 1000 + +# Go back to items list +- tapOn: + id: "back-button" + +# Wait for items screen to be visible +- extendedWaitUntil: + visible: + id: "items-screen" + timeout: 10000 + +# Verify the newly created item appears in the list by tapping on it +- tapOn: + text: ${output.UNIQUE_NAME} + +# Wait for item detail screen to confirm we tapped the right item +- extendedWaitUntil: + visible: + text: "Login credentials" + timeout: 10000 + +- takeScreenshot: "04-4-item-verified" diff --git a/apps/mobile-app/.maestro/utils/generate-unique-name.js b/apps/mobile-app/.maestro/utils/generate-unique-name.js new file mode 100644 index 000000000..ac1adec45 --- /dev/null +++ b/apps/mobile-app/.maestro/utils/generate-unique-name.js @@ -0,0 +1,11 @@ +/* global PREFIX, output */ +// Generate a unique name using timestamp +// Usage: Set PREFIX env var to customize the prefix (default: "Test") +// Output: UNIQUE_NAME variable containing the generated name + +const prefix = PREFIX || 'Test'; +const timestamp = Date.now(); +// Use last 6 digits to keep it readable but unique +const shortId = String(timestamp).slice(-6); + +output.UNIQUE_NAME = `${prefix} ${shortId}`; diff --git a/apps/mobile-app/app/(tabs)/items/[id].tsx b/apps/mobile-app/app/(tabs)/items/[id].tsx index fcb94702f..9b5d0c4d1 100644 --- a/apps/mobile-app/app/(tabs)/items/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/[id].tsx @@ -1,7 +1,8 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; -import { ActivityIndicator, View, Text, StyleSheet, Linking, Platform } from 'react-native' +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, View, Text, StyleSheet, Linking, Platform } from 'react-native'; import Toast from 'react-native-toast-message'; import type { Item } from '@/utils/dist/core/models/vault'; @@ -21,6 +22,7 @@ import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; import { ThemedText } from '@/components/themed/ThemedText'; import { ThemedView } from '@/components/themed/ThemedView'; +import { HeaderBackButton } from '@/components/ui/HeaderBackButton'; import { RobustPressable } from '@/components/ui/RobustPressable'; import { useDb } from '@/context/DbContext'; @@ -35,6 +37,7 @@ export default function ItemDetailsScreen() : React.ReactNode { const navigation = useNavigation(); const colors = useColors(); const router = useRouter(); + const { t } = useTranslation(); /** * Handle the edit button press. @@ -45,11 +48,20 @@ export default function ItemDetailsScreen() : React.ReactNode { // Set header buttons useEffect(() => { - navigation.setOptions({ + const headerOptions: Record = { + /** + * Header back button. + */ + headerLeft: (): React.ReactNode => ( + router.back()} + /> + ), /** * Header right button. */ - headerRight: () => ( + headerRight: (): React.ReactNode => ( ), - }); - }, [navigation, item, handleEdit, colors.primary]); + }; + + navigation.setOptions(headerOptions); + }, [navigation, item, handleEdit, colors.primary, router, t]); useEffect(() => { /** diff --git a/apps/mobile-app/app/(tabs)/items/add-edit.tsx b/apps/mobile-app/app/(tabs)/items/add-edit.tsx index f4f9b8f9a..3bac7f253 100644 --- a/apps/mobile-app/app/(tabs)/items/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/items/add-edit.tsx @@ -6,7 +6,7 @@ import * as Haptics from 'expo-haptics'; import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View, Alert, Keyboard, Platform, ScrollView, KeyboardAvoidingView } from 'react-native'; +import { StyleSheet, View, Alert, Keyboard, Platform, ScrollView, KeyboardAvoidingView, TouchableOpacity } from 'react-native'; import Toast from 'react-native-toast-message'; import type { Folder } from '@/utils/db/repositories/FolderRepository'; @@ -959,6 +959,19 @@ export default function AddEditItemScreen(): React.ReactNode { } }, [t]); + /** + * Get testID for a field based on its field key. + */ + const getFieldTestId = useCallback((fieldKey: string): string | undefined => { + const testIdMap: Record = { + 'login.url': 'service-url-input', + 'login.email': 'login-email-input', + 'login.username': 'login-username-input', + 'login.password': 'login-password-input', + }; + return testIdMap[fieldKey]; + }, []); + /** * Render a field input based on field type. */ @@ -972,6 +985,7 @@ export default function AddEditItemScreen(): React.ReactNode { ): React.ReactNode => { const value = fieldValues[fieldKey] || ''; const stringValue = Array.isArray(value) ? value[0] || '' : value; + const testID = getFieldTestId(fieldKey); switch (fieldType) { case FieldTypes.Password: @@ -984,6 +998,7 @@ export default function AddEditItemScreen(): React.ReactNode { onShowPasswordChange={setIsPasswordVisible} isNewCredential={!isEditMode} onRemove={onRemove} + testID={testID} /> ); @@ -995,6 +1010,7 @@ export default function AddEditItemScreen(): React.ReactNode { label={label} keyboardType={fieldKey === 'card.pin' || fieldKey === 'card.cvv' ? 'numeric' : 'default'} onRemove={onRemove} + testID={testID} /> ); @@ -1005,6 +1021,7 @@ export default function AddEditItemScreen(): React.ReactNode { onChange={(val) => handleFieldChange(fieldKey, val)} label={label} onRemove={onRemove} + testID={testID} /> ); @@ -1018,6 +1035,7 @@ export default function AddEditItemScreen(): React.ReactNode { numberOfLines={4} textAlignVertical="top" onRemove={onRemove} + testID={testID} /> ); @@ -1039,6 +1057,7 @@ export default function AddEditItemScreen(): React.ReactNode { onPress: generateRandomUsername }]} onRemove={onRemove} + testID={testID} /> ); } @@ -1050,10 +1069,11 @@ export default function AddEditItemScreen(): React.ReactNode { placeholder={fieldKey === 'alias.birthdate' ? t('items.birthDatePlaceholder') : undefined} keyboardType={fieldType === FieldTypes.Phone || fieldType === FieldTypes.Number ? 'numeric' : 'default'} onRemove={onRemove} + testID={testID} /> ); } - }, [fieldValues, handleFieldChange, isPasswordVisible, isEditMode, aliasFieldsShownByDefault, generateRandomUsername, t]); + }, [fieldValues, handleFieldChange, isPasswordVisible, isEditMode, aliasFieldsShownByDefault, generateRandomUsername, t, getFieldTestId]); const styles = StyleSheet.create({ container: { @@ -1184,18 +1204,19 @@ export default function AddEditItemScreen(): React.ReactNode { * Show the save button */ headerRight: () => ( - - + ), }); }, [navigation, onSubmit, colors.primary, isEditMode, router, styles.headerLeftButton, styles.headerLeftButtonText, styles.headerRightButton, styles.headerRightButtonDisabled, isSaveDisabled, t, handleCancel]); @@ -1381,6 +1402,7 @@ export default function AddEditItemScreen(): React.ReactNode { handleAddOptionalField('login.email')} style={styles.addEmailBadge} + testID="add-email-button" > diff --git a/apps/mobile-app/app/login-settings.tsx b/apps/mobile-app/app/login-settings.tsx index 71c7e87f0..f18278c05 100644 --- a/apps/mobile-app/app/login-settings.tsx +++ b/apps/mobile-app/app/login-settings.tsx @@ -1,4 +1,4 @@ -import { useNavigation } from 'expo-router'; +import { useNavigation, useRouter } from 'expo-router'; import { useState, useEffect, useCallback, useMemo, useLayoutEffect } from 'react'; import { StyleSheet, View, Text, TextInput, ActivityIndicator } from 'react-native'; @@ -10,6 +10,7 @@ import { useTranslation } from '@/hooks/useTranslation'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; import { ThemedView } from '@/components/themed/ThemedView'; +import { HeaderBackButton } from '@/components/ui/HeaderBackButton'; import { RobustPressable } from '@/components/ui/RobustPressable'; import NativeVaultManager from '@/specs/NativeVaultManager'; @@ -24,6 +25,7 @@ type ApiOption = { export default function SettingsScreen() : React.ReactNode { const colors = useColors(); const navigation = useNavigation(); + const router = useRouter(); const { t } = useTranslation(); const [selectedOption, setSelectedOption] = useState(AppInfo.DEFAULT_API_URL); const [customUrl, setCustomUrl] = useState(''); @@ -37,9 +39,17 @@ export default function SettingsScreen() : React.ReactNode { useLayoutEffect(() => { navigation.setOptions({ title: t('app.navigation.loginSettings'), - headerBackTitle: t('app.navigation.login'), + /** + * Header left button (custom back button with testID for E2E tests). + */ + headerLeft: (): React.ReactNode => ( + router.back()} + /> + ), }); - }, [navigation, t]); + }, [navigation, router, t]); /** * Load the stored settings from native layer. diff --git a/apps/mobile-app/components/ServerSyncIndicator.tsx b/apps/mobile-app/components/ServerSyncIndicator.tsx index 3b8d1376d..560e67104 100644 --- a/apps/mobile-app/components/ServerSyncIndicator.tsx +++ b/apps/mobile-app/components/ServerSyncIndicator.tsx @@ -185,6 +185,7 @@ export function ServerSyncIndicator(): React.ReactNode { style={[styles.container, styles.offline]} onPress={handleRetry} disabled={isRetrying} + testID="sync-indicator-offline" > {isRetrying ? ( @@ -209,7 +210,7 @@ export function ServerSyncIndicator(): React.ReactNode { // Uses showSyncing which has minimum display time to prevent flickering if (showSyncing) { return ( - + {t('sync.syncing')} diff --git a/apps/mobile-app/components/form/AdvancedPasswordField.tsx b/apps/mobile-app/components/form/AdvancedPasswordField.tsx index 8feea8e07..b85a5071d 100644 --- a/apps/mobile-app/components/form/AdvancedPasswordField.tsx +++ b/apps/mobile-app/components/form/AdvancedPasswordField.tsx @@ -27,6 +27,8 @@ type AdvancedPasswordFieldProps = Omit isNewCredential?: boolean; /** Optional callback for remove button - when provided, shows X button in label row */ onRemove?: () => void; + /** Optional testID for the text input */ + testID?: string; } const AdvancedPasswordFieldComponent = forwardRef(({ @@ -38,6 +40,7 @@ const AdvancedPasswordFieldComponent = forwardRef { const colors = useColors(); @@ -386,6 +389,8 @@ const AdvancedPasswordFieldComponent = forwardRef diff --git a/apps/mobile-app/components/form/EmailDomainField.tsx b/apps/mobile-app/components/form/EmailDomainField.tsx index eac1a6f90..56cba26e4 100644 --- a/apps/mobile-app/components/form/EmailDomainField.tsx +++ b/apps/mobile-app/components/form/EmailDomainField.tsx @@ -16,6 +16,8 @@ type EmailDomainFieldProps = { label: string; /** Optional callback for remove button - when provided, shows X button in label row */ onRemove?: () => void; + /** Optional testID for the text input */ + testID?: string; } // Hardcoded public email domains (same as in browser extension) @@ -42,7 +44,8 @@ export const EmailDomainField: React.FC = ({ error, required = false, label, - onRemove + onRemove, + testID }) => { const { t } = useTranslation(); const colors = useColors(); @@ -371,6 +374,8 @@ export const EmailDomainField: React.FC = ({ keyboardType="email-address" multiline={false} numberOfLines={1} + testID={testID} + accessibilityLabel={testID} /> {!isCustomDomain && ( diff --git a/apps/mobile-app/components/form/FormField.tsx b/apps/mobile-app/components/form/FormField.tsx index a113022d9..b217257e8 100644 --- a/apps/mobile-app/components/form/FormField.tsx +++ b/apps/mobile-app/components/form/FormField.tsx @@ -25,6 +25,8 @@ type FormFieldProps = Omit & { error?: string; /** Optional callback for remove button - when provided, shows X button in label row */ onRemove?: () => void; + /** Optional testID for the text input */ + testID?: string; } /** @@ -38,6 +40,7 @@ const FormFieldComponent = forwardRef(({ buttons, error, onRemove, + testID, ...props }, ref) => { const colors = useColors(); @@ -143,6 +146,8 @@ const FormFieldComponent = forwardRef(({ clearButtonMode={Platform.OS === 'ios' ? "while-editing" : "never"} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} + testID={testID} + accessibilityLabel={testID} {...props} /> {showClearButton && ( diff --git a/apps/mobile-app/components/form/HiddenField.tsx b/apps/mobile-app/components/form/HiddenField.tsx index 014aa9f73..219d39b9a 100644 --- a/apps/mobile-app/components/form/HiddenField.tsx +++ b/apps/mobile-app/components/form/HiddenField.tsx @@ -20,6 +20,8 @@ type HiddenFieldProps = { keyboardType?: 'default' | 'numeric'; /** Optional callback for remove button */ onRemove?: () => void; + /** Optional testID for the text input */ + testID?: string; }; /** @@ -33,6 +35,7 @@ export const HiddenField: React.FC = ({ placeholder, keyboardType = 'default', onRemove, + testID, }) => { const colors = useColors(); const [isVisible, setIsVisible] = useState(false); @@ -97,6 +100,8 @@ export const HiddenField: React.FC = ({ keyboardType={keyboardType} autoCapitalize="none" autoCorrect={false} + testID={testID} + accessibilityLabel={testID} /> setIsVisible(!isVisible)} diff --git a/apps/mobile-app/components/items/ItemCard.tsx b/apps/mobile-app/components/items/ItemCard.tsx index 27d4e4f4a..50aa39e54 100644 --- a/apps/mobile-app/components/items/ItemCard.tsx +++ b/apps/mobile-app/components/items/ItemCard.tsx @@ -289,6 +289,7 @@ export function ItemCard({ item, onItemDelete }: ItemCardProps): React.ReactNode }} activeOpacity={0.7} testID="item-card" + accessibilityLabel={item.Name} > diff --git a/apps/mobile-app/components/ui/HeaderBackButton.tsx b/apps/mobile-app/components/ui/HeaderBackButton.tsx new file mode 100644 index 000000000..fb12bd65c --- /dev/null +++ b/apps/mobile-app/components/ui/HeaderBackButton.tsx @@ -0,0 +1,57 @@ +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import React from 'react'; +import { StyleSheet, Text } from 'react-native'; + +import { useColors } from '@/hooks/useColorScheme'; + +import { RobustPressable } from './RobustPressable'; + +type HeaderBackButtonProps = { + /** The label to display next to the back arrow */ + label: string; + /** Callback when the button is pressed */ + onPress: () => void; + /** Optional testID for E2E testing */ + testID?: string; +}; + +/** + * A reusable header back button component for navigation headers. + * Displays a chevron icon with a label, styled to match iOS navigation patterns. + * Uses RobustPressable for reliable touch handling in E2E tests. + */ +export const HeaderBackButton: React.FC = ({ + label, + onPress, + testID = 'back-button', +}) => { + const colors = useColors(); + + return ( + + + + {label} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + marginLeft: -8, + paddingHorizontal: 8, + }, + label: { + fontSize: 17, + }, +}); diff --git a/apps/mobile-app/components/ui/RobustPressable.tsx b/apps/mobile-app/components/ui/RobustPressable.tsx index 3f48fde8d..60bbc55ca 100644 --- a/apps/mobile-app/components/ui/RobustPressable.tsx +++ b/apps/mobile-app/components/ui/RobustPressable.tsx @@ -9,6 +9,7 @@ interface IRobustPressableProps { disabled?: boolean; pressRetentionOffset?: number; hitSlop?: number; + testID?: string; } /** @@ -24,6 +25,7 @@ export const RobustPressable: React.FC = ({ disabled, pressRetentionOffset = 10, hitSlop = 10, + testID, }) => { return ( = ({ style, { opacity: pressed ? 0.6 : 1 }, ]} + testID={testID} + accessibilityLabel={testID} > {children}