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 ( &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