This commit is contained in:
shafi54 2026-09-15 22:26:59 +05:30
parent 2e90c36de7
commit 839c3f8380
29 changed files with 360 additions and 1192 deletions

View file

@ -2,6 +2,7 @@
## Important instructions ## Important instructions
- Don't run any drizzle migrations. User will handle it. - Don't run any drizzle migrations. User will handle it.
- don't try to run any test unless user explicitly asks for
## Code Style Guidelines ## Code Style Guidelines

View file

@ -108,6 +108,7 @@ export default function Dashboard() {
description: 'Create a new product listing', description: 'Create a new product listing',
route: '/(drawer)/dashboard/products/add', route: '/(drawer)/dashboard/products/add',
category: 'quick', category: 'quick',
testID: 'add-product-menu-item',
iconColor: theme.colors.brand500, iconColor: theme.colors.brand500,
iconBg: theme.colors.brand50, iconBg: theme.colors.brand50,
}, },

View file

@ -5,6 +5,15 @@ 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'
// SKUs store bucket-relative object keys, not URLs. Drop the query string and
// keep the last two path segments:
// https://<acct>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
// -> product-images/1.jpg
const toImageKey = (url: string): string => {
const parts = url.split('?')[0].split('/').filter(Boolean)
return parts.slice(-2).join('/')
}
export default function AddProduct() { export default function AddProduct() {
const createProduct = trpc.admin.product.createProduct.useMutation() const createProduct = trpc.admin.product.createProduct.useMutation()
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, { const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
@ -63,7 +72,8 @@ export default function AddProduct() {
let urlCursor = 0 let urlCursor = 0
const skus = values.variants.map((variant: any, vIndex: number) => { const skus = values.variants.map((variant: any, vIndex: number) => {
const count = imageCounts[vIndex] const count = imageCounts[vIndex]
const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count) // Send keys, not the presigned URLs.
const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count).map(toImageKey)
urlCursor += count urlCursor += count
return { return {

View file

@ -6,6 +6,15 @@ import ProductForm, { ProductFormRef } 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';
// SKUs store bucket-relative object keys, not URLs. Drop the query string and
// keep the last two path segments:
// https://<acct>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
// -> product-images/1.jpg
const toImageKey = (url: string): string => {
const parts = url.split('?')[0].split('/').filter(Boolean)
return parts.slice(-2).join('/')
}
export default function EditProduct() { export default function EditProduct() {
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const productId = Number(id); const productId = Number(id);
@ -140,9 +149,10 @@ export default function EditProduct() {
// Existing images (mimeType === null) stay as-is // Existing images (mimeType === null) stay as-is
const existingUrls = variantImages[vIndex] const existingUrls = variantImages[vIndex]
?.filter((img) => img.mimeType === null) ?.filter((img) => img.mimeType === null)
.map((img) => img.url) || [] .map((img) => toImageKey(img.url)) || []
const allUrls = [...existingUrls, ...newUrls] // Send keys, not URLs — the SKU stores the bucket-relative object key.
const allUrls = [...existingUrls, ...newUrls.map(toImageKey)]
return { return {
id: variant.id, id: variant.id,

View file

@ -43,9 +43,11 @@ export function useUploadToObjectStorage() {
headers: { 'Content-Type': mimeType }, headers: { 'Content-Type': mimeType },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Upload ${index + 1} failed with status ${response.status}`); const body = await response.text(); // <Error><Code>…</Code></Error>
} console.error('upload failed', response.status, body);
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
}
// Update progress // Update progress
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null); setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);

View file

@ -255,6 +255,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
return ( return (
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}> <ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}>
<MyTextInput <MyTextInput
testID="product-name-input"
topLabel="Product Name" topLabel="Product Name"
placeholder="Enter product name" placeholder="Enter product name"
value={values.name} value={values.name}
@ -262,6 +263,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<MyTextInput <MyTextInput
testID="product-short-description-input"
topLabel="Short Description" topLabel="Short Description"
placeholder="Enter short description" placeholder="Enter short description"
multiline multiline
@ -271,6 +273,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<MyTextInput <MyTextInput
testID="product-long-description-input"
topLabel="Long Description" topLabel="Long Description"
placeholder="Enter detailed description" placeholder="Enter detailed description"
multiline multiline
@ -280,6 +283,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<BottomDropdown <BottomDropdown
testID="product-store-dropdown"
topLabel="Store" topLabel="Store"
label="Store" label="Store"
value={values.storeId} value={values.storeId}
@ -290,6 +294,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
/> />
<BottomDropdown <BottomDropdown
testID="product-type-dropdown"
topLabel="Product Type" topLabel="Product Type"
label="Product Type" label="Product Type"
value={values.productType} value={values.productType}
@ -308,6 +313,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<View style={tw`flex-row justify-between items-center mb-3`}> <View style={tw`flex-row justify-between items-center mb-3`}>
<MyText style={tw`text-lg font-bold text-gray-800`}>Variants</MyText> <MyText style={tw`text-lg font-bold text-gray-800`}>Variants</MyText>
<TouchableOpacity <TouchableOpacity
testID="add-variant-button"
onPress={() => { onPress={() => {
push(defaultVariant()) push(defaultVariant())
setVariantImages((prev) => [...prev, []]) setVariantImages((prev) => [...prev, []])
@ -355,6 +361,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
</View> </View>
<MyTextInput <MyTextInput
testID={`variant-${vIndex}-sku-name`}
topLabel="SKU Name (optional)" topLabel="SKU Name (optional)"
placeholder="Overrides the auto-generated name" placeholder="Overrides the auto-generated name"
value={variant.name ?? ''} value={variant.name ?? ''}
@ -385,6 +392,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<View key={aIndex} style={tw`flex-row items-center mb-2 gap-2`}> <View key={aIndex} style={tw`flex-row items-center mb-2 gap-2`}>
<View style={tw`flex-1`}> <View style={tw`flex-1`}>
<MyTextInput <MyTextInput
testID={`variant-${vIndex}-attr-${aIndex}-name`}
placeholder="Name" placeholder="Name"
value={attr.featureName ?? ''} value={attr.featureName ?? ''}
onChangeText={(text) => onChangeText={(text) =>
@ -399,6 +407,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
</View> </View>
<View style={tw`flex-1`}> <View style={tw`flex-1`}>
<MyTextInput <MyTextInput
testID={`variant-${vIndex}-attr-${aIndex}-value`}
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'} placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
value={attr.featureValue} value={attr.featureValue}
onChangeText={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)} onChangeText={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)}
@ -432,6 +441,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<View style={tw`flex-row gap-2 mb-3`}> <View style={tw`flex-row gap-2 mb-3`}>
<View style={tw`flex-1`}> <View style={tw`flex-1`}>
<MyTextInput <MyTextInput
testID={`variant-${vIndex}-market-price`}
topLabel="Market Price" topLabel="Market Price"
placeholder="MRP" placeholder="MRP"
keyboardType="numeric" keyboardType="numeric"
@ -466,6 +476,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<View style={tw`flex-row items-center mb-3`}> <View style={tw`flex-row items-center mb-3`}>
<Checkbox <Checkbox
testID={`variant-${vIndex}-offer`}
checked={variant.isOffer} checked={variant.isOffer}
onPress={() => setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)} onPress={() => setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)}
style={tw`mr-3`} style={tw`mr-3`}
@ -476,6 +487,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
{!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && ( {!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && (
<View style={tw`flex-row items-center mb-3`}> <View style={tw`flex-row items-center mb-3`}>
<Checkbox <Checkbox
testID={`variant-${vIndex}-combo-only`}
checked={variant.isComboOnly} checked={variant.isComboOnly}
onPress={() => setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)} onPress={() => setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)}
style={tw`mr-3`} style={tw`mr-3`}
@ -498,6 +510,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
{variant.isFlashAvailable && ( {variant.isFlashAvailable && (
<MyTextInput <MyTextInput
testID={`variant-${vIndex}-flash-price`}
topLabel="Flash Price" topLabel="Flash Price"
placeholder="Enter flash price" placeholder="Enter flash price"
keyboardType="numeric" keyboardType="numeric"
@ -508,6 +521,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
)} )}
<ImageUploaderNeo <ImageUploaderNeo
testID={`variant-${vIndex}-image-add`}
images={variantImages[vIndex] || []} images={variantImages[vIndex] || []}
onImageAdd={(payloads) => onImageAdd={(payloads) =>
setVariantImages((prev) => { setVariantImages((prev) => {

View file

@ -118,10 +118,14 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
try { try {
const client = getAwsClient() const client = getAwsClient()
const url = buildObjectUrl(getS3BucketName(), s3Url) // X-Amz-Expires must be a QUERY param, not a header: with signQuery, aws4fetch
const signedRequest = await client.sign(url, { // hoists header values into the query but still lists them in
// X-Amz-SignedHeaders, and R2 then hashes the missing header as empty — which
// makes the signature mismatch (S3 tolerates it, R2 does not).
const url = new URL(buildObjectUrl(getS3BucketName(), s3Url))
url.searchParams.set('X-Amz-Expires', String(expiresIn))
const signedRequest = await client.sign(url.toString(), {
method: 'GET', method: 'GET',
headers: { 'X-Amz-Expires': String(expiresIn) },
aws: { signQuery: true }, aws: { signQuery: true },
}) })
return signedRequest.url return signedRequest.url
@ -170,13 +174,13 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
await createUploadUrlStatus(key) await createUploadUrlStatus(key)
const client = getAwsClient() const client = getAwsClient()
const url = buildObjectUrl(getS3BucketName(), key) // Same as the GET path above: keep X-Amz-Expires in the query, and do not
const signedRequest = await client.sign(url, { // sign Content-Type — leaving it unsigned means the client may send any value
// without breaking the signature.
const url = new URL(buildObjectUrl(getS3BucketName(), key))
url.searchParams.set('X-Amz-Expires', String(expiresIn))
const signedRequest = await client.sign(url.toString(), {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': mimeType,
'X-Amz-Expires': String(expiresIn),
},
aws: { signQuery: true }, aws: { signQuery: true },
}) })
@ -193,15 +197,22 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
// return decodeURIComponent(rawKey); // return decodeURIComponent(rawKey);
// } // }
// New function (excludes bucket name) /**
* Normalise an image reference to the bucket-relative object key that SKUs store.
*
* Accepts either a full (presigned) URL or an already-extracted key, so callers
* can send whichever they have. Drops the query string, then keeps the last two
* path segments:
*
* https://<account>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
* -> product-images/1.jpg
* product-images/1.jpg
* -> product-images/1.jpg
*/
export function extractKeyFromPresignedUrl(url: string): string { export function extractKeyFromPresignedUrl(url: string): string {
const u = new URL(url) const withoutQuery = url.split('?')[0]
const rawKey = u.pathname.replace(/^\/+/, '') // remove leading slash const parts = withoutQuery.split('/').filter(Boolean)
const decodedKey = decodeURIComponent(rawKey) return decodeURIComponent(parts.slice(-2).join('/'))
// Remove bucket prefix
const parts = decodedKey.split('/')
parts.shift() // Remove bucket name
return parts.join('/')
} }
export async function claimUploadUrl(url: string): Promise<void> { export async function claimUploadUrl(url: string): Promise<void> {

View file

@ -260,8 +260,6 @@ export const productRouter = router({
await deleteImageUtil({ keys: deletedImageKeys }) await deleteImageUtil({ keys: deletedImageKeys })
} }
const allUploadUrls: string[] = skus.flatMap((sku) => sku.images)
const skuInputs = skus.map((sku) => ({ const skuInputs = skus.map((sku) => ({
id: sku.id, id: sku.id,
name: sku.name ?? null, name: sku.name ?? null,

View file

@ -2077,3 +2077,94 @@ if the SKU has no valid slots, drop the item. Silent, regular cart only, runs on
=== apps/user-ui/src/components/CentralStoreInitializer.tsx === === apps/user-ui/src/components/CentralStoreInitializer.tsx ===
- import: + useReconcileCartSlots from '@/hooks/cart-query-hooks' - import: + useReconcileCartSlots from '@/hooks/cart-query-hooks'
- call useReconcileCartSlots() alongside existing store initializers. - call useReconcileCartSlots() alongside existing store initializers.
[2026-09-14 23:40:00] E2E: admin dashboard component smoke flow.
apps/admin-ui/app/(drawer)/dashboard/index.tsx — the "Add Product" quick action had
no testID (only "Delivery Slots" did), so it could only be tapped by its label
text, which is what the map renders as testID/accessibilityLabel:
title: 'Add Product',
icon: 'add-circle',
description: 'Create a new product listing',
route: '/(drawer)/dashboard/products/add',
category: 'quick',
+ testID: 'add-product-menu-item',
New file e2e/flows/admin_comp.yaml (appId in.freshyo.adminui):
- launchApp -> dismiss the dev toast -> subflows/admin-login.yaml (only acts when
"Admin Login" is on screen, using ADMIN_NAME/ADMIN_PASSWORD from e2e/.env)
- extendedWaitUntil "Quick Actions" (polls the hierarchy until the dashboard
renders, or 90s)
- assertVisible id delivery-slots-menu-item, then wait for id
add-product-menu-item
- tapOn id add-product-menu-item — and stops there.
[2026-09-15 15:30:00] Fix: R2 presigned URLs rejected with SignatureDoesNotMatch (403).
Symptom: every product-image upload failed with 403 from R2
(<Code>SignatureDoesNotMatch</Code>). The CanonicalRequest in the error showed the
signed-headers list as "host;x-amz-expires" with x-amz-expires hashed as EMPTY,
while the URL also carried X-Amz-Expires=86400 as a query param.
Cause: the presigns are hand-rolled with aws4fetch (not the AWS SDK) and pass
X-Amz-Expires as a HEADER while signing the query:
await client.sign(url, {
method: 'PUT',
headers: { 'Content-Type': mimeType, 'X-Amz-Expires': String(expiresIn) },
aws: { signQuery: true },
})
With signQuery, aws4fetch hoists the value into the query string but leaves
x-amz-expires in X-Amz-SignedHeaders. R2 rebuilds the canonical request from the
real request, finds no such header and hashes it as empty, so the two canonical
requests differ. S3 tolerates this; R2 does not.
apps/backend/src/lib/s3-client.ts — move the expiry into the URL query so nothing
is hoisted, and stop signing Content-Type (an unsigned header is fine):
generateSignedUrlFromS3Url() (GET):
- const url = buildObjectUrl(getS3BucketName(), s3Url)
- const signedRequest = await client.sign(url, {
- method: 'GET',
- headers: { 'X-Amz-Expires': String(expiresIn) },
- aws: { signQuery: true },
- })
+ const url = new URL(buildObjectUrl(getS3BucketName(), s3Url))
+ url.searchParams.set('X-Amz-Expires', String(expiresIn))
+ const signedRequest = await client.sign(url.toString(), {
+ method: 'GET',
+ aws: { signQuery: true },
+ })
generateUploadUrl() (PUT):
- const url = buildObjectUrl(getS3BucketName(), key)
- const signedRequest = await client.sign(url, {
- method: 'PUT',
- headers: {
- 'Content-Type': mimeType,
- 'X-Amz-Expires': String(expiresIn),
- },
- aws: { signQuery: true },
- })
+ const url = new URL(buildObjectUrl(getS3BucketName(), key))
+ url.searchParams.set('X-Amz-Expires', String(expiresIn))
+ // Content-Type is deliberately NOT signed: it stays an unsigned header (any
+ // value is allowed) and cannot break verification.
+ const signedRequest = await client.sign(url.toString(), {
+ method: 'PUT',
+ aws: { signQuery: true },
+ })
NOTE: the GET path was equally broken, so signed asset/image reads against R2
would have failed the same way.
[2026-09-15 22:30:00] Remove the stale e2e script.
The e2e/ directory was deleted, so its npm script no longer resolves.
package.json:
"typecheck": "bash typecheck",
- "e2e": "bash e2e/run.sh",
"web-ui": "bun run --filter web-ui",

View file

@ -1,17 +0,0 @@
# Copy this file to e2e/.env (gitignored). `run.sh` / `bun run e2e` loads it
# automatically, so you don't need -e flags on the command line.
#
# Login uses the username + password path ("other ways to login"), because the
# OTP flow needs a real SMS/OTP that can't be automated.
TEST_IDENTIFIER=9676651496
TEST_PASSWORD=meatfarmer@123
# Optional: the address vars already have non-secret defaults in config.yaml.
# Set them here only if you want to override those.
# ADDRESS_NAME=
# ADDRESS_PHONE=
# ADDRESS_LINE1=
ADMIN_NAME=shafi
ADMIN_PASSWORD=shafI123

View file

@ -1,14 +0,0 @@
# Copy this file to e2e/.env (gitignored). `run.sh` / `bun run e2e` loads it
# automatically and forwards the values to Maestro as -e flags.
#
# Login uses the username + password path ("other ways to login"), because the
# OTP flow needs a real SMS/OTP that can't be automated.
TEST_IDENTIFIER=
TEST_PASSWORD=
# Optional: the address vars already have non-secret defaults in config.yaml.
# Set them here only if you want to override those.
# ADDRESS_NAME=
# ADDRESS_PHONE=
# ADDRESS_LINE1=

View file

@ -1,125 +0,0 @@
# Maestro E2E — apps/user-ui (Android only)
Maestro flows that drive the **user-ui** app through placing an order:
home → product → add to cart (choose delivery slot) → cart → checkout
(address if needed, Cash on Delivery) → **Place Order** → confirmation.
Everything here targets **Android only** (`appId: in.freshyo.app`), and `run.sh`
always passes `--platform android` — it will never use an iOS simulator.
## Prerequisites
- Maestro CLI installed (`maestro --version`; installed here at `~/.maestro/bin/maestro`).
- A running **Android emulator/device** with the user-ui dev build installed:
```bash
cd apps/user-ui
npx expo run:android
```
- A **test account with a password set**. OTP login can't be automated (the OTP
is server-issued, no dev bypass). The flow reaches the login screen by tapping
**Checkout** as a guest — checkout is behind `useAuthenticatedRoute`, so the app
bounces there — then signs in via `other ways to login``Username` +
`Password` and returns to the cart. Alternatively, sign in once by hand and the
subflow becomes a no-op; `launchApp` uses `clearState: false` so the session and
the first-run onboarding survive.
- A catalog with at least one **in-stock product that has a future delivery slot**.
Note: in a dev build the "Open debugger to view warnings" toast overlays the bottom
of the screen and swallows taps on the bottom tab bar — the flows avoid the tab bar
and use `centerElement: true` where a control can land under it.
## Running
**Recommended — one command, values from a gitignored file.** Create `e2e/.env`
(see `.env.example`) and then, from the repo root:
```bash
bun run e2e # or: bash e2e/run.sh
```
`e2e/run.sh` sources `e2e/.env`, runs `maestro test e2e --platform android`, and
falls back to `~/.maestro/bin/maestro` when `maestro` isn't on `PATH`. Extra flags
pass through, and flow paths are relative to `e2e/`:
```bash
bash e2e/run.sh flows/place-order.yaml # one flow
bash e2e/run.sh --format junit # junit report
bash e2e/run.sh --device emulator-5554 # pick an emulator (needed if several are connected)
```
To see which emulators are available: `adb devices -l`.
### How values are supplied
Maestro resolves `${VAR}` from three places:
1. **`config.yaml``env:`** — committed, non-secret defaults shared by all flows
(`ADDRESS_NAME`, `ADDRESS_PHONE`, `ADDRESS_LINE1` live here).
2. **`e2e/.env`** (gitignored) — loaded by `run.sh`; put `TEST_IDENTIFIER` /
`TEST_PASSWORD` here so secrets stay out of git and off the command line.
3. **Shell / CLI**`MAESTRO_`-prefixed env vars or `-e KEY=VALUE`, good for CI:
```bash
MAESTRO_TEST_IDENTIFIER=... MAESTRO_TEST_PASSWORD=... bash e2e/run.sh
# or
maestro test e2e -e TEST_IDENTIFIER=... -e TEST_PASSWORD=...
```
Need more than one setup (e.g. CI vs local)? Maestro supports alternate configs:
`maestro test --config e2e/ci-config.yaml e2e`.
Useful flags: `--include-tags`, `--format junit`, `--test-output-dir`.
Reports/screenshots land in `e2e/.maestro-output/`.
## Layout
```
e2e/
config.yaml # workspace config (flows, execution order, env defaults)
subflows/login.yaml # reusable login (runs only if the login screen is visible)
flows/smoke.yaml # app launches, home renders
flows/place-order.yaml # the order flow
```
## What place-order covers
1. Home → add a product from the card's cart icon → pick a delivery slot in the
"Select Delivery Slot" dialog → add to cart.
(The icon sits inside the card's touchable, so the tap can fall through to the
card; the flow then adds from the product detail page instead.)
2. Cart → checkout. A guest is bounced to login (checkout is behind
`useAuthenticatedRoute`), signs in, and the flow re-enters the cart.
3. Address (only when the account has none) → Cash on Delivery → **Place Order**.
4. **Home glimpse**: `Continue Shopping` returns home; the flow scrolls to the
`NextOrderGlimpse` card and captures it with `takeScreenshot`, so each run
leaves a picture of the upcoming-order card. Maestro writes it under
`~/.maestro/tests/<timestamp>/place-order/takeScreenshot/home-upcoming-order-glimpse.png`.
5. **Cancel**: taps the glimpse card (which routes to
`/(drawer)/(tabs)/me/my-orders/{id}`), scrolls to **Cancel Order**, fills the
reason, confirms, dismisses the success alert, and asserts the
**Cancellation Reason** panel renders.
## Notes
- **These flows place real orders** on whatever backend the app points at, and
then cancel the one they placed. Point the app at a non-production backend /
use a throwaway account.
- The product is chosen as **the first card** (`id: product-card`, `index: 0`).
If that item is out of stock or has no future slot, change the index or seed a
known product.
- The address step is conditional: it only runs when `No addresses found` is
visible, so an account with a saved address skips it.
- Payment defaults to **Cash on Delivery**, so the flow just asserts it's visible.
- Controls below the fold need `scrollUntilVisible`; the home glimpse sits below
**Our Stores**, and **Cancel Order** below the bill summary.
- `launchApp` uses `clearState: false`, so the session and (on the first run) the
onboarding survive. The trade-off is that the app resumes wherever the previous
run left it — the flow taps the **Home** tab (`optional: true`) to get back.
- Maestro taps can be eaten by a dismissing keyboard or the dev-warning toast, so
the flow waits for animations and retries the confirm tap once.
- Stable `testID` anchors used by the flows live in user-ui:
`product-card`, `add-to-cart-icon`, `slot-option`, `add-to-cart-confirm`,
`go-to-cart`, `checkout-button`, `address-name`, `address-phone`,
`address-line1`, `address-submit`, `place-order-button`, `cancel-reason`.

View file

@ -1,21 +0,0 @@
# Maestro workspace configuration for apps/user-ui E2E tests (Android only).
# Run from the repo root: bash e2e/run.sh (or: bun run e2e)
flows:
- 'flows/**'
executionOrder:
# Stop the suite as soon as a flow fails.
continueOnFailure: false
flowsOrder:
- smoke
- place-order
# Non-secret defaults shared by all flows. ${VAR} in a flow's commands resolves
# from here. Secrets (TEST_IDENTIFIER / TEST_PASSWORD) belong in the gitignored
# e2e/.env — see e2e/run.sh — not in this committed file.
env:
ADDRESS_NAME: Test User
ADDRESS_PHONE: '9876543210'
ADDRESS_LINE1: '1 Test Street'
testOutputDir: .maestro-output

View file

@ -1,92 +0,0 @@
# Admin: attach the product created in admin-product.yaml to the slot created in
# admin-slot.yaml.
#
# The slot is found by its delivery label (SLOT_DELIVERY_LABEL) rather than by
# id, because the id is only known at runtime. Tapping the row opens Slot
# Details; the edit FAB there reopens the same SlotForm used to create it.
#
# Env (see e2e/run.sh): ADMIN_NAME, ADMIN_PASSWORD, PRODUCT_NAME,
# SLOT_DELIVERY_LABEL (e.g. "15 Oct, 6:00 PM")
appId: in.freshyo.adminui
---
- launchApp:
clearState: false
- runFlow: ../subflows/dismiss-dev-toast.yaml
- runFlow: ../subflows/admin-login.yaml
- extendedWaitUntil:
visible: "Dashboard"
timeout: 90000
# --- Slots list: find our slot ----------------------------------------------
- tapOn:
id: "delivery-slots-menu-item"
- extendedWaitUntil:
visible:
id: "add-slot-fab"
timeout: 40000
- runFlow:
when:
notVisible: ${SLOT_DELIVERY_LABEL}
commands:
- scrollUntilVisible:
element:
text: ${SLOT_DELIVERY_LABEL}
direction: DOWN
centerElement: true
timeout: 40000
- tapOn: ${SLOT_DELIVERY_LABEL}
# The details screen keeps the "Slots" header and titles itself "Slot #<id>",
# so anchor on a section that only exists there.
- extendedWaitUntil:
visible: "Vendor Snippets"
timeout: 40000
# --- Slot Details -> edit form ----------------------------------------------
- runFlow: ../subflows/dismiss-dev-toast.yaml
- tapOn:
id: "edit-slot-fab"
- extendedWaitUntil:
visible: "Edit Slot"
timeout: 40000
# --- Attach the product in the multi-select dropdown ------------------------
# Target the trigger by id: once the slot has any products it renders the
# selected labels instead of its placeholder text.
- tapOn:
id: "slot-products-selector"
- extendedWaitUntil:
visible: "Select Products"
timeout: 30000
- tapOn:
id: "slot-products-selector-search"
- inputText: ${PRODUCT_NAME}
- hideKeyboard
- waitForAnimationToEnd:
timeout: 5000
# The option label is the SKU label — "<product name> <feature values>". The
# pattern must require that trailing part: it stops it matching the search input
# itself (which holds exactly the product name), and tapping the input just
# re-opens the keyboard instead of selecting the option.
- extendedWaitUntil:
visible: "${PRODUCT_NAME} .+"
timeout: 30000
- tapOn: "${PRODUCT_NAME} .+"
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
id: "slot-products-selector-done"
# --- Save -------------------------------------------------------------------
- scrollUntilVisible:
element:
id: "create-slot-button"
direction: DOWN
centerElement: true
timeout: 30000
- runFlow: ../subflows/dismiss-dev-toast.yaml
- tapOn:
id: "create-slot-button"
- extendedWaitUntil:
visible: "Slot updated successfully!"
timeout: 40000
- tapOn: "OK"

View file

@ -1,279 +0,0 @@
# Full product lifecycle, driven across BOTH apps in one run.
#
# Maestro's launchApp accepts an appId, so a single flow can alternate between
# the admin app and the user app and verify each change immediately.
#
# Coverage:
# - create a product + attach it to the slot (admin)
# - it is searchable and orderable (user)
# - suspend the SKU (admin) -> user: Unavailable
# - change its units (quantity attribute) (admin) -> user: new unit
# - flash: enable, then disable (admin) -> user: no 1 hr row
# - add a second variant (multiple SKUs) (admin) -> user: variant count
# - replace the image (admin)
# - save and re-verify (user)
#
# Env (see e2e/run.sh): ADMIN_NAME, ADMIN_PASSWORD, PRODUCT_NAME, PRODUCT_PRICE,
# TEST_IDENTIFIER, TEST_PASSWORD, SLOT_DELIVERY_LABEL
#
# NOTE: the image step drives the Android system Photo Picker, which only lists
# photos already on the device — e2e/run.sh pushes a fixture image first.
appId: in.freshyo.adminui
---
# --- 1. Admin: create the product and attach it to the slot ------------------
# Reuses the standalone flows so the selectors live in one place.
# Slot update disabled for now — only product create + update.
- runFlow: admin-product.yaml
# - runFlow: admin-attach-product.yaml
# --- 2. User: the new product is searchable ---------------------------------
# DISABLED while iterating on the admin side — re-enable to verify in the user
# app (it is a long leg: app switch + CDN refresh + search).
# - launchApp:
# appId: in.freshyo.app
# clearState: false
# - tapOn:
# text: "^Home$"
# optional: true
# - extendedWaitUntil:
# visible:
# id: "add-to-cart-icon"
# timeout: 90000
# # The catalog is a versioned CDN file, so refresh to pick up the new product.
# - swipe:
# start: "50%,30%"
# end: "50%,80%"
# - waitForAnimationToEnd:
# timeout: 10000
# - tapOn: "Search fresh meat..."
# - extendedWaitUntil:
# visible: "All Products"
# timeout: 30000
# - inputText: ${PRODUCT_NAME}
# - hideKeyboard
# - extendedWaitUntil:
# visible: "${PRODUCT_NAME}.*"
# timeout: 120000
# # The card carries the unit notation and the price from the admin form.
# - assertVisible: "Quantity: 0.5 kg"
# --- 3. Admin: suspend the SKU, change its units, add a variant -------------
- launchApp:
appId: in.freshyo.adminui
clearState: false
- extendedWaitUntil:
visible: "Dashboard"
timeout: 90000
- tapOn: "Products"
- extendedWaitUntil:
visible: "Search products..."
timeout: 40000
- tapOn: "Search products..."
- inputText: ${PRODUCT_NAME}
- hideKeyboard
- waitForAnimationToEnd:
timeout: 5000
- tapOn: "Edit"
- extendedWaitUntil:
visible: "Enter product name"
timeout: 40000
# Units: the quantity attribute value (auto-added, name is locked to "quantity").
# Scroll to it first — centering the checkbox labels below would push it off-screen.
- scrollUntilVisible:
element:
text: "0.5 kg"
direction: DOWN
centerElement: true
timeout: 30000
- tapOn: "0.5 kg"
- eraseText
- inputText: "1 kg"
- hideKeyboard
# Multi-SKU: a second variant with its own price and unit. The leading "+" is an
# icon rather than part of the label, so match on the label alone.
- scrollUntilVisible:
element:
text: ".*Add Variant"
direction: DOWN
centerElement: true
timeout: 30000
- tapOn: ".*Add Variant"
# Reveal the new card — it is added below the fold, and extendedWaitUntil never
# scrolls. Only retry the button if the card is still absent AFTER scrolling (a
# bare notVisible would be true while the card is merely off-screen and would
# double-tap, adding a third variant).
- scrollUntilVisible:
element:
text: "Variant 2"
direction: DOWN
centerElement: true
timeout: 30000
- runFlow:
when:
notVisible: "Variant 2"
commands:
- waitForAnimationToEnd:
timeout: 3000
- tapOn: ".*Add Variant"
- scrollUntilVisible:
element:
text: "Variant 2"
direction: DOWN
centerElement: true
timeout: 30000
- waitForAnimationToEnd:
timeout: 5000
- scrollUntilVisible:
element:
text: "Selling price"
direction: DOWN
centerElement: true
timeout: 30000
# Settle before tapping: a tap that lands while the list is still scrolling hits
# nothing, which is why this field stayed empty before.
- waitForAnimationToEnd:
timeout: 5000
# Scroll to the price field — the second variant (index 1, shown as "Variant 2")
# sits below the first, so this reveals it. Tapping an off-screen field does
# nothing, hence the scroll first.
- scrollUntilVisible:
element:
id: "variant-1-price"
direction: DOWN
centerElement: true
timeout: 30000
- waitForAnimationToEnd:
timeout: 5000
- tapOn:
id: "variant-1-price"
- inputText: "999"
- hideKeyboard
- scrollUntilVisible:
element:
text: "e.g. 0.5 kg"
direction: DOWN
centerElement: true
timeout: 30000
# Variant 1's price/value are already filled, so only variant 2's empty fields
# render these placeholders — exactly one match each, hence no index.
- waitForAnimationToEnd:
timeout: 5000
- tapOn: "e.g. 0.5 kg"
- inputText: "2 kg"
- hideKeyboard
# Flash: turn it on, fill the flash price, then turn it back off. Tap the actual
# checkbox (id), not the label text — the label is a plain Text that ignores taps.
- scrollUntilVisible:
element:
id: "variant-0-flash"
direction: DOWN
centerElement: true
timeout: 30000
- tapOn:
id: "variant-0-flash"
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Enter flash price"
commands:
- tapOn: "Enter flash price"
- inputText: "399"
- hideKeyboard
- tapOn:
id: "variant-0-flash"
- waitForAnimationToEnd:
timeout: 3000
# Suspend the SKU (the whole product effectively goes unavailable to users).
- tapOn:
id: "variant-0-suspend"
# Replace the image: the add tile sits just below the checkboxes, and this is the
# only path (expo-image-picker -> Android Photo Picker).
- tapOn:
point: "21%,61%"
- waitForAnimationToEnd:
timeout: 8000
- runFlow:
when:
visible: "This app can only access the photos you select"
commands:
- tapOn:
point: "16%,32%"
- waitForAnimationToEnd:
timeout: 3000
# The confirm button is labelled "Add (1)" once a photo is selected, so it
# can't be matched by a bare "Add".
- runFlow:
when:
visible: "Add.*"
commands:
- tapOn: "Add.*"
- runFlow:
when:
visible: "Done"
commands:
- tapOn: "Done"
# Save everything.
- scrollUntilVisible:
element:
text: "Save Changes"
direction: DOWN
centerElement: true
timeout: 30000
- waitForAnimationToEnd:
timeout: 5000
- tapOn: "Save Changes"
# Retry while the button is still up: a tap that lands mid-settle is swallowed,
# and the label only goes away once the mutation is actually in flight.
- repeat:
times: 5
commands:
- runFlow:
when:
visible: "Save Changes"
commands:
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "Save Changes"
- extendedWaitUntil:
visible: "Product updated successfully!"
timeout: 60000
- tapOn: "OK"
# --- 4. User: the changes are visible ---------------------------------------
# DISABLED while iterating on the admin side — re-enable to assert the effects
# (suspended -> Out of Stock / UNAVAILABLE, new unit, no 1 hr row).
# - launchApp:
# appId: in.freshyo.app
# clearState: false
# - tapOn:
# text: "^Home$"
# optional: true
# - extendedWaitUntil:
# visible:
# id: "add-to-cart-icon"
# timeout: 90000
# - swipe:
# start: "50%,30%"
# end: "50%,80%"
# - waitForAnimationToEnd:
# timeout: 10000
# - tapOn: "Search fresh meat..."
# - extendedWaitUntil:
# visible: "All Products"
# timeout: 30000
# - inputText: ${PRODUCT_NAME}
# - hideKeyboard
#
# # Suspended SKU -> the card is marked out of stock and cannot be added.
# - extendedWaitUntil:
# visible: "${PRODUCT_NAME}.*"
# timeout: 120000
# - assertVisible: "Out of Stock"
# - assertVisible: "UNAVAILABLE"

View file

@ -1,64 +0,0 @@
# Admin: create a product (one item variant) so the user app can order it.
#
# ProductForm has no testIDs, so fields are addressed by their label/placeholder:
# "Enter product name" -> name (must be globally unique)
# "Selling price" -> Our Price (must be > 0)
# "e.g. 0.5 kg" -> quantity feature value (required)
# Store and Product Type are pre-filled ("1" / "Item"), and images are optional,
# so nothing else is needed for a valid save.
#
# Env (see e2e/run.sh): ADMIN_NAME, ADMIN_PASSWORD, PRODUCT_NAME, PRODUCT_PRICE
appId: in.freshyo.adminui
---
- launchApp:
clearState: false
- runFlow: ../subflows/dismiss-dev-toast.yaml
- runFlow: ../subflows/admin-login.yaml
- extendedWaitUntil:
visible: "Dashboard"
timeout: 90000
# --- Dashboard quick action -> product form ---------------------------------
- tapOn: "Add Product"
# The form is long, so anchor on its first field (the submit button is far below
# the fold and would fail an assertVisible).
- extendedWaitUntil:
visible: "Enter product name"
timeout: 60000
# --- Required fields --------------------------------------------------------
- tapOn: "Enter product name"
- inputText: ${PRODUCT_NAME}
- hideKeyboard
- scrollUntilVisible:
element:
text: "Selling price"
direction: DOWN
centerElement: true
timeout: 30000
- tapOn: "Selling price"
- inputText: ${PRODUCT_PRICE}
- hideKeyboard
- scrollUntilVisible:
element:
text: "e.g. 0.5 kg"
direction: DOWN
centerElement: true
timeout: 30000
- tapOn: "e.g. 0.5 kg"
- inputText: "0.5 kg"
- hideKeyboard
# --- Submit -----------------------------------------------------------------
- scrollUntilVisible:
element:
text: "Create Product"
direction: DOWN
centerElement: true
timeout: 30000
- runFlow: ../subflows/dismiss-dev-toast.yaml
- tapOn: "Create Product"
- extendedWaitUntil:
visible: "Product created successfully!"
timeout: 60000
- tapOn: "OK"

View file

@ -1,103 +0,0 @@
# Admin: create a delivery slot with a future date (no products yet — the
# product is attached in admin-attach-product.yaml).
#
# The date/time pickers are native Android Material dialogs, so they are driven
# through their resource-ids rather than text:
# android:id/next next month
# android:id/button1 OK (button2 = CANCEL)
# android:id/toggle_mode switch the clock dialog to text entry
# android:id/input_hour / input_minute the typed time fields
# The clock dialog remembers its last mode, hence the `notVisible` guard.
#
# Env (see e2e/run.sh): ADMIN_NAME, ADMIN_PASSWORD, SLOT_DATE_DESC
# SLOT_DATE_DESC = the 15th of next month, e.g. "15 October 2026"
appId: in.freshyo.adminui
---
- launchApp:
clearState: false
- runFlow: ../subflows/dismiss-dev-toast.yaml
- runFlow: ../subflows/admin-login.yaml
- extendedWaitUntil:
visible: "Dashboard"
timeout: 90000
# --- Slots list -> create form ----------------------------------------------
- tapOn:
id: "delivery-slots-menu-item"
- extendedWaitUntil:
visible:
id: "add-slot-fab"
timeout: 40000
- runFlow: ../subflows/dismiss-dev-toast.yaml
- tapOn:
id: "add-slot-fab"
- extendedWaitUntil:
visible: "Create New Slot"
timeout: 40000
# --- Delivery date & time ---------------------------------------------------
- tapOn:
id: "delivery-date-picker"
- tapOn:
id: "android:id/next"
- tapOn: ${SLOT_DATE_DESC}
- tapOn:
id: "android:id/button1"
- tapOn:
id: "delivery-time-picker"
- runFlow:
when:
notVisible: "Type in time"
commands:
- tapOn:
id: "android:id/toggle_mode"
- tapOn:
id: "android:id/input_hour"
- eraseText
- inputText: "18"
- tapOn:
id: "android:id/input_minute"
- eraseText
- inputText: "00"
- tapOn:
id: "android:id/button1"
# --- Freeze date & time (must be <= delivery) -------------------------------
- tapOn:
id: "freeze-date-picker"
- tapOn:
id: "android:id/next"
- tapOn: ${SLOT_DATE_DESC}
- tapOn:
id: "android:id/button1"
- tapOn:
id: "freeze-time-picker"
- runFlow:
when:
notVisible: "Type in time"
commands:
- tapOn:
id: "android:id/toggle_mode"
- tapOn:
id: "android:id/input_hour"
- eraseText
- inputText: "17"
- tapOn:
id: "android:id/input_minute"
- eraseText
- inputText: "00"
- tapOn:
id: "android:id/button1"
# --- Submit -----------------------------------------------------------------
- runFlow: ../subflows/dismiss-dev-toast.yaml
- tapOn:
id: "create-slot-button"
- extendedWaitUntil:
visible: "Slot created successfully!"
timeout: 40000
- tapOn: "OK"
- extendedWaitUntil:
visible:
id: "add-slot-fab"
timeout: 40000

View file

@ -1,65 +0,0 @@
# End-to-end: place an order (scheduled delivery, Cash on Delivery).
#
# Preconditions:
# - a running Android emulator with the user-ui dev build installed
# - a test account that has a password set (see e2e/README.md)
# - the catalog has at least one in-stock product with a future delivery slot
#
# Env (set in e2e/.env, see e2e/.env.example):
# TEST_IDENTIFIER, TEST_PASSWORD
# ADDRESS_NAME, ADDRESS_PHONE, ADDRESS_LINE1 (used only when no address exists)
#
# Android-only suite (Android package id).
appId: in.freshyo.app
---
- launchApp:
clearState: false
# The app can resume on whatever screen the previous run left open (product
# details, checkout, an order, ...). All of those still render the bottom tab
# bar, so tapping the Home tab is a reliable way back to the grid.
- tapOn:
text: "^Home$"
optional: true
# --- Home is ready ----------------------------------------------------------
# Anchor on the add button itself (present on every card that isn't already in
# the cart), so a cart carried over from a previous run doesn't matter.
- extendedWaitUntil:
visible:
id: "add-to-cart-icon"
timeout: 90000
# --- Add a product straight from the home card ------------------------------
# Use the card's cart icon instead of opening the product detail screen; on home
# that icon opens the Add-to-Cart dialog (useAddToCartDialog). The icon sits
# inside the card's touchable, so the tap can occasionally fall through to the
# card — when that happens we're on the detail page and add from there instead.
- tapOn:
id: "add-to-cart-icon"
index: 0
- runFlow:
when:
visible: "Product Details"
commands:
- extendedWaitUntil:
visible: "Add to Cart"
timeout: 30000
- tapOn: "Add to Cart"
# --- Choose a delivery slot in the dialog, then add -------------------------
- extendedWaitUntil:
visible: "Select Delivery Slot"
timeout: 30000
- tapOn:
id: "slot-option"
index: 0
- tapOn:
id: "add-to-cart-confirm"
# --- Cart -> checkout -> place -> glimpse -> cancel -------------------------
# Shared with user-search-order.yaml so both order paths stay in step.
- tapOn:
id: "go-to-cart"
- runFlow: ../subflows/checkout-and-place-order.yaml
- runFlow: ../subflows/glimpse-and-cancel.yaml

View file

@ -1,13 +0,0 @@
# Smoke test: the app launches and the home screen renders (no auth needed —
# home is browsable while logged out).
# Android-only suite (Android package id).
appId: in.freshyo.app
---
- launchApp:
clearState: false
# Home has rendered when a product card is on screen. Dev builds are slow to
# cold-start (Metro bundle), so allow plenty of time.
- extendedWaitUntil:
visible:
id: "product-card"
timeout: 60000

View file

@ -1,70 +0,0 @@
# User app: search for the product the admin flow just created, add it to the
# cart, and order it (then cancel, so the round leaves no live orders).
#
# The catalog the app searches comes from a versioned products.json on the CDN
# (`/products/v-<cacheVersion>/products.json`), and the backend bumps that
# version when a product is created, so the app only sees it after a refresh —
# hence the pull-to-refresh and the generous wait below.
#
# Env (see e2e/run.sh): PRODUCT_NAME, TEST_IDENTIFIER, TEST_PASSWORD, ADDRESS_*
appId: in.freshyo.app
---
- launchApp:
clearState: false
# The app resumes wherever the last run left it; the Home tab is always there.
- tapOn:
text: "^Home$"
optional: true
- extendedWaitUntil:
visible:
id: "add-to-cart-icon"
timeout: 90000
# --- Pull to refresh so the newly created product is in the catalog ---------
- swipe:
start: "50%,30%"
end: "50%,80%"
- waitForAnimationToEnd:
timeout: 10000
- extendedWaitUntil:
visible:
id: "add-to-cart-icon"
timeout: 60000
# --- Search ------------------------------------------------------------------
# The home search bar is not editable — tapping it opens the search screen.
- tapOn: "Search fresh meat..."
- extendedWaitUntil:
visible: "All Products"
timeout: 30000
- inputText: ${PRODUCT_NAME}
- hideKeyboard
- extendedWaitUntil:
visible: "${PRODUCT_NAME}.*"
timeout: 120000
# --- Add the result to the cart ---------------------------------------------
# Search results render the full-width "Add to Cart" button on the card (they
# don't use the small cart icon the home grid uses). An item already in the cart
# shows a quantifier instead, so the add is conditional — a cart carried over
# from a previous run still ends up ordered.
- runFlow:
when:
visible: "Add to Cart"
commands:
- tapOn: "Add to Cart"
- extendedWaitUntil:
visible: "Select Delivery Slot"
timeout: 30000
- tapOn:
id: "slot-option"
index: 0
- tapOn:
id: "add-to-cart-confirm"
- tapOn:
id: "go-to-cart"
index: 0
# --- Checkout -> place -> glimpse -> cancel ---------------------------------
- runFlow: ../subflows/checkout-and-place-order.yaml
- runFlow: ../subflows/glimpse-and-cancel.yaml

View file

@ -1,77 +0,0 @@
#!/usr/bin/env bash
#
# Runs the Maestro E2E suite for apps/user-ui — Android only.
#
# - loads secrets/defaults from the gitignored e2e/.env (Maestro has no .env support)
# - always passes --platform android, so it can never target an iOS simulator
# - falls back to ~/.maestro/bin/maestro when `maestro` isn't on PATH
#
# Usage:
# bash e2e/run.sh # all flows
# bash e2e/run.sh flows/place-order.yaml # a single flow
# bash e2e/run.sh --device emulator-5554 # target a specific emulator
# bash e2e/run.sh --format junit # extra Maestro flags pass through
set -euo pipefail
E2E_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$E2E_DIR/.env"
if [ -f "$ENV_FILE" ]; then
# `set -a` exports every variable defined while sourcing.
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
else
echo "warning: $ENV_FILE not found — copy e2e/.env.example to e2e/.env and fill it in." >&2
fi
MAESTRO_BIN="${MAESTRO_BIN:-maestro}"
if ! command -v "$MAESTRO_BIN" >/dev/null 2>&1; then
MAESTRO_BIN="$HOME/.maestro/bin/maestro"
fi
# Allow flow paths relative to e2e/ (e.g. `flows/place-order.yaml`) while
# running from the repo root. Anything else is forwarded to Maestro as-is.
TARGET="$E2E_DIR"
if [ "$#" -gt 0 ]; then
case "$1" in
-*) : ;;
*)
if [ -e "$E2E_DIR/$1" ]; then
TARGET="$E2E_DIR/$1"
shift
fi
;;
esac
fi
# Values shared across the admin + user flows in one round. The slot is always
# created for the 15th of next month, so it is in the future whatever time the
# suite runs at (the admin pickers are driven with SLOT_DATE_DESC).
SLOT_DATE_DESC="$(date -v+1m -v15d '+%d %B %Y')"
SLOT_DELIVERY_LABEL="$(date -v+1m -v15d '+%d %b'), 6:00 PM"
PRODUCT_NAME="${PRODUCT_NAME:-E2E Test Item $(date '+%d%b-%H%M')}"
PRODUCT_PRICE="${PRODUCT_PRICE:-499}"
# Maestro doesn't read the shell environment — values from .env have to be
# forwarded explicitly as `-e KEY=VALUE`.
ENV_ARGS=()
for key in TEST_IDENTIFIER TEST_PASSWORD ADDRESS_NAME ADDRESS_PHONE ADDRESS_LINE1 \
ADMIN_NAME ADMIN_PASSWORD PRODUCT_NAME PRODUCT_PRICE \
SLOT_DATE_DESC SLOT_DELIVERY_LABEL; do
if [ -n "${!key:-}" ]; then
ENV_ARGS+=(-e "$key=${!key}")
fi
done
# Fixture photo for the product-image step. The admin app can only set a product
# image through expo-image-picker -> the Android Photo Picker, which lists photos
# already on the device, so make sure one is there (idempotent).
FIXTURE_SRC="$E2E_DIR/../apps/user-ui/assets/images/icon.png"
if [ -f "$FIXTURE_SRC" ] && adb devices 2>/dev/null | grep -q "device$"; then
adb push "$FIXTURE_SRC" /sdcard/Pictures/e2e-test-image.png >/dev/null 2>&1 || true
adb shell "am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file:///sdcard/Pictures/e2e-test-image.png" >/dev/null 2>&1 || true
fi
exec "$MAESTRO_BIN" test "$TARGET" --platform android ${ENV_ARGS[@]+"${ENV_ARGS[@]}"} "$@"

View file

@ -1,25 +0,0 @@
# Reusable admin login subflow.
#
# Only acts when the admin login screen is actually showing, so an existing
# staff session (launchApp without clearState) is left untouched.
#
# Env (from e2e/.env): ADMIN_NAME, ADMIN_PASSWORD
appId: in.freshyo.adminui
---
- runFlow:
when:
visible: "Admin Login"
commands:
- tapOn:
id: "login-name-input"
- inputText: ${ADMIN_NAME}
- tapOn:
id: "login-password-input"
- inputText: ${ADMIN_PASSWORD}
- hideKeyboard
- tapOn:
id: "login-button"
# The screen title disappears once we land on the dashboard.
- extendedWaitUntil:
notVisible: "Admin Login"
timeout: 60000

View file

@ -1,96 +0,0 @@
# Places whatever is in the cart, from the cart page through to the success
# screen. Shared by place-order.yaml (add from home) and user-search-order.yaml
# (add from search).
#
# Starts on the cart page and ends on "Order Placed Successfully!".
appId: in.freshyo.app
---
- assertVisible: "Scheduled Delivery Cart"
# The Checkout button sits under the bill details, below the fold.
- scrollUntilVisible:
element:
id: "checkout-button"
direction: DOWN
- tapOn:
id: "checkout-button"
# --- Sign in ---------------------------------------------------------------
# Checkout is behind useAuthenticatedRoute, so a guest is bounced to the login
# screen here. Let the redirect settle before the conditional check, otherwise
# the login screen hasn't rendered yet and the subflow is skipped.
# login.yaml is a no-op when a session already exists (OTP login can't be
# automated, so a password must be set on the test account).
- waitForAnimationToEnd:
timeout: 15000
- runFlow: login.yaml
# Login lands back on Home, so once we're authenticated head to the cart again.
# (Skipped when a session already existed and we're still on the checkout page.)
- runFlow:
when:
visible:
id: "add-to-cart-icon"
commands:
- tapOn:
id: "go-to-cart"
- scrollUntilVisible:
element:
id: "checkout-button"
direction: DOWN
- tapOn:
id: "checkout-button"
- extendedWaitUntil:
visible: "Scheduled Delivery Checkout"
timeout: 45000
# --- Add an address only if the account has none ----------------------------
# The selector renders "No addresses found" both when the account has none and
# while the addresses query is still loading, so settle and check again before
# creating one — otherwise we open the add-address form for an account that
# already has an address.
- runFlow:
when:
visible: "No addresses found"
commands:
- waitForAnimationToEnd:
timeout: 10000
- runFlow:
when:
visible: "No addresses found"
commands:
- tapOn: "Add Address"
- tapOn:
id: "address-name"
- inputText: ${ADDRESS_NAME}
- tapOn:
id: "address-phone"
- inputText: ${ADDRESS_PHONE}
- tapOn:
id: "address-line1"
- inputText: ${ADDRESS_LINE1}
- hideKeyboard
- tapOn:
id: "address-submit"
- assertVisible: "Delivery Address"
# --- Pay (COD is preselected) and place the order ---------------------------
# The payment section sits below the address block; scrolling also lifts the
# button clear of the dev-warning toast that overlays the bottom of the screen.
- scrollUntilVisible:
element:
text: "Cash on Delivery"
direction: DOWN
- assertVisible: "Cash on Delivery"
- scrollUntilVisible:
element:
id: "place-order-button"
direction: DOWN
centerElement: true
- tapOn:
id: "place-order-button"
# --- Confirmation -----------------------------------------------------------
# The checkout shows a "Placing your order..." dialog first; give the API time.
- extendedWaitUntil:
visible: "Order Placed Successfully!"
timeout: 60000
- assertVisible: "Continue Shopping"

View file

@ -1,14 +0,0 @@
# Dismisses the React Native dev LogBox toast ("Open debugger to view warnings").
#
# In a dev build that toast sits over the bottom of the screen and swallows taps
# (it blocked the admin add-slot FAB and the user app's bottom tab bar). It only
# appears when the app logs a warning, so this is conditional — tapping its
# bottom-right X unconditionally could hit a real control instead.
appId: in.freshyo.adminui
---
- runFlow:
when:
visible: "Open debugger to view warnings."
commands:
- tapOn:
point: "91%,90%"

View file

@ -1,66 +0,0 @@
# From the order-success screen: back to home, capture the Next Order glimpse
# card, open the order from it and cancel it — so a run leaves no live orders
# behind. Shared by place-order.yaml and user-search-order.yaml.
appId: in.freshyo.app
---
- tapOn: "Continue Shopping"
# --- Home glimpse -----------------------------------------------------------
# NextOrderGlimpse sits below "Our Stores" in the home header, so scroll to it.
- scrollUntilVisible:
element:
text: "Upcoming Order"
direction: DOWN
centerElement: true
timeout: 30000
- assertVisible: "Track Order"
- takeScreenshot: "home-upcoming-order-glimpse"
# --- Open it from the glimpse and cancel ------------------------------------
# The card sets the navigation target and replaces to /me, which forwards to
# /(drawer)/(tabs)/me/my-orders/{id}.
- tapOn: "Upcoming Order"
- extendedWaitUntil:
visible: "Order Items"
timeout: 30000
- scrollUntilVisible:
element:
text: "Cancel Order"
direction: DOWN
centerElement: true
- tapOn: "Cancel Order"
- extendedWaitUntil:
visible: "Reason for cancellation"
timeout: 20000
- tapOn:
id: "cancel-reason"
- inputText: "Ordered by mistake"
- hideKeyboard
# The sheet slides back down as the keyboard collapses, and a tap during that
# animation misses. Tap the sheet's (non-interactive) warning text to force the
# layout to settle before confirming.
- tapOn: "Are you sure you want to cancel this order\\?.*"
- waitForAnimationToEnd:
timeout: 8000
# Tapping this button right after the sheet settles can be swallowed (the JS
# thread is still busy finishing the navigation), so keep tapping until the
# request actually goes out — while it is in flight the label is replaced by a
# spinner, which is what ends the loop.
- repeat:
times: 5
commands:
- runFlow:
when:
visible: "Confirm Cancellation"
commands:
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "Confirm Cancellation"
# Native success alert, then the detail page refetches with the cancellation.
- extendedWaitUntil:
visible: "Order cancelled successfully"
timeout: 20000
- tapOn: "OK"
- extendedWaitUntil:
visible: "Cancellation Reason"
timeout: 30000

View file

@ -1,24 +0,0 @@
# Reusable login subflow.
#
# Runs only when the login screen is actually showing, so an already signed-in
# session is left untouched. (OTP login can't be automated — no dev bypass — so
# this uses the username + password path: "other ways to login".)
#
# Android-only suite (Android package id).
appId: in.freshyo.app
---
- runFlow:
when:
visible: "other ways to login"
commands:
- tapOn: "other ways to login"
- tapOn: "Enter your email or mobile"
- inputText: ${TEST_IDENTIFIER}
- tapOn: "Enter your password"
- inputText: ${TEST_PASSWORD}
- hideKeyboard
- tapOn: "Login"
# Wait for the login screen to go away (the caller decides where we land).
- extendedWaitUntil:
notVisible: "other ways to login"
timeout: 60000

194
image-upload.diff Normal file
View file

@ -0,0 +1,194 @@
diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx
index e227458..4465099 100644
--- a/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx
+++ b/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx
@@ -5,6 +5,15 @@ import ProductForm from '@/src/components/ProductForm'
import { trpc } from '@/src/trpc-client'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'
+// SKUs store bucket-relative object keys, not URLs. Drop the query string and
+// keep the last two path segments:
+// https://<acct>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
+// -> product-images/1.jpg
+const toImageKey = (url: string): string => {
+ const parts = url.split('?')[0].split('/').filter(Boolean)
+ return parts.slice(-2).join('/')
+}
+
export default function AddProduct() {
const createProduct = trpc.admin.product.createProduct.useMutation()
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
@@ -63,7 +72,8 @@ export default function AddProduct() {
let urlCursor = 0
const skus = values.variants.map((variant: any, vIndex: number) => {
const count = imageCounts[vIndex]
- const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
+ // Send keys, not the presigned URLs.
+ const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count).map(toImageKey)
urlCursor += count
return {
diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx
index ac72345..69f0c97 100644
--- a/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx
+++ b/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx
@@ -6,6 +6,15 @@ import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
+// SKUs store bucket-relative object keys, not URLs. Drop the query string and
+// keep the last two path segments:
+// https://<acct>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
+// -> product-images/1.jpg
+const toImageKey = (url: string): string => {
+ const parts = url.split('?')[0].split('/').filter(Boolean)
+ return parts.slice(-2).join('/')
+}
+
export default function EditProduct() {
const { id } = useLocalSearchParams();
const productId = Number(id);
@@ -140,9 +149,10 @@ export default function EditProduct() {
// Existing images (mimeType === null) stay as-is
const existingUrls = variantImages[vIndex]
?.filter((img) => img.mimeType === null)
- .map((img) => img.url) || []
+ .map((img) => toImageKey(img.url)) || []
- const allUrls = [...existingUrls, ...newUrls]
+ // Send keys, not URLs — the SKU stores the bucket-relative object key.
+ const allUrls = [...existingUrls, ...newUrls.map(toImageKey)]
return {
id: variant.id,
diff --git a/apps/admin-ui/hooks/useUploadToObjectStore.ts b/apps/admin-ui/hooks/useUploadToObjectStore.ts
index 8327cfa..25c8ee7 100644
--- a/apps/admin-ui/hooks/useUploadToObjectStore.ts
+++ b/apps/admin-ui/hooks/useUploadToObjectStore.ts
@@ -43,9 +43,11 @@ export function useUploadToObjectStorage() {
headers: { 'Content-Type': mimeType },
});
- if (!response.ok) {
- throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
- }
+ if (!response.ok) {
+ const body = await response.text(); // <Error><Code>…</Code></Error>
+ console.error('upload failed', response.status, body);
+ throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
+ }
// Update progress
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
diff --git a/apps/backend/src/lib/s3-client.ts b/apps/backend/src/lib/s3-client.ts
index 1f8b437..47372ca 100644
--- a/apps/backend/src/lib/s3-client.ts
+++ b/apps/backend/src/lib/s3-client.ts
@@ -118,10 +118,14 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
try {
const client = getAwsClient()
- const url = buildObjectUrl(getS3BucketName(), s3Url)
- const signedRequest = await client.sign(url, {
+ // X-Amz-Expires must be a QUERY param, not a header: with signQuery, aws4fetch
+ // hoists header values into the query but still lists them in
+ // X-Amz-SignedHeaders, and R2 then hashes the missing header as empty — which
+ // makes the signature mismatch (S3 tolerates it, R2 does not).
+ const url = new URL(buildObjectUrl(getS3BucketName(), s3Url))
+ url.searchParams.set('X-Amz-Expires', String(expiresIn))
+ const signedRequest = await client.sign(url.toString(), {
method: 'GET',
- headers: { 'X-Amz-Expires': String(expiresIn) },
aws: { signQuery: true },
})
return signedRequest.url
@@ -170,13 +174,13 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
await createUploadUrlStatus(key)
const client = getAwsClient()
- const url = buildObjectUrl(getS3BucketName(), key)
- const signedRequest = await client.sign(url, {
+ // Same as the GET path above: keep X-Amz-Expires in the query, and do not
+ // sign Content-Type — leaving it unsigned means the client may send any value
+ // without breaking the signature.
+ const url = new URL(buildObjectUrl(getS3BucketName(), key))
+ url.searchParams.set('X-Amz-Expires', String(expiresIn))
+ const signedRequest = await client.sign(url.toString(), {
method: 'PUT',
- headers: {
- 'Content-Type': mimeType,
- 'X-Amz-Expires': String(expiresIn),
- },
aws: { signQuery: true },
})
@@ -193,15 +197,22 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
// return decodeURIComponent(rawKey);
// }
-// New function (excludes bucket name)
+/**
+ * Normalise an image reference to the bucket-relative object key that SKUs store.
+ *
+ * Accepts either a full (presigned) URL or an already-extracted key, so callers
+ * can send whichever they have. Drops the query string, then keeps the last two
+ * path segments:
+ *
+ * https://<account>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
+ * -> product-images/1.jpg
+ * product-images/1.jpg
+ * -> product-images/1.jpg
+ */
export function extractKeyFromPresignedUrl(url: string): string {
- const u = new URL(url)
- const rawKey = u.pathname.replace(/^\/+/, '') // remove leading slash
- const decodedKey = decodeURIComponent(rawKey)
- // Remove bucket prefix
- const parts = decodedKey.split('/')
- parts.shift() // Remove bucket name
- return parts.join('/')
+ const withoutQuery = url.split('?')[0]
+ const parts = withoutQuery.split('/').filter(Boolean)
+ return decodeURIComponent(parts.slice(-2).join('/'))
}
export async function claimUploadUrl(url: string): Promise<void> {
diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts
index 7018961..57b51a7 100644
--- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts
+++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts
@@ -260,8 +260,6 @@ export const productRouter = router({
await deleteImageUtil({ keys: deletedImageKeys })
}
- const allUploadUrls: string[] = skus.flatMap((sku) => sku.images)
-
const skuInputs = skus.map((sku) => ({
id: sku.id,
name: sku.name ?? null,
diff --git a/packages/ui/src/components/ImageUploaderNeo.tsx b/packages/ui/src/components/ImageUploaderNeo.tsx
index eb5e5d7..37201f9 100644
--- a/packages/ui/src/components/ImageUploaderNeo.tsx
+++ b/packages/ui/src/components/ImageUploaderNeo.tsx
@@ -11,11 +11,12 @@ import usePickImage from './use-pick-image'
export type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from '@packages/shared'
-const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
+const ImageUploaderNeo: React.FC<ImageUploaderNeoProps & { testID?: string }> = ({
images,
onImageAdd,
onImageRemove,
allowMultiple = true,
+ testID,
}) => {
const totalImageCount = images.length
@@ -60,6 +61,7 @@ const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
</View>
))}
<MyTouchableOpacity
+ testID={testID}
disabled={!allowMultiple && totalImageCount >= 1}
onPress={handlePickImage}
style={tw`w-1/3 px-1 mb-2`}

View file

@ -8,7 +8,6 @@
"dev": "turbo run dev --parallel", "dev": "turbo run dev --parallel",
"lint": "turbo run lint", "lint": "turbo run lint",
"typecheck": "bash typecheck", "typecheck": "bash typecheck",
"e2e": "bash e2e/run.sh",
"web-ui": "bun run --filter web-ui", "web-ui": "bun run --filter web-ui",
"web-ui:dev": "bun run web-ui dev", "web-ui:dev": "bun run web-ui dev",
"web-ui:build": "bun run web-ui build", "web-ui:build": "bun run web-ui build",

View file

@ -11,11 +11,12 @@ import usePickImage from './use-pick-image'
export type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from '@packages/shared' export type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from '@packages/shared'
const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({ const ImageUploaderNeo: React.FC<ImageUploaderNeoProps & { testID?: string }> = ({
images, images,
onImageAdd, onImageAdd,
onImageRemove, onImageRemove,
allowMultiple = true, allowMultiple = true,
testID,
}) => { }) => {
const totalImageCount = images.length const totalImageCount = images.length
@ -60,6 +61,7 @@ const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
</View> </View>
))} ))}
<MyTouchableOpacity <MyTouchableOpacity
testID={testID}
disabled={!allowMultiple && totalImageCount >= 1} disabled={!allowMultiple && totalImageCount >= 1}
onPress={handlePickImage} onPress={handlePickImage}
style={tw`w-1/3 px-1 mb-2`} style={tw`w-1/3 px-1 mb-2`}