add product test

This commit is contained in:
shafi54 2026-09-15 23:59:59 +05:30
parent 839c3f8380
commit e560f51fe7
16 changed files with 768 additions and 187 deletions

3
.gitignore vendored
View file

@ -135,8 +135,7 @@ dist
type-clusters/* type-clusters/*
# ---> Maestro (root E2E suite) # ---> Maestro (root E2E suite)
/e2e/.maestro-output/ tests/e2e/.env
/e2e/.env
apps/user-ui/android/ apps/user-ui/android/
apps/admin-ui/android/ apps/admin-ui/android/

View file

@ -29,6 +29,8 @@ const MenuItemComponent: React.FC<MenuItemComponentProps> = ({ item, router }) =
return ( return (
<Pressable <Pressable
testID={item.testID}
accessibilityLabel={item.testID}
onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)} onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)}
style={({ pressed }) => [ style={({ pressed }) => [
tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`, tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`,

View file

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { Alert } from 'react-native' import { Alert, View } from 'react-native'
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui' import { ImageUploaderNeoPayload, tw } from 'common-ui'
import ProductForm from '@/src/components/ProductForm' import ProductForm from '@/src/components/ProductForm'
import { trpc } from '@/src/trpc-client' import { trpc } from '@/src/trpc-client'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore' import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'
@ -139,13 +139,13 @@ export default function AddProduct() {
} }
return ( return (
<AppContainer> <View style={tw`flex-1 bg-white`}>
<ProductForm <ProductForm
mode="create" mode="create"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={createProduct.isPending || isUploading} isLoading={createProduct.isPending || isUploading}
/> />
</AppContainer> </View>
) )
} }

View file

@ -219,7 +219,7 @@ export default function EditProduct() {
} }
return ( return (
<AppContainer> <View style={tw`flex-1 bg-white`}>
<ProductForm <ProductForm
ref={productFormRef} ref={productFormRef}
mode="edit" mode="edit"
@ -229,6 +229,6 @@ export default function EditProduct() {
existingVariantImages={existingVariantImages} existingVariantImages={existingVariantImages}
existingVariantImageKeys={existingVariantImageKeys} existingVariantImageKeys={existingVariantImageKeys}
/> />
</AppContainer> </View>
); );
} }

View file

@ -1,9 +1,9 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { View, TouchableOpacity, RefreshControl } from 'react-native'; import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui'; import { MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import { SuccessToast, ErrorToast } from '@/services/toaster'; import { SuccessToast, ErrorToast } from '@/services/toaster';
@ -13,151 +13,59 @@ type FilterType = 'all' | 'in-stock' | 'out-of-stock';
type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date } type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date }
function getDefaultSku(product: { skus: SerializedAdminSku[] }): SerializedAdminSku | null { function getDefaultSku(product: { skus?: SerializedAdminSku[] }): SerializedAdminSku | null {
return product.skus?.[0] ?? null return product.skus?.[0] ?? null
} }
export default function Products() { function FilterButton({
const router = useRouter(); filter,
const [searchTerm, setSearchTerm] = useState(''); label,
const [activeFilter, setActiveFilter] = useState<FilterType>('all'); count,
const [refreshing, setRefreshing] = useState(false); activeFilter,
onSelect,
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery(); }: {
filter: FilterType
const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation({ label: string
onSuccess: async (res) => { count: number
await refetch(); activeFilter: FilterType
SuccessToast(res.message); onSelect: (filter: FilterType) => void
}, }) {
onError: (err) => { const isActive = activeFilter === filter
ErrorToast(err.message || 'Failed to update stock status'); return (
},
});
useManualRefresh(refetch);
useMarkDataFetchers(() => {
refetch();
});
const products = productsData?.products || [];
const handleRefresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const filteredProducts = useMemo(() => {
return products.filter(product => {
const defaultSku = getDefaultSku(product)
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesFilter = activeFilter === 'all' ||
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
(activeFilter === 'out-of-stock' && defaultSku?.isOutOfStock);
return matchesSearch && matchesFilter;
});
}, [products, searchTerm, activeFilter]);
const handleEdit = (productId: number) => {
router.push(`/(drawer)/dashboard/products/edit?id=${productId}` as any);
};
const handleViewDetails = (productId: number) => {
router.push(`/(drawer)/dashboard/products/detail/${productId}` as any);
};
const handleToggleStock = (product: { id: number }) => {
toggleOutOfStock.mutate({ id: product.id });
};
const FilterButton = ({ filter, label, count }: { filter: FilterType; label: string; count: number }) => (
<TouchableOpacity <TouchableOpacity
onPress={() => setActiveFilter(filter)} onPress={() => onSelect(filter)}
style={tw`px-4 py-2 rounded-lg ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`} style={tw`px-4 py-2 rounded-lg ${isActive ? 'bg-blue-500' : 'bg-gray-200'}`}
> >
<MyText style={tw`${activeFilter === filter ? 'text-white' : 'text-gray-700'} font-semibold`}> <MyText style={tw`${isActive ? 'text-white' : 'text-gray-700'} font-semibold`}>
{label} ({count}) {label} ({count})
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
); )
if (isLoading) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center`}>
<MyText style={tw`text-gray-600`}>Loading products...</MyText>
</View>
</AppContainer>
);
} }
if (error) { type ProductRowProps = {
return ( product: any
<AppContainer> isToggling: boolean
<View style={tw`flex-1 justify-center items-center`}> onView: (productId: number) => void
<MyText style={tw`text-red-600`}>Error loading products</MyText> onEdit: (productId: number) => void
<TouchableOpacity onToggle: (product: { id: number }) => void
onPress={() => refetch()}
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
>
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
</TouchableOpacity>
</View>
</AppContainer>
);
} }
const inStockCount = products.filter(p => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length; const ProductRow = React.memo(function ProductRow({ product, isToggling, onView, onEdit, onToggle }: ProductRowProps) {
const outOfStockCount = products.filter(p => getDefaultSku(p)?.isOutOfStock).length;
const listHeader = (
<>
{/* Header */}
<View style={tw`flex-row justify-between items-center mb-6`}>
<MyText style={tw`text-2xl font-bold text-gray-800`}>Products</MyText>
<MyButton onPress={() => router.push('/(drawer)/dashboard/products/add' as any)}>
Add Product
</MyButton>
</View>
{/* Search Bar */}
<View style={tw`mb-4`}>
<SearchBar
value={searchTerm}
onChangeText={setSearchTerm}
placeholder="Search products..."
containerStyle={tw`mb-0`}
/>
</View>
{/* Filter Tabs */}
<View style={tw`flex-row gap-2 mb-6`}>
<FilterButton filter="all" label="All" count={products.length} />
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} />
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} />
</View>
</>
);
const renderItem = ({ item: product }: { item: any }) => {
const defaultSku = getDefaultSku(product) const defaultSku = getDefaultSku(product)
const skuImages = defaultSku?.images ?? null const skuImages = defaultSku?.images ?? null
const isOut = defaultSku?.isOutOfStock ?? false const isOut = defaultSku?.isOutOfStock ?? false
return ( return (
<View key={product.id} style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}> <View style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}>
{/* Product Image */} {/* Product Image */}
{skuImages && skuImages.length > 0 ? ( {skuImages && skuImages.length > 0 ? (
<Image <Image
source={{ uri: skuImages[0] }} source={{ uri: skuImages[0] }}
style={tw`w-full h-48`} style={tw`w-full h-48`}
resizeMode="cover" contentFit="cover"
cachePolicy="memory-disk"
recyclingKey={String(product.id)}
/> />
) : ( ) : (
<View style={tw`w-full h-48 bg-gray-200 justify-center items-center`}> <View style={tw`w-full h-48 bg-gray-200 justify-center items-center`}>
@ -194,7 +102,7 @@ export default function Products() {
{/* Action Buttons */} {/* Action Buttons */}
<View style={tw`flex-row gap-2`}> <View style={tw`flex-row gap-2`}>
<TouchableOpacity <TouchableOpacity
onPress={() => handleViewDetails(product.id)} onPress={() => onView(product.id)}
style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`} style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`}
> >
<MaterialIcons name="visibility" size={16} color="white" /> <MaterialIcons name="visibility" size={16} color="white" />
@ -202,7 +110,7 @@ export default function Products() {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => handleEdit(product.id)} onPress={() => onEdit(product.id)}
style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`} style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`}
> >
<MaterialIcons name="edit" size={16} color="white" /> <MaterialIcons name="edit" size={16} color="white" />
@ -210,9 +118,9 @@ export default function Products() {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => handleToggleStock(product)} onPress={() => onToggle(product)}
disabled={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id} disabled={isToggling}
style={tw`flex-1 ${isOut ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center ${toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id ? 'opacity-50' : ''}`} style={tw`flex-1 ${isOut ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center ${isToggling ? 'opacity-50' : ''}`}
> >
<MaterialIcons name={isOut ? 'check-circle' : 'block'} size={16} color="white" /> <MaterialIcons name={isOut ? 'check-circle' : 'block'} size={16} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}> <MyText style={tw`text-white font-semibold ml-1`}>
@ -223,15 +131,145 @@ export default function Products() {
</View> </View>
</View> </View>
) )
}; })
export default function Products() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation({
onSuccess: async (res) => {
await refetch();
SuccessToast(res.message);
},
onError: (err) => {
ErrorToast(err.message || 'Failed to update stock status');
},
});
useManualRefresh(refetch);
useMarkDataFetchers(() => {
refetch();
});
const products = productsData?.products || [];
const handleRefresh = useCallback(async () => {
await refetch();
}, [refetch]);
const filteredProducts = useMemo(() => {
const term = searchTerm.toLowerCase();
return products.filter(product => {
const defaultSku = getDefaultSku(product)
const matchesSearch = product.name.toLowerCase().includes(term) ||
(product.shortDescription?.toLowerCase().includes(term));
const matchesFilter = activeFilter === 'all' ||
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
(activeFilter === 'out-of-stock' && defaultSku?.isOutOfStock);
return matchesSearch && matchesFilter;
});
}, [products, searchTerm, activeFilter]);
const inStockCount = useMemo(
() => products.filter(p => { const sku = getDefaultSku(p); return sku && !sku.isOutOfStock }).length,
[products]
);
const outOfStockCount = useMemo(
() => products.filter(p => getDefaultSku(p)?.isOutOfStock).length,
[products]
);
const handleEdit = useCallback((productId: number) => {
router.push(`/(drawer)/dashboard/products/edit?id=${productId}` as any);
}, [router]);
const handleViewDetails = useCallback((productId: number) => {
router.push(`/(drawer)/dashboard/products/detail/${productId}` as any);
}, [router]);
const handleToggleStock = useCallback((product: { id: number }) => {
toggleOutOfStock.mutate({ id: product.id });
}, [toggleOutOfStock]);
const handleSelectFilter = useCallback((filter: FilterType) => setActiveFilter(filter), []);
const keyExtractor = useCallback((item: { id: number }) => item.id.toString(), []);
const renderItem = useCallback(({ item: product }: { item: any }) => (
<ProductRow
product={product}
isToggling={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id}
onView={handleViewDetails}
onEdit={handleEdit}
onToggle={handleToggleStock}
/>
), [toggleOutOfStock.isPending, toggleOutOfStock.variables?.id, handleViewDetails, handleEdit, handleToggleStock]);
const listHeader = useMemo(() => (
<>
{/* Header */}
<View style={tw`flex-row justify-between items-center mb-6`}>
<MyText style={tw`text-2xl font-bold text-gray-800`}>Products</MyText>
<MyButton testID="add-product-button" onPress={() => router.push('/(drawer)/dashboard/products/add' as any)}>
Add Product
</MyButton>
</View>
{/* Search Bar */}
<View style={tw`mb-4`}>
<SearchBar
value={searchTerm}
onChangeText={setSearchTerm}
placeholder="Search products..."
containerStyle={tw`mb-0`}
/>
</View>
{/* Filter Tabs */}
<View style={tw`flex-row gap-2 mb-6`}>
<FilterButton filter="all" label="All" count={products.length} activeFilter={activeFilter} onSelect={handleSelectFilter} />
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} activeFilter={activeFilter} onSelect={handleSelectFilter} />
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} activeFilter={activeFilter} onSelect={handleSelectFilter} />
</View>
</>
), [products.length, inStockCount, outOfStockCount, searchTerm, activeFilter, handleSelectFilter, router]);
if (isLoading) {
return (
<View style={tw`flex-1 bg-white justify-center items-center`}>
<MyText style={tw`text-gray-600`}>Loading products...</MyText>
</View>
);
}
if (error) {
return (
<View style={tw`flex-1 bg-white justify-center items-center`}>
<MyText style={tw`text-red-600`}>Error loading products</MyText>
<TouchableOpacity
onPress={() => refetch()}
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
>
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
</TouchableOpacity>
</View>
);
}
return ( return (
<AppContainer> <View style={tw`flex-1 bg-white`}>
<View style={tw`flex-1`}>
<MyFlatList <MyFlatList
data={filteredProducts} data={filteredProducts}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(item) => item.id.toString()} keyExtractor={keyExtractor}
ListHeaderComponent={listHeader} ListHeaderComponent={listHeader}
ListEmptyComponent={ ListEmptyComponent={
<View style={tw`flex-1 justify-center items-center py-10`}> <View style={tw`flex-1 justify-center items-center py-10`}>
@ -255,13 +293,14 @@ export default function Products() {
)} )}
</View> </View>
} }
contentContainerStyle={tw`pb-4`} contentContainerStyle={tw`p-4`}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={ onRefresh={handleRefresh}
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} /> initialNumToRender={6}
} maxToRenderPerBatch={8}
windowSize={5}
removeClippedSubviews
/> />
</View> </View>
</AppContainer>
); );
} }

View file

@ -253,7 +253,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
> >
{({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => { {({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
return ( return (
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}> <ScrollView style={tw`flex-1`} contentContainerStyle={tw`px-4 pt-4 pb-10`} keyboardShouldPersistTaps="handled">
<MyTextInput <MyTextInput
testID="product-name-input" testID="product-name-input"
topLabel="Product Name" topLabel="Product Name"
@ -341,6 +341,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
</View> </View>
{(values.variants.length > 1 || isExistingSku) && ( {(values.variants.length > 1 || isExistingSku) && (
<TouchableOpacity <TouchableOpacity
testID={`variant-${vIndex}-remove`}
onPress={() => { onPress={() => {
if (isExistingSku) { if (isExistingSku) {
// Mark existing SKU as deleted (sent to backend on save). // Mark existing SKU as deleted (sent to backend on save).
@ -600,6 +601,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
})} })}
<TouchableOpacity <TouchableOpacity
testID="add-variant-button-footer"
onPress={() => { onPress={() => {
push(defaultVariant()) push(defaultVariant())
setVariantImages((prev) => [...prev, []]) setVariantImages((prev) => [...prev, []])
@ -614,6 +616,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
</FieldArray> </FieldArray>
<TouchableOpacity <TouchableOpacity
testID="create-product-button"
onPress={async () => { onPress={async () => {
const validationErrors = await validateForm() const validationErrors = await validateForm()
if (Object.keys(validationErrors).length > 0) { if (Object.keys(validationErrors).length > 0) {

View file

@ -2168,3 +2168,118 @@ package.json:
"typecheck": "bash typecheck", "typecheck": "bash typecheck",
- "e2e": "bash e2e/run.sh", - "e2e": "bash e2e/run.sh",
"web-ui": "bun run --filter web-ui", "web-ui": "bun run --filter web-ui",
[2026-09-15 23:29:38] Maestro E2E setup: add missing testIDs + tests/e2e/ scaffold.
First flow: add a product with 2 SKUs (one flash), values from env, taps by testID.
=== packages/ui/src/components/bottom-dropdown.tsx ===
- option row TouchableOpacity (inside filteredOptions.map):
+ testID={`${testID}-option-${option.value}`}
+ accessibilityLabel={`${testID}-option-${option.value}`}
=== apps/admin-ui/src/components/ProductForm.tsx ===
- footer "Add Variant" TouchableOpacity: + testID="add-variant-button-footer"
- variant delete/restore TouchableOpacity: + testID={`variant-${vIndex}-remove`}
- submit TouchableOpacity: + testID="create-product-button"
=== apps/admin-ui/app/(drawer)/dashboard/products/index.tsx ===
- header "Add Product" MyButton: + testID="add-product-button"
=== apps/admin-ui/app/(drawer)/dashboard/index.tsx ===
- MenuItemComponent Pressable: + testID={item.testID} + accessibilityLabel={item.testID}
=== .gitignore ===
- + tests/e2e/.env
=== NEW files under tests/e2e/ ===
- config.yaml (workspace config: flows: ['*.yaml'])
- add-product.yaml (main flow; appId ${APP_ID})
- subflows/login.yaml (reusable login)
- subflows/pick-image.yaml (OS photo-picker adapter; optional fallbacks)
- assets/test-product.png (copied from apps/admin-ui/assets/images/partial-react-logo.png)
- .env.example
- run.sh (sources .env, appends timestamp to PRODUCT_NAME, runs maestro -e)
- README.md
[2026-09-15 23:39:25] Fix run.sh .env loading (spaces in values).
Symptom: `./.env: line 19: g: command not found` — `source`-ing `.env` treated
`SKU1_QTY=500 g` as the command `g` (same for `PRODUCT_NAME=E2E Test Product`).
=== tests/e2e/run.sh ===
- replaced `set -a; . ./.env; set +a` with a line-by-line loader that tolerates
unquoted spaces:
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in '' | '#'*) continue ;; esac
export "$line"
done < ./.env
- header comment updated (read, not source)
=== tests/e2e/.env.example ===
- quoted space-containing values (PRODUCT_NAME, SKU1_QTY, SKU2_QTY) so it is also
safe to `source` directly.
[2026-09-15 23:46:55] Fix add-product flow: scroll to fields + remove nested scroll on form screens.
Symptom: Maestro failed with `Element not found: Id matching regex: variant-0-price`.
Cause: ProductForm fields below the fold were never scrolled to; also add.tsx/edit.tsx
wrapped ProductForm in AppContainer (KeyboardAwareScrollView) around ProductForm's own
ScrollView — nested same-direction scrollers.
=== apps/admin-ui/app/(drawer)/dashboard/products/add.tsx ===
- import: `{ Alert }` from 'react-native' -> `{ Alert, View }`
- import: `{ AppContainer, ImageUploaderNeoPayload }` -> `{ ImageUploaderNeoPayload, tw }`
- render: `<AppContainer><ProductForm .../></AppContainer>` -> `<View style={tw`flex-1 bg-white`}>...</View>`
=== apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx ===
- main render: `<AppContainer><ProductForm .../></AppContainer>` -> `<View style={tw`flex-1 bg-white`}>...</View>`
(loading/error states still use AppContainer; import kept)
=== apps/admin-ui/src/components/ProductForm.tsx ===
- ScrollView: contentContainerStyle `pb-10` -> `px-4 pt-4 pb-10` (padding moved off AppContainer)
+ keyboardShouldPersistTaps="handled"
=== tests/e2e/add-product.yaml ===
- added scrollUntilVisible (direction DOWN, UP for add-variant-button) before every
field tap so off-screen inputs are scrolled into view.
[2026-09-15 23:52:45] Harden the OS photo-picker subflow.
Symptom: the picker opened and selected an image but never pressed "Add"
(bottom-right), so it stayed open and the flow later failed on
`No visible element found: id: create-product-button`.
=== tests/e2e/subflows/pick-image.yaml ===
- confirm step now tries, in order (all optional):
text regex '.*(Add|Done|Select|Open|OK).*'
id com.google.android.providers.media.module:id/button_add
id com.google.android.providers.media.module:id/button_done
id com.google.android.documentsui:id/button_add
- added waitForAnimationToEnd after selecting the thumbnail
- added a guarded last-resort tap: only when the app root is still not visible
(picker still open) tap point '90%,92%' (bottom-right)
- added a final hard assertion `extendedWaitUntil visible id: app-root` so a
stuck picker fails here instead of confusingly at create-product-button
[2026-09-15 23:53:46] Pin the picker confirm button to its real resource id.
Confirmed from AOSP MediaProvider res/layout/activity_photo_picker.xml:
- thumbnail id: icon_thumbnail
- confirm id: button_add (Button, text "Add", bottom-right of picker_bottom_bar)
=== tests/e2e/subflows/pick-image.yaml ===
- thumbnail taps: added AOSP package fallbacks
com.google.android.providers.media.module:id/icon_thumbnail
com.android.providers.media.module:id/icon_thumbnail
com.google.android.documentsui:id/thumbnail
com.android.documentsui:id/thumbnail
- confirm taps now target the exact id first (Google + AOSP + documentsui):
...providers.media.module:id/button_add
- moved the text/coordinate fallbacks inside a `when: notVisible id: app-root`
guard so they can only run while the picker is still open (previously the text
regex could have tapped "Add Variant" in the app after the picker closed)

View file

@ -212,6 +212,8 @@ const BottomDropdown: React.FC<BottomDropdownProps> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
key={option.value} key={option.value}
testID={`${testID}-option-${option.value}`}
accessibilityLabel={`${testID}-option-${option.value}`}
style={[ style={[
tw`px-4 py-3 rounded-md my-1 mx-2 flex-row items-center justify-between`, tw`px-4 py-3 rounded-md my-1 mx-2 flex-row items-center justify-between`,
selected ? { backgroundColor: colors.brand50 } : tw`bg-transparent`, selected ? { backgroundColor: colors.brand50 } : tw`bg-transparent`,

28
tests/e2e/.env.example Normal file
View file

@ -0,0 +1,28 @@
# Copy this file to `tests/e2e/.env` and fill in real values.
# `tests/e2e/run.sh` loads it. `.env` is gitignored.
# Quote values that contain spaces (they are fine either way with run.sh).
# App under test
APP_ID=in.freshyo.adminui
# Admin login (must exist on the target backend)
STAFF_NAME=
STAFF_PASSWORD=
# Product under test. run.sh appends a timestamp so re-runs don't collide with
# the backend's unique-name check.
PRODUCT_NAME="E2E Test Product"
# Id of the store to pick in the Store dropdown (see the Stores screen / DB)
STORE_ID=1
# Variant 1 (regular SKU)
SKU1_QTY="500 g"
SKU1_PRICE=199
SKU1_MRP=249
# Variant 2 (flash SKU)
SKU2_QTY="1 kg"
SKU2_PRICE=349
SKU2_MRP=399
SKU2_FLASH_PRICE=299

62
tests/e2e/README.md Normal file
View file

@ -0,0 +1,62 @@
# Admin E2E tests (Maestro)
Maestro flows that drive the **admin-ui** Expo app on an Android emulator.
## Layout
```
tests/e2e/
config.yaml # workspace config (entry flows = *.yaml at the root)
add-product.yaml # first flow: create a product with 2 SKUs (one flash)
subflows/
login.yaml # reusable login
pick-image.yaml # OS photo-picker adapter (Android)
assets/test-product.png # seeded into the emulator gallery via `addMedia`
run.sh # sources .env and runs `maestro test`
.env.example # copy to .env and fill in
```
## Prerequisites
- Maestro CLI installed (`curl -Ls "https://get.maestro.mobile.dev" | bash`).
- A booted Android emulator (`maestro start-device --platform android`, or Android Studio).
- The admin-ui **dev build** installed on the emulator (`bun run --filter admin-ui android`, appId `in.freshyo.adminui`).
- The build must be able to reach the backend (login + image upload hit the API/R2).
## Setup
```bash
cp tests/e2e/.env.example tests/e2e/.env
# edit tests/e2e/.env — at minimum STAFF_NAME, STAFF_PASSWORD, STORE_ID
```
## Run
```bash
bash tests/e2e/run.sh # every entry flow in tests/e2e/
bash tests/e2e/run.sh add-product.yaml
```
`run.sh` appends a timestamp to `PRODUCT_NAME` (the backend rejects duplicate names).
## What `add-product.yaml` does
1. `addMedia` seeds `assets/test-product.png` into the emulator gallery.
2. `launchApp` with `clearState: true` (forces login) and gallery permission.
3. Logs in (subflow) and taps the `add-product-menu-item` quick action.
4. Fills the product name and selects Store + Product Type.
5. Variant 1: quantity, price, MRP, attaches an image.
6. Adds Variant 2, fills quantity/prices, toggles **Flash Available**, sets flash price, attaches an image.
7. Submits and asserts `Product created successfully!`.
All in-app interactions use `testID` selectors (`id:`). The only exception is the
OS photo picker, which is system UI and is targeted by its platform IDs in
`subflows/pick-image.yaml`.
## Notes / gotchas
- The image picker flow is the most version-sensitive part; thumbs are targeted by
the Android 13/14 Photo Picker id with a DocumentsUI fallback, and the confirm
button by text regex `Add|Done|Select`.
- On a reused emulator, `addMedia` images accumulate in the gallery (harmless).
- Tests create real data on the connected backend.

169
tests/e2e/add-product.yaml Normal file
View file

@ -0,0 +1,169 @@
appId: ${APP_ID || "in.freshyo.adminui"}
env:
# Values are supplied via `-e` by run.sh. The inline `||` defaults let the
# flow run standalone against a dev build without run.sh.
APP_ID: ${APP_ID || "in.freshyo.adminui"}
STAFF_NAME: ${STAFF_NAME || ""}
STAFF_PASSWORD: ${STAFF_PASSWORD || ""}
PRODUCT_NAME: ${PRODUCT_NAME || "E2E Test Product"}
STORE_ID: ${STORE_ID || 1}
SKU1_QTY: ${SKU1_QTY || "500 g"}
SKU1_PRICE: ${SKU1_PRICE || "199"}
SKU1_MRP: ${SKU1_MRP || "249"}
SKU2_QTY: ${SKU2_QTY || "1 kg"}
SKU2_PRICE: ${SKU2_PRICE || "349"}
SKU2_MRP: ${SKU2_MRP || "399"}
SKU2_FLASH_PRICE: ${SKU2_FLASH_PRICE || "299"}
tags:
- products
- smoke
---
# Seed the emulator gallery so the OS picker has an image to select.
- addMedia:
- './assets/test-product.png'
# Fresh state (logs out) + grant gallery access.
- launchApp:
clearState: true
permissions:
storage: allow
# --- Login ---
- runFlow: subflows/login.yaml
# --- Navigate to Add Product ---
- tapOn:
id: add-product-menu-item
- assertVisible:
id: product-name-input
# --- Product level ---
- tapOn:
id: product-name-input
- inputText: ${PRODUCT_NAME}
- hideKeyboard
- scrollUntilVisible:
element:
id: product-store-dropdown
direction: DOWN
- tapOn:
id: product-store-dropdown
- tapOn:
id: product-store-dropdown-option-${STORE_ID}
- scrollUntilVisible:
element:
id: product-type-dropdown
direction: DOWN
- tapOn:
id: product-type-dropdown
- tapOn:
id: product-type-dropdown-option-item
# --- Variant 1 (regular) ---
- scrollUntilVisible:
element:
id: variant-0-attr-0-value
direction: DOWN
- tapOn:
id: variant-0-attr-0-value
- inputText: ${SKU1_QTY}
- hideKeyboard
- scrollUntilVisible:
element:
id: variant-0-price
direction: DOWN
- tapOn:
id: variant-0-price
- inputText: ${SKU1_PRICE}
- hideKeyboard
- scrollUntilVisible:
element:
id: variant-0-market-price
direction: DOWN
- tapOn:
id: variant-0-market-price
- inputText: ${SKU1_MRP}
- hideKeyboard
- scrollUntilVisible:
element:
id: variant-0-image-add
direction: DOWN
- tapOn:
id: variant-0-image-add
- runFlow: subflows/pick-image.yaml
# --- Add variant 2 (flash) ---
- scrollUntilVisible:
element:
id: add-variant-button
direction: UP
- tapOn:
id: add-variant-button
- scrollUntilVisible:
element:
id: variant-1-attr-0-value
direction: DOWN
- tapOn:
id: variant-1-attr-0-value
- inputText: ${SKU2_QTY}
- hideKeyboard
- scrollUntilVisible:
element:
id: variant-1-price
direction: DOWN
- tapOn:
id: variant-1-price
- inputText: ${SKU2_PRICE}
- hideKeyboard
- scrollUntilVisible:
element:
id: variant-1-market-price
direction: DOWN
- tapOn:
id: variant-1-market-price
- inputText: ${SKU2_MRP}
- hideKeyboard
# Mark variant 2 as flash and set its flash price.
- scrollUntilVisible:
element:
id: variant-1-flash
direction: DOWN
- tapOn:
id: variant-1-flash
- scrollUntilVisible:
element:
id: variant-1-flash-price
direction: DOWN
- tapOn:
id: variant-1-flash-price
- inputText: ${SKU2_FLASH_PRICE}
- hideKeyboard
- scrollUntilVisible:
element:
id: variant-1-image-add
direction: DOWN
- tapOn:
id: variant-1-image-add
- runFlow: subflows/pick-image.yaml
# --- Submit ---
- scrollUntilVisible:
element:
id: create-product-button
direction: DOWN
- tapOn:
id: create-product-button
- assertVisible: 'Product created successfully!'
- tapOn: 'OK'

BIN
tests/e2e/assets/test-product.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

6
tests/e2e/config.yaml Normal file
View file

@ -0,0 +1,6 @@
# Maestro workspace config for the root E2E suite.
# Only YAML files in this folder are entry flows; `subflows/` is invoked
# explicitly via runFlow and is intentionally not matched here.
flows:
- '*.yaml'
- '!config.yaml'

57
tests/e2e/run.sh Executable file
View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
#
# Runs the Maestro E2E suite against a running Android emulator.
#
# Values come from tests/e2e/.env (copy .env.example first). Maestro's CLI does
# not load .env files itself, so we read it here and forward each value with `-e`.
#
# Usage:
# bash tests/e2e/run.sh # run every entry flow in tests/e2e/
# bash tests/e2e/run.sh add-product.yaml
set -euo pipefail
cd "$(dirname "$0")"
if [ ! -f .env ]; then
echo "Missing tests/e2e/.env — copy .env.example to .env and fill it in." >&2
exit 1
fi
# Load .env line-by-line instead of `source`-ing it, so values that contain
# spaces (e.g. `SKU1_QTY=500 g`) work without needing quotes.
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}" # tolerate CRLF
case "$line" in
'' | '#'*) continue ;;
esac
export "$line"
done < ./.env
: "${APP_ID:?APP_ID is required}"
: "${STAFF_NAME:?STAFF_NAME is required}"
: "${STAFF_PASSWORD:?STAFF_PASSWORD is required}"
: "${PRODUCT_NAME:?PRODUCT_NAME is required}"
: "${STORE_ID:?STORE_ID is required}"
# Unique product name so repeat runs don't trip the backend's duplicate check.
PRODUCT_NAME="${PRODUCT_NAME} $(date +%s)"
export PRODUCT_NAME
# Default to the whole suite when no path/tags are passed.
if [ "$#" -eq 0 ]; then
set -- .
fi
maestro test \
-e APP_ID="$APP_ID" \
-e STAFF_NAME="$STAFF_NAME" \
-e STAFF_PASSWORD="$STAFF_PASSWORD" \
-e PRODUCT_NAME="$PRODUCT_NAME" \
-e STORE_ID="$STORE_ID" \
-e SKU1_QTY="${SKU1_QTY:-}" \
-e SKU1_PRICE="${SKU1_PRICE:-}" \
-e SKU1_MRP="${SKU1_MRP:-}" \
-e SKU2_QTY="${SKU2_QTY:-}" \
-e SKU2_PRICE="${SKU2_PRICE:-}" \
-e SKU2_MRP="${SKU2_MRP:-}" \
-e SKU2_FLASH_PRICE="${SKU2_FLASH_PRICE:-}" \
"$@"

View file

@ -0,0 +1,20 @@
appId: ${APP_ID || "in.freshyo.adminui"}
env:
STAFF_NAME: ${STAFF_NAME || ""}
STAFF_PASSWORD: ${STAFF_PASSWORD || ""}
---
# Logs into the admin app. Reused by other flows via `runFlow: subflows/login.yaml`.
- assertVisible:
id: login-name-input
- tapOn:
id: login-name-input
- inputText: ${STAFF_NAME}
- tapOn:
id: login-password-input
- inputText: ${STAFF_PASSWORD}
- hideKeyboard
- tapOn:
id: login-button
# Dashboard quick actions are only rendered once the drawer shell is up.
- assertVisible:
id: add-product-menu-item

View file

@ -0,0 +1,79 @@
appId: ${APP_ID || "in.freshyo.adminui"}
---
# Android system picker adapter.
#
# The picker is OS-level UI in a separate window, so it cannot be targeted with
# app testIDs. Selectors here are system ids/text, and every step is `optional`
# so different OS versions fall through until one matches.
#
# Confirmed from AOSP MediaProvider (activity_photo_picker.xml):
# thumbnail : @id/icon_thumbnail
# confirm : @id/button_add (Button, text "Add", bottom-right)
# The provider package is `com.google.android.providers.media.module` on Google
# Play images and `com.android.providers.media.module` on AOSP images.
- runFlow:
when:
platform: Android
commands:
# Runtime permission prompt (older Android / first launch).
- tapOn:
text: 'Allow'
optional: true
# Select the first thumbnail: Android 13/14 Photo Picker, then the older
# DocumentsUI gallery.
- tapOn:
id: 'com.google.android.providers.media.module:id/icon_thumbnail'
index: 0
optional: true
- tapOn:
id: 'com.android.providers.media.module:id/icon_thumbnail'
index: 0
optional: true
- tapOn:
id: 'com.google.android.documentsui:id/thumbnail'
index: 0
optional: true
- tapOn:
id: 'com.android.documentsui:id/thumbnail'
index: 0
optional: true
- waitForAnimationToEnd
# Confirm the selection by its exact id.
- tapOn:
id: 'com.google.android.providers.media.module:id/button_add'
optional: true
- tapOn:
id: 'com.android.providers.media.module:id/button_add'
optional: true
- tapOn:
id: 'com.google.android.documentsui:id/button_add'
optional: true
# Give the picker a moment to close.
- extendedWaitUntil:
visible:
id: app-root
timeout: 3000
optional: true
# Fallbacks run ONLY while the picker is still open (app root not visible),
# so they can never tap in-app controls like "Add Variant".
- runFlow:
when:
notVisible:
id: app-root
commands:
- tapOn:
text: '.*(Add|Done|Select|Open|OK).*'
optional: true
- tapOn:
point: '90%,92%'
# The picker must be closed before the rest of the flow can continue.
- extendedWaitUntil:
visible:
id: app-root
timeout: 15000