Add item create iOS maestro test (#1404)

This commit is contained in:
Leendert de Borst
2026-01-13 12:10:48 +01:00
parent 426618ab90
commit 8bee44851f
14 changed files with 258 additions and 16 deletions

View File

@@ -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:

View File

@@ -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"

View File

@@ -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}`;

View File

@@ -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<string, unknown> = {
/**
* Header back button.
*/
headerLeft: (): React.ReactNode => (
<HeaderBackButton
label={t('items.title')}
onPress={() => router.back()}
/>
),
/**
* Header right button.
*/
headerRight: () => (
headerRight: (): React.ReactNode => (
<View style={styles.headerRightContainer}>
<RobustPressable
onPress={handleEdit}
@@ -63,8 +75,10 @@ export default function ItemDetailsScreen() : React.ReactNode {
</RobustPressable>
</View>
),
});
}, [navigation, item, handleEdit, colors.primary]);
};
navigation.setOptions(headerOptions);
}, [navigation, item, handleEdit, colors.primary, router, t]);
useEffect(() => {
/**

View File

@@ -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<string, string> = {
'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: () => (
<RobustPressable
<TouchableOpacity
onPress={onSubmit}
style={[styles.headerRightButton, isSaveDisabled && styles.headerRightButtonDisabled]}
disabled={isSaveDisabled}
testID="save-button"
accessibilityLabel="save-button"
>
<MaterialIcons
name="save"
size={Platform.OS === 'android' ? 24 : 22}
color={colors.primary}
/>
</RobustPressable>
</TouchableOpacity>
),
});
}, [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 {
<RobustPressable
onPress={() => handleAddOptionalField('login.email')}
style={styles.addEmailBadge}
testID="add-email-button"
>
<MaterialIcons name="add" size={14} color={colors.textMuted} />
<ThemedText style={styles.addEmailBadgeText}>

View File

@@ -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<string>(AppInfo.DEFAULT_API_URL);
const [customUrl, setCustomUrl] = useState<string>('');
@@ -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 => (
<HeaderBackButton
label={t('app.navigation.login')}
onPress={() => router.back()}
/>
),
});
}, [navigation, t]);
}, [navigation, router, t]);
/**
* Load the stored settings from native layer.

View File

@@ -185,6 +185,7 @@ export function ServerSyncIndicator(): React.ReactNode {
style={[styles.container, styles.offline]}
onPress={handleRetry}
disabled={isRetrying}
testID="sync-indicator-offline"
>
<View>
{isRetrying ? (
@@ -209,7 +210,7 @@ export function ServerSyncIndicator(): React.ReactNode {
// Uses showSyncing which has minimum display time to prevent flickering
if (showSyncing) {
return (
<View style={[styles.container, styles.syncing]}>
<View style={[styles.container, styles.syncing]} testID="sync-indicator-syncing">
<ActivityIndicator size="small" color={colors.success ?? '#16a34a'} />
<ThemedText style={[styles.text, styles.syncingText]}>
{t('sync.syncing')}

View File

@@ -27,6 +27,8 @@ type AdvancedPasswordFieldProps = Omit<TextInputProps, 'value' | 'onChangeText'>
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<AdvancedPasswordFieldRef, AdvancedPasswordFieldProps>(({
@@ -38,6 +40,7 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
onShowPasswordChange,
isNewCredential = false,
onRemove,
testID,
...props
}, ref) => {
const colors = useColors();
@@ -386,6 +389,8 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
autoCorrect={false}
clearButtonMode={Platform.OS === 'ios' ? "while-editing" : "never"}
secureTextEntry={!showPassword}
testID={testID}
accessibilityLabel={testID}
{...props}
/>

View File

@@ -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<EmailDomainFieldProps> = ({
error,
required = false,
label,
onRemove
onRemove,
testID
}) => {
const { t } = useTranslation();
const colors = useColors();
@@ -371,6 +374,8 @@ export const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
keyboardType="email-address"
multiline={false}
numberOfLines={1}
testID={testID}
accessibilityLabel={testID}
/>
{!isCustomDomain && (

View File

@@ -25,6 +25,8 @@ type FormFieldProps = Omit<TextInputProps, 'onChangeText'> & {
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<FormFieldRef, FormFieldProps>(({
buttons,
error,
onRemove,
testID,
...props
}, ref) => {
const colors = useColors();
@@ -143,6 +146,8 @@ const FormFieldComponent = forwardRef<FormFieldRef, FormFieldProps>(({
clearButtonMode={Platform.OS === 'ios' ? "while-editing" : "never"}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
testID={testID}
accessibilityLabel={testID}
{...props}
/>
{showClearButton && (

View File

@@ -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<HiddenFieldProps> = ({
placeholder,
keyboardType = 'default',
onRemove,
testID,
}) => {
const colors = useColors();
const [isVisible, setIsVisible] = useState(false);
@@ -97,6 +100,8 @@ export const HiddenField: React.FC<HiddenFieldProps> = ({
keyboardType={keyboardType}
autoCapitalize="none"
autoCorrect={false}
testID={testID}
accessibilityLabel={testID}
/>
<RobustPressable
onPress={() => setIsVisible(!isVisible)}

View File

@@ -289,6 +289,7 @@ export function ItemCard({ item, onItemDelete }: ItemCardProps): React.ReactNode
}}
activeOpacity={0.7}
testID="item-card"
accessibilityLabel={item.Name}
>
<View style={styles.itemContent}>
<ItemIcon item={item} style={styles.logo} />

View File

@@ -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<HeaderBackButtonProps> = ({
label,
onPress,
testID = 'back-button',
}) => {
const colors = useColors();
return (
<RobustPressable
onPress={onPress}
style={styles.container}
>
<MaterialIcons
name="chevron-left"
size={28}
color={colors.primary}
/>
<Text testID={testID} style={[styles.label, { color: colors.primary }]}>
{label}
</Text>
</RobustPressable>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
flexDirection: 'row',
marginLeft: -8,
paddingHorizontal: 8,
},
label: {
fontSize: 17,
},
});

View File

@@ -9,6 +9,7 @@ interface IRobustPressableProps {
disabled?: boolean;
pressRetentionOffset?: number;
hitSlop?: number;
testID?: string;
}
/**
@@ -24,6 +25,7 @@ export const RobustPressable: React.FC<IRobustPressableProps> = ({
disabled,
pressRetentionOffset = 10,
hitSlop = 10,
testID,
}) => {
return (
<Pressable
@@ -36,6 +38,8 @@ export const RobustPressable: React.FC<IRobustPressableProps> = ({
style,
{ opacity: pressed ? 0.6 : 1 },
]}
testID={testID}
accessibilityLabel={testID}
>
{children}
</Pressable>