From 839c3f8380f37c9b9ed8de6099ee25e851578abf Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:26:59 +0530 Subject: [PATCH] enh --- AGENTS.md | 1 + .../admin-ui/app/(drawer)/dashboard/index.tsx | 1 + .../app/(drawer)/dashboard/products/add.tsx | 12 +- .../app/(drawer)/dashboard/products/edit.tsx | 14 +- apps/admin-ui/hooks/useUploadToObjectStore.ts | 8 +- apps/admin-ui/src/components/ProductForm.tsx | 14 + apps/backend/src/lib/s3-client.ts | 45 +-- .../src/trpc/apis/admin-apis/apis/product.ts | 2 - change-log.txt | 91 ++++++ e2e/.env | 17 -- e2e/.env.example | 14 - e2e/README.md | 125 -------- e2e/config.yaml | 21 -- e2e/flows/admin-attach-product.yaml | 92 ------ e2e/flows/admin-product-full.yaml | 279 ------------------ e2e/flows/admin-product.yaml | 64 ---- e2e/flows/admin-slot.yaml | 103 ------- e2e/flows/place-order.yaml | 65 ---- e2e/flows/smoke.yaml | 13 - e2e/flows/user-search-order.yaml | 70 ----- e2e/run.sh | 77 ----- e2e/subflows/admin-login.yaml | 25 -- e2e/subflows/checkout-and-place-order.yaml | 96 ------ e2e/subflows/dismiss-dev-toast.yaml | 14 - e2e/subflows/glimpse-and-cancel.yaml | 66 ----- e2e/subflows/login.yaml | 24 -- image-upload.diff | 194 ++++++++++++ package.json | 1 - .../ui/src/components/ImageUploaderNeo.tsx | 4 +- 29 files changed, 360 insertions(+), 1192 deletions(-) delete mode 100644 e2e/.env delete mode 100644 e2e/.env.example delete mode 100644 e2e/README.md delete mode 100644 e2e/config.yaml delete mode 100644 e2e/flows/admin-attach-product.yaml delete mode 100644 e2e/flows/admin-product-full.yaml delete mode 100644 e2e/flows/admin-product.yaml delete mode 100644 e2e/flows/admin-slot.yaml delete mode 100644 e2e/flows/place-order.yaml delete mode 100644 e2e/flows/smoke.yaml delete mode 100644 e2e/flows/user-search-order.yaml delete mode 100644 e2e/run.sh delete mode 100644 e2e/subflows/admin-login.yaml delete mode 100644 e2e/subflows/checkout-and-place-order.yaml delete mode 100644 e2e/subflows/dismiss-dev-toast.yaml delete mode 100644 e2e/subflows/glimpse-and-cancel.yaml delete mode 100644 e2e/subflows/login.yaml create mode 100644 image-upload.diff diff --git a/AGENTS.md b/AGENTS.md index feff7cb..312b63e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## Important instructions - 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 diff --git a/apps/admin-ui/app/(drawer)/dashboard/index.tsx b/apps/admin-ui/app/(drawer)/dashboard/index.tsx index fbc31b9..571c118 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/index.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/index.tsx @@ -108,6 +108,7 @@ export default function Dashboard() { description: 'Create a new product listing', route: '/(drawer)/dashboard/products/add', category: 'quick', + testID: 'add-product-menu-item', iconColor: theme.colors.brand500, iconBg: theme.colors.brand50, }, 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://.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://.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(); // + 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/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index cd9a347..8ac2cb1 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -255,6 +255,7 @@ const ProductForm = forwardRef(({ return ( (({ style={{ marginBottom: 16 }} /> (({ style={{ marginBottom: 16 }} /> (({ style={{ marginBottom: 16 }} /> (({ /> (({ Variants { push(defaultVariant()) setVariantImages((prev) => [...prev, []]) @@ -355,6 +361,7 @@ const ProductForm = forwardRef(({ (({ @@ -399,6 +407,7 @@ const ProductForm = forwardRef(({ (({ (({ setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)} style={tw`mr-3`} @@ -476,6 +487,7 @@ const ProductForm = forwardRef(({ {!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && ( setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)} style={tw`mr-3`} @@ -498,6 +510,7 @@ const ProductForm = forwardRef(({ {variant.isFlashAvailable && ( (({ )} setVariantImages((prev) => { 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://.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 { 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/change-log.txt b/change-log.txt index 64ecf78..8caf727 100644 --- a/change-log.txt +++ b/change-log.txt @@ -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 === - import: + useReconcileCartSlots from '@/hooks/cart-query-hooks' - 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 +(SignatureDoesNotMatch). 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", diff --git a/e2e/.env b/e2e/.env deleted file mode 100644 index ef28717..0000000 --- a/e2e/.env +++ /dev/null @@ -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 diff --git a/e2e/.env.example b/e2e/.env.example deleted file mode 100644 index 95d100b..0000000 --- a/e2e/.env.example +++ /dev/null @@ -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= - diff --git a/e2e/README.md b/e2e/README.md deleted file mode 100644 index 64b33eb..0000000 --- a/e2e/README.md +++ /dev/null @@ -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//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`. diff --git a/e2e/config.yaml b/e2e/config.yaml deleted file mode 100644 index 94287b2..0000000 --- a/e2e/config.yaml +++ /dev/null @@ -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 diff --git a/e2e/flows/admin-attach-product.yaml b/e2e/flows/admin-attach-product.yaml deleted file mode 100644 index 419e06a..0000000 --- a/e2e/flows/admin-attach-product.yaml +++ /dev/null @@ -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 #", -# 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 — " ". 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" diff --git a/e2e/flows/admin-product-full.yaml b/e2e/flows/admin-product-full.yaml deleted file mode 100644 index f742976..0000000 --- a/e2e/flows/admin-product-full.yaml +++ /dev/null @@ -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" diff --git a/e2e/flows/admin-product.yaml b/e2e/flows/admin-product.yaml deleted file mode 100644 index 29f770a..0000000 --- a/e2e/flows/admin-product.yaml +++ /dev/null @@ -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" diff --git a/e2e/flows/admin-slot.yaml b/e2e/flows/admin-slot.yaml deleted file mode 100644 index a391c61..0000000 --- a/e2e/flows/admin-slot.yaml +++ /dev/null @@ -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 diff --git a/e2e/flows/place-order.yaml b/e2e/flows/place-order.yaml deleted file mode 100644 index 1ad229d..0000000 --- a/e2e/flows/place-order.yaml +++ /dev/null @@ -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 diff --git a/e2e/flows/smoke.yaml b/e2e/flows/smoke.yaml deleted file mode 100644 index 164d5cc..0000000 --- a/e2e/flows/smoke.yaml +++ /dev/null @@ -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 diff --git a/e2e/flows/user-search-order.yaml b/e2e/flows/user-search-order.yaml deleted file mode 100644 index d996a72..0000000 --- a/e2e/flows/user-search-order.yaml +++ /dev/null @@ -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-/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 diff --git a/e2e/run.sh b/e2e/run.sh deleted file mode 100644 index 1165fcc..0000000 --- a/e2e/run.sh +++ /dev/null @@ -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[@]}"} "$@" diff --git a/e2e/subflows/admin-login.yaml b/e2e/subflows/admin-login.yaml deleted file mode 100644 index 0de0d0f..0000000 --- a/e2e/subflows/admin-login.yaml +++ /dev/null @@ -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 diff --git a/e2e/subflows/checkout-and-place-order.yaml b/e2e/subflows/checkout-and-place-order.yaml deleted file mode 100644 index 98b3ab9..0000000 --- a/e2e/subflows/checkout-and-place-order.yaml +++ /dev/null @@ -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" diff --git a/e2e/subflows/dismiss-dev-toast.yaml b/e2e/subflows/dismiss-dev-toast.yaml deleted file mode 100644 index 98ebed6..0000000 --- a/e2e/subflows/dismiss-dev-toast.yaml +++ /dev/null @@ -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%" diff --git a/e2e/subflows/glimpse-and-cancel.yaml b/e2e/subflows/glimpse-and-cancel.yaml deleted file mode 100644 index 4f84766..0000000 --- a/e2e/subflows/glimpse-and-cancel.yaml +++ /dev/null @@ -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 diff --git a/e2e/subflows/login.yaml b/e2e/subflows/login.yaml deleted file mode 100644 index c6c9487..0000000 --- a/e2e/subflows/login.yaml +++ /dev/null @@ -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 diff --git a/image-upload.diff b/image-upload.diff new file mode 100644 index 0000000..c668761 --- /dev/null +++ b/image-upload.diff @@ -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://.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://.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(); // ++ 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://.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 { +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 = ({ ++const ImageUploaderNeo: React.FC = ({ + images, + onImageAdd, + onImageRemove, + allowMultiple = true, ++ testID, + }) => { + const totalImageCount = images.length + +@@ -60,6 +61,7 @@ const ImageUploaderNeo: React.FC = ({ + + ))} + = 1} + onPress={handlePickImage} + style={tw`w-1/3 px-1 mb-2`} diff --git a/package.json b/package.json index 6074288..64b4d6d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "dev": "turbo run dev --parallel", "lint": "turbo run lint", "typecheck": "bash typecheck", - "e2e": "bash e2e/run.sh", "web-ui": "bun run --filter web-ui", "web-ui:dev": "bun run web-ui dev", "web-ui:build": "bun run web-ui build", 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 = ({ +const ImageUploaderNeo: React.FC = ({ images, onImageAdd, onImageRemove, allowMultiple = true, + testID, }) => { const totalImageCount = images.length @@ -60,6 +61,7 @@ const ImageUploaderNeo: React.FC = ({ ))} = 1} onPress={handlePickImage} style={tw`w-1/3 px-1 mb-2`}