From e560f51fe78dde6837d1c882a8a89f52ac2c4b23 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:59:59 +0530 Subject: [PATCH] add product test --- .gitignore | 3 +- .../admin-ui/app/(drawer)/dashboard/index.tsx | 2 + .../app/(drawer)/dashboard/products/add.tsx | 8 +- .../app/(drawer)/dashboard/products/edit.tsx | 4 +- .../app/(drawer)/dashboard/products/index.tsx | 395 ++++++++++-------- apps/admin-ui/src/components/ProductForm.tsx | 5 +- change-log.txt | 115 +++++ .../ui/src/components/bottom-dropdown.tsx | 2 + tests/e2e/.env.example | 28 ++ tests/e2e/README.md | 62 +++ tests/e2e/add-product.yaml | 169 ++++++++ tests/e2e/assets/test-product.png | Bin 0 -> 5075 bytes tests/e2e/config.yaml | 6 + tests/e2e/run.sh | 57 +++ tests/e2e/subflows/login.yaml | 20 + tests/e2e/subflows/pick-image.yaml | 79 ++++ 16 files changed, 768 insertions(+), 187 deletions(-) create mode 100644 tests/e2e/.env.example create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/add-product.yaml create mode 100755 tests/e2e/assets/test-product.png create mode 100644 tests/e2e/config.yaml create mode 100755 tests/e2e/run.sh create mode 100644 tests/e2e/subflows/login.yaml create mode 100644 tests/e2e/subflows/pick-image.yaml diff --git a/.gitignore b/.gitignore index 199ddb7..949d628 100644 --- a/.gitignore +++ b/.gitignore @@ -135,8 +135,7 @@ dist type-clusters/* # ---> Maestro (root E2E suite) -/e2e/.maestro-output/ -/e2e/.env +tests/e2e/.env apps/user-ui/android/ apps/admin-ui/android/ diff --git a/apps/admin-ui/app/(drawer)/dashboard/index.tsx b/apps/admin-ui/app/(drawer)/dashboard/index.tsx index 571c118..582363b 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/index.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/index.tsx @@ -29,6 +29,8 @@ const MenuItemComponent: React.FC = ({ item, router }) = return ( item.onPress ? item.onPress() : router.push(item.route as any)} style={({ pressed }) => [ tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`, diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx index 4465099..e728976 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { Alert } from 'react-native' -import { AppContainer, ImageUploaderNeoPayload } from 'common-ui' +import { Alert, View } from 'react-native' +import { ImageUploaderNeoPayload, tw } from 'common-ui' import ProductForm from '@/src/components/ProductForm' import { trpc } from '@/src/trpc-client' import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore' @@ -139,13 +139,13 @@ export default function AddProduct() { } return ( - + - + ) } diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx index 69f0c97..8fd400e 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx @@ -219,7 +219,7 @@ export default function EditProduct() { } return ( - + - + ); } diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/index.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/index.tsx index cae5b19..4dd80e9 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/products/index.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/products/index.tsx @@ -1,9 +1,9 @@ -import React, { useState, useMemo } from 'react'; -import { View, TouchableOpacity, RefreshControl } from 'react-native'; +import React, { useState, useMemo, useCallback } from 'react'; +import { View, TouchableOpacity } from 'react-native'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; 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 { SuccessToast, ErrorToast } from '@/services/toaster'; @@ -13,15 +13,130 @@ type FilterType = 'all' | 'in-stock' | 'out-of-stock'; type SerializedAdminSku = Omit & { createdAt: string | Date } -function getDefaultSku(product: { skus: SerializedAdminSku[] }): SerializedAdminSku | null { +function getDefaultSku(product: { skus?: SerializedAdminSku[] }): SerializedAdminSku | null { return product.skus?.[0] ?? null } +function FilterButton({ + filter, + label, + count, + activeFilter, + onSelect, +}: { + filter: FilterType + label: string + count: number + activeFilter: FilterType + onSelect: (filter: FilterType) => void +}) { + const isActive = activeFilter === filter + return ( + onSelect(filter)} + style={tw`px-4 py-2 rounded-lg ${isActive ? 'bg-blue-500' : 'bg-gray-200'}`} + > + + {label} ({count}) + + + ) +} + +type ProductRowProps = { + product: any + isToggling: boolean + onView: (productId: number) => void + onEdit: (productId: number) => void + onToggle: (product: { id: number }) => void +} + +const ProductRow = React.memo(function ProductRow({ product, isToggling, onView, onEdit, onToggle }: ProductRowProps) { + const defaultSku = getDefaultSku(product) + const skuImages = defaultSku?.images ?? null + const isOut = defaultSku?.isOutOfStock ?? false + + return ( + + {/* Product Image */} + {skuImages && skuImages.length > 0 ? ( + + ) : ( + + + + )} + + {/* Product Info */} + + + + {product.name} + + + + + {isOut ? 'Out' : 'In'} + + + + + {product.shortDescription && ( + + {product.shortDescription} + + )} + + + + {product.skus?.length ?? 0} Variants + + + + {/* Action Buttons */} + + onView(product.id)} + style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`} + > + + View + + + onEdit(product.id)} + style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`} + > + + Edit + + + onToggle(product)} + disabled={isToggling} + style={tw`flex-1 ${isOut ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center ${isToggling ? 'opacity-50' : ''}`} + > + + + {isOut ? 'Stock' : 'Out'} + + + + + + ) +}) + export default function Products() { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(''); const [activeFilter, setActiveFilter] = useState('all'); - const [refreshing, setRefreshing] = useState(false); const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery(); @@ -43,18 +158,17 @@ export default function Products() { const products = productsData?.products || []; - const handleRefresh = async () => { - setRefreshing(true); + const handleRefresh = useCallback(async () => { await refetch(); - setRefreshing(false); - }; + }, [refetch]); const filteredProducts = useMemo(() => { + const term = searchTerm.toLowerCase(); return products.filter(product => { const defaultSku = getDefaultSku(product) - const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) || - (product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase())); + const matchesSearch = product.name.toLowerCase().includes(term) || + (product.shortDescription?.toLowerCase().includes(term)); const matchesFilter = activeFilter === 'all' || (activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) || @@ -64,64 +178,47 @@ export default function Products() { }); }, [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 }) => ( - setActiveFilter(filter)} - style={tw`px-4 py-2 rounded-lg ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`} - > - - {label} ({count}) - - + 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] ); - if (isLoading) { - return ( - - - Loading products... - - - ); - } + const handleEdit = useCallback((productId: number) => { + router.push(`/(drawer)/dashboard/products/edit?id=${productId}` as any); + }, [router]); - if (error) { - return ( - - - Error loading products - refetch()} - style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`} - > - Retry - - - - ); - } + const handleViewDetails = useCallback((productId: number) => { + router.push(`/(drawer)/dashboard/products/detail/${productId}` as any); + }, [router]); - const inStockCount = products.filter(p => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length; - const outOfStockCount = products.filter(p => getDefaultSku(p)?.isOutOfStock).length; + const handleToggleStock = useCallback((product: { id: number }) => { + toggleOutOfStock.mutate({ id: product.id }); + }, [toggleOutOfStock]); - const listHeader = ( + const handleSelectFilter = useCallback((filter: FilterType) => setActiveFilter(filter), []); + + const keyExtractor = useCallback((item: { id: number }) => item.id.toString(), []); + + const renderItem = useCallback(({ item: product }: { item: any }) => ( + + ), [toggleOutOfStock.isPending, toggleOutOfStock.variables?.id, handleViewDetails, handleEdit, handleToggleStock]); + + const listHeader = useMemo(() => ( <> {/* Header */} Products - router.push('/(drawer)/dashboard/products/add' as any)}> + router.push('/(drawer)/dashboard/products/add' as any)}> Add Product @@ -138,130 +235,72 @@ export default function Products() { {/* Filter Tabs */} - - - + + + - ); - - const renderItem = ({ item: product }: { item: any }) => { - const defaultSku = getDefaultSku(product) - const skuImages = defaultSku?.images ?? null - const isOut = defaultSku?.isOutOfStock ?? false + ), [products.length, inStockCount, outOfStockCount, searchTerm, activeFilter, handleSelectFilter, router]); + if (isLoading) { return ( - - {/* Product Image */} - {skuImages && skuImages.length > 0 ? ( - - ) : ( - - - - )} - - {/* Product Info */} - - - - {product.name} - - - - - {isOut ? 'Out' : 'In'} - - - - - {product.shortDescription && ( - - {product.shortDescription} - - )} - - - - {product.skus?.length ?? 0} Variants - - - - {/* Action Buttons */} - - handleViewDetails(product.id)} - style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`} - > - - View - - - handleEdit(product.id)} - style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`} - > - - Edit - - - handleToggleStock(product)} - disabled={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id} - 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' : ''}`} - > - - - {isOut ? 'Stock' : 'Out'} - - - - + + Loading products... - ) - }; + ); + } + + if (error) { + return ( + + Error loading products + refetch()} + style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`} + > + Retry + + + ); + } return ( - - - item.id.toString()} - ListHeaderComponent={listHeader} - ListEmptyComponent={ - - - - {searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'} - - - {searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'} - - {(searchTerm || activeFilter !== 'all') && ( - { - setSearchTerm(''); - setActiveFilter('all'); - }} - style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`} - > - Clear Filters - - )} - - } - contentContainerStyle={tw`pb-4`} - showsVerticalScrollIndicator={false} - refreshControl={ - - } - /> - - + + + + + {searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'} + + + {searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'} + + {(searchTerm || activeFilter !== 'all') && ( + { + setSearchTerm(''); + setActiveFilter('all'); + }} + style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`} + > + Clear Filters + + )} + + } + contentContainerStyle={tw`p-4`} + showsVerticalScrollIndicator={false} + onRefresh={handleRefresh} + initialNumToRender={6} + maxToRenderPerBatch={8} + windowSize={5} + removeClippedSubviews + /> + ); } diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 8ac2cb1..30d3818 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -253,7 +253,7 @@ const ProductForm = forwardRef(({ > {({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => { return ( - + (({ {(values.variants.length > 1 || isExistingSku) && ( { if (isExistingSku) { // Mark existing SKU as deleted (sent to backend on save). @@ -600,6 +601,7 @@ const ProductForm = forwardRef(({ })} { push(defaultVariant()) setVariantImages((prev) => [...prev, []]) @@ -614,6 +616,7 @@ const ProductForm = forwardRef(({ { const validationErrors = await validateForm() if (Object.keys(validationErrors).length > 0) { diff --git a/change-log.txt b/change-log.txt index 8caf727..bec9267 100644 --- a/change-log.txt +++ b/change-log.txt @@ -2168,3 +2168,118 @@ package.json: "typecheck": "bash typecheck", - "e2e": "bash e2e/run.sh", "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: `` -> `...` + +=== apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx === +- main render: `` -> `...` + (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) + + + + diff --git a/packages/ui/src/components/bottom-dropdown.tsx b/packages/ui/src/components/bottom-dropdown.tsx index 0893ef7..b7a97df 100644 --- a/packages/ui/src/components/bottom-dropdown.tsx +++ b/packages/ui/src/components/bottom-dropdown.tsx @@ -212,6 +212,8 @@ const BottomDropdown: React.FC = ({ return ( `^RUDF*EjzZN@faiImZdeUh<+LiR{8$Wp|Fki?9!D^2#LvJW9kmXEAs zO%Xz+kg-+vJzMzA_xHy=&+~rW=ic`{=bm%Vy*JL>%#e#6$qoPjxQvbTEdc-)HUI#K zgMygk$)C6`W`Gfm?EL`%L7D$Gps}U&Iuio@Ee&-66$7FROu_1bH^l=0)z3KoIfI!x z&D_M=fEl5rVrV!GN-E$bRYJoGq2Yzlumb4gB4z>!FMyJYprjmVSP=&^2`hqx6+*(8 zl{v7mLNJqrGhrd@F|)FWJ(R|Tu&^RnI1LtF1R*gY4f2>t3fM^nFs39ky9%LXCMjei zF>4ChNi_D*0_G4XDW5Hr_WzDd_re?F>9fq0DF;}ZSpiUOI=oX;Btp&_0X2mVA6(;I zL+9iYE6oR~&nk(}h*Wc;?=z<=^FecSUxIntU@W5{3jpAYGuFpj2Rkmmi1V3M=Zj82 zJ?Ke7Ii&f%zn>_+);)dLHsQ`k7=Z_P$Q)VP)h;~(>UI3qG`0}xUl!0)>tD8=BFT@8 zXxA86H1WiD{7}?b*kpTwt})HJ4RPRKr8JKH=sd1{7bRp)JJg$QrPUuMw^}0Hv#sh^ z)>%|#ZpmL>ySJkKXZ`%B4TAbN^jLqSPFd^wj~zM97gritK3dr}0PPqX-}A~kxQew4 z-NsHxdMnZJmC6oQD*HSV~a7*rJfmTDP45WC=wlG+Dpz2EO4R=|ZPkAR;JInJpjs{KW z0m&JEkwZ@CHI-$$S#}B|lN>*>iKf@}Cy+CDJkiCqa|i!|kurF=H$k-Wz9!e1j-j*oAh zM4&42%aQ)+=nygmY4-ne&IK-b9jGLI!D=q@dEX#vWwC1=tP6S2S_U%6wo#a^FR2|**;HbYA9qkP@ zC+tR0dKFaWIg#|~#-$qUo@t8RNKj20aos`OwaV4}b8bGVHRmDDN1e*&{Crqz+p@_~ zXMAK~9DG$RAwEV3Iv8xhs5I&7B&R@axt8Et0{fkbV%*Xg{n9wn<}kBNf6bmP{nrP;S2YDg`(V*_Rz_Wc53E~{Yv z-4pKC9ZaAt*#5{a;`x>5CvA6Mf$Z@L2Dz$p2IG@X&ObFtUwIt@a>`LSDSy2IJ3@qX z9MA~btRB|sh(BLf&b8tG)4viWtrNrRZ5#Ust_ugreQNLvbix!|gQhNZp9a%$5SA=E zz(g3&-2mz-k{$08$m_!-FfbO}9*iE`n9)&$PJzbXsq?1!pmw`no^}+C1y6@xHvuMt zsu>};GmVPb;F>^Qh#uglQpG=fCDeUMr@r~3QF~p49qAE0yk(mL=!ziX&a4BI?08!t z=NwPRgQp)+XH@e4C)qQY^4lyv@zbepmI=_YSJb8Mi1mwfh!ICM%9CduX%YCzOAVN2 zXF2mG!aLd4c>R%VgAFgov1x!%BLrN}O#MtNu@A?@Ylm#{W-sPV*R(hVHcsQsUd4i| z^Mj1QEBN@JCocFs@s!~AX9OeH+HY!?p2QV93UY2UqIPZZ+SI+7Mn8*f*aO&C${SAZ zFxr9-dEdZ$Ww||H+E(;7aUEXDrU?B`f!M2r=jLk*62$f6g`X3)PFDkVNhb^xAICx1 ztWWFeGFejf&rf78kRm8Ngt-`&JCWY2xxm3e;7QB#B-{?(ivm|ccDultvTYW?FOueR zwlB;?d$IelD!?Y8+Yq#Gx5!on*w%q_lq(7teIbE!AqkdP200$U0pL-8`us=5>#K^f z0y_k8kgK#iQaa$sTbX11*_!W%OFaKov}5i9SKE^gedLqLcnH%};WJ(78VO8-ESsG! z62nqQF7E?4LMC11_q|Z2x<7eMm)dpjf2GHR!#8-4{bIZI7T7Ts%E{-8Rn00?TV=^u z>Bu)*mwat0>{SFl9)l75;JfK7EbcW6oEq9U6~vWIsMgm1{7jKis}48_#x^1pfJ>$FkyW$T^;G zSmm$3hwZUdo@Jiy;dxVIg>8g_sWTpxbO(<DJmcE{loifrBifhGc4^!?x_?`F?{#l3clu8@biy>EYEmbcmBE_p#Q8P%_p)Of zcwKhpa}FX=#hgv4RDgJJ3y2AYskj?Yd$F)*)S+8sqP|pH31oOhBK*^CCX2r?HHyG= z?|e$E^O}0jeNzdZG2Qo>OG0YZ2v|l$5Lht9zdWP4PMsvB(}f=pXr2)ZZCBHbQ*QEQ z6cNAR^q&-9u1zdJJ*vxLF+?URkzBvP+UV^cYBpYKtQ5o;5^G-1S{+ zKkO#ANpxW+w%yF%JiPyR+UI&AEAi|jp5su4Z;N(y0j!S^(h6?!D~w5PnHq>G;eJ)c zIZ&9g`_8Ffjxzk*vVML-$vv4YS+`tp!BOrk=vw^#keat@a!Nbdy3;~TRNRPdDe6S9 zN$ld*>`l4wJ;5s0$qOZWqJJx9U13)(6IV3HYx*$Pm4&_)xFD=!wjQT$#t&zXZoTqL zUp-#+zmo^OoX1&8X{)rX)H*Bn+QWe+8|;C(gU?m{ccMUa+G#{h4uPuH>gHTmHSV%X zJm@8~K+Arr5kf+s+96KWtxnqEonDkGPKCc;x!xh0m^WLiKHaZ;7C3}F({4QMPc|Yo z57KU}7sRsc8{@|(IlZe%bg-blpbW6nbq~2LE#j z7jjs3980K@!M_TcTY6)|x~_m??fG9F8CXfwwswQE$l#srp`!^7tQ)=sKDUR4DE=fa zo{CdF$pxX zcO<^#Gu16eit@dh+jQ1To_Rc%BAm3QCpxSnc=^nQ)M(l6dgQm0ipR9dK1&-*MQK{^ zK@0@vh%Je`EZ9-jb#P&1j-hPj@)B7Y!;y#H-^$DT;M`*p_urf+`X;nnIY@gOi~kop z(Dz10Kr*tpf1gV0trr~xkALEqitL%6NUSL|b}{VJ58U&ZyZV{LOE?WWTDLQueKdH9 zvHrou0+eNtcZ;op+c)gKo8w8ue&f2Y;ZkIvcwvsCMqQ)48B}R+OMWlZE{4N*W~yqs zFI&JY?$r-v*ByIW3M`&R?TZ;Ww$wQr+P<%=ab-kUb46tjEOoZ^m#5&fNWxA}cJ1$> zBFmX2hVdriF<+8cv%NPjGI%DVf8kTFs6)x?rwWqpx>6k<09gj!+5x_zk2>06h5F)m z3Uf`Y)y{`Q_RJP7494U1t)(Y;%L3nAr6vw356;z(b#!<;+Swa zPBy(KG*T@eP4r9Ap}F68>SCSydkeQ)Pm!e*Wu^GW?PRul^wX_g(Ui9jULkcbBf1zG zBABddu|5~Q_KxR+yXO8oCE!%IM`Ea_nKE21*){v^T6KACfgmb@^U`IJ(@H>*Y$Cs` z-bjQ0Lj3_e=pb-UeFH{dV-RC`;wRp7FTS|yA2u=Aal4_P&=zO^o$D)k#TD{jdCal5 zhI`WxBfDE7J0o+Fr&+9-=A%MD3RxuzWplOK|2qZt{0y8~b2;#_rbD_V02qKZtNtz0 zx1Vh0@ZmA>%Qx;}soAFM3iKs{LyTs4EUY_U- zpcyz=EZ4(GwMceUx)d~?6qDDoD$nlc$3XDOjYjf4+V<;JzL}s3n$u(ndrY=k&J}Fp zrSqKQ;MA_!9Hcfpva6AP0~&us&a7@xd!?Bz#vgGVypMOwqnjm~oa&Yr9peV5 z0BNA95l-Z`#zWlo^##ZAjr6)g9Y`Gs5m^!;Uc z)bmsiWMoWoW|gq^B&HzD;pReGJsm~a4f&cT25>~;x@Q+zp^m%pA;#su#9xEc{*;hp=%ng|E z?R@pZF}xWjV};&}lltZZK_s_(LrgT@$xSRnRVZFhYQ&x}%J)q9tX?2cBbuPQEbLYf z>gAUEb!0qVYwlW2o%X9p=Db4JyEwD_f-0scujclR3dK@*Q#YktAu?|71K+UgU>GI* zY5R4^k32z~aq1Yf#W}MsZpHccf#vM#mb=9{ymd zszl$)0KZ=JgM(E+#r@nV^7*#10>8!PK!OqV_h05~e3RWm14w{Wkz$WL6l0- zY;l7G;O2-O6WvONCVS-+gb1eeadXD`dx?k=aLWhJsK8Vb2`X8uGAPV5TOv*~sT)kf z)!z%l(mCA`-e+}m`2PsUp~VB^7xxo3$J#d~Zmk9~qcg`qLqTLZszVoRR kvXn3fU`gP6K*T?Yo!&0+^B6>v`J)9eHZaq#&?Q9tAFP`sjQ{`u literal 0 HcmV?d00001 diff --git a/tests/e2e/config.yaml b/tests/e2e/config.yaml new file mode 100644 index 0000000..c9b4859 --- /dev/null +++ b/tests/e2e/config.yaml @@ -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' diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..d04e320 --- /dev/null +++ b/tests/e2e/run.sh @@ -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:-}" \ + "$@" diff --git a/tests/e2e/subflows/login.yaml b/tests/e2e/subflows/login.yaml new file mode 100644 index 0000000..d8753e4 --- /dev/null +++ b/tests/e2e/subflows/login.yaml @@ -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 diff --git a/tests/e2e/subflows/pick-image.yaml b/tests/e2e/subflows/pick-image.yaml new file mode 100644 index 0000000..147021d --- /dev/null +++ b/tests/e2e/subflows/pick-image.yaml @@ -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