Compare commits
32 Commits
bd2d5610b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d28c13c4b | |||
| 510fba47b5 | |||
| 39263a4af5 | |||
| d3159bc2ae | |||
|
|
0735e3513c | ||
|
|
b10ef57d37 | ||
|
|
1e4c8982a5 | ||
|
|
4cd83a9281 | ||
|
|
207831eb3e | ||
|
|
0ff2949c1e | ||
|
|
591b5d7b88 | ||
|
|
a544b051cc | ||
| 230942e776 | |||
| e671827dc4 | |||
| 67df7957c6 | |||
| aef4491f97 | |||
| ca927fd05e | |||
| 9a44eed2a6 | |||
| 2b498607d1 | |||
| 68b0055cfe | |||
| e3d8d30b1b | |||
| 9642eb0268 | |||
| 41a222ea11 | |||
| b9574eb96a | |||
| 52bb1d1af4 | |||
| 91cedf54c9 | |||
| b43e28e09d | |||
| 9c07bd7e0e | |||
| 277eff3da0 | |||
| 7ce837b49a | |||
| e7e11771b3 | |||
| c0a69707e6 |
36
.env
Normal file
36
.env
Normal file
@@ -0,0 +1,36 @@
|
||||
# Option A: single URL (recommended)
|
||||
DATABASE_URL=postgresql+psycopg2://admin:Package%40123%23@31.97.228.132:5432/pgworkolik
|
||||
DB_ECHO=false
|
||||
|
||||
# App Settings
|
||||
APP_SECRET=80khAhsZiYbCXB_mehHfGZ-oAhmU9jxPp8AR11AUuvWz-wpUgIXliqVOfNihYIhV
|
||||
|
||||
|
||||
# Option B: parts (no DATABASE_URL needed if you set all parts)
|
||||
DB_HOST=31.97.228.132
|
||||
DB_PORT=5432
|
||||
DB_NAME=pgworkolik
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=Package@123#
|
||||
DB_ECHO=false
|
||||
|
||||
# App Settings
|
||||
APP_SECRET=80khAhsZiYbCXB_mehHfGZ-oAhmU9jxPp8AR11AUuvWz-wpUgIXliqVOfNihYIhV
|
||||
BACKGROUND_IMAGE_URL=assets/bg.jpg
|
||||
|
||||
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=workolik360@gmail.com
|
||||
SMTP_PASSWORD=nggo euhg chus yyyw
|
||||
SMTP_USE_TLS=true
|
||||
SMTP_FROM_EMAIL=workolik360@gmail.com
|
||||
SMTP_FROM_NAME=Workolik Team
|
||||
|
||||
REPORT_RECIPIENTS=Darshan@caman.au,darshan@caman.com.au,workolik360@gmail.com,ColinA@caman.au,ColinA@caman.com.au,tabs@tuckerfresh.com.au,jay@tuckerfresh.com.au,sanjay@tuckerfresh.com.au,veer@tuckerfresh.com.au
|
||||
|
||||
|
||||
BCC_RECIPIENTS=fazulilahi@gmail.com
|
||||
|
||||
# Darshan@caman.au,ColinA@caman.au,tabs@tuckerfresh.com.au,
|
||||
# jay@tuckerfresh.com.au
|
||||
1
.github/workflows/backend-foundation.yml
vendored
1
.github/workflows/backend-foundation.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Backend Foundation
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger only — auto-triggers disabled (free plan)
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
67
.github/workflows/maestro-e2e.yml
vendored
Normal file
67
.github/workflows/maestro-e2e.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
# Maestro E2E Tests
|
||||
# Runs on: manual trigger, or when maestro flows change
|
||||
# Requires secrets: TEST_STAFF_PHONE, TEST_STAFF_OTP, TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD
|
||||
# Optional: TEST_CLIENT_COMPANY, TEST_STAFF_SIGNUP_PHONE
|
||||
name: Maestro E2E
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger only — auto-triggers disabled (free plan)
|
||||
|
||||
jobs:
|
||||
maestro-e2e:
|
||||
name: 🎭 Maestro E2E
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🦋 Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.x'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: 🔧 Install Firebase CLI
|
||||
run: npm install -g firebase-tools
|
||||
|
||||
- name: 📦 Get dependencies
|
||||
run: make mobile-install
|
||||
|
||||
- name: 🔨 Build Staff APK
|
||||
run: make mobile-staff-build PLATFORM=apk MODE=debug
|
||||
|
||||
- name: 🔨 Build Client APK
|
||||
run: make mobile-client-build PLATFORM=apk MODE=debug
|
||||
|
||||
- name: 📲 Start emulator
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 33
|
||||
target: default
|
||||
arch: x86_64
|
||||
profile: Nexus 6
|
||||
script: |
|
||||
# Install Maestro
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
export PATH="$HOME/.maestro/bin:$PATH"
|
||||
maestro --version
|
||||
|
||||
# Install APKs
|
||||
adb install -r apps/mobile/apps/staff/build/app/outputs/flutter-apk/app-debug.apk
|
||||
adb install -r apps/mobile/apps/client/build/app/outputs/flutter-apk/app-debug.apk
|
||||
|
||||
# Run auth flows (Staff + Client)
|
||||
maestro test --shard-split=1 \
|
||||
apps/mobile/apps/staff/maestro/auth/sign_in.yaml \
|
||||
apps/mobile/apps/staff/maestro/auth/sign_up.yaml \
|
||||
apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
apps/mobile/apps/client/maestro/auth/sign_up.yaml \
|
||||
-e TEST_STAFF_PHONE="${{ secrets.TEST_STAFF_PHONE }}" \
|
||||
-e TEST_STAFF_OTP="${{ secrets.TEST_STAFF_OTP }}" \
|
||||
-e TEST_STAFF_SIGNUP_PHONE="${{ secrets.TEST_STAFF_SIGNUP_PHONE }}" \
|
||||
-e TEST_CLIENT_EMAIL="${{ secrets.TEST_CLIENT_EMAIL }}" \
|
||||
-e TEST_CLIENT_PASSWORD="${{ secrets.TEST_CLIENT_PASSWORD }}" \
|
||||
-e TEST_CLIENT_COMPANY="${{ secrets.TEST_CLIENT_COMPANY }}"
|
||||
2
.github/workflows/mobile-ci.yml
vendored
2
.github/workflows/mobile-ci.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Mobile CI
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger only — auto-triggers disabled (free plan)
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -236,4 +237,3 @@ jobs:
|
||||
else
|
||||
echo "✅ Pipeline PASSED"
|
||||
fi
|
||||
|
||||
|
||||
2
.github/workflows/web-quality.yml
vendored
2
.github/workflows/web-quality.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Web Quality
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: # Manual trigger only — auto-triggers disabled (free plan)
|
||||
|
||||
jobs:
|
||||
web-quality:
|
||||
|
||||
13
.streamlit/config.toml
Normal file
13
.streamlit/config.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[theme]
|
||||
base = "light"
|
||||
primaryColor = "#4F46E5"
|
||||
backgroundColor = "#F8FAFC"
|
||||
secondaryBackgroundColor = "#FFFFFF"
|
||||
textColor = "#0F172A"
|
||||
font = "sans serif"
|
||||
|
||||
[client]
|
||||
showSidebarNavigation = false
|
||||
|
||||
[server]
|
||||
fileWatcherType = "poll"
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps (timezone data for TZ support)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
EXPOSE 8501
|
||||
|
||||
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||
|
||||
|
||||
127
README.md
127
README.md
@@ -1,127 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="apps/mobile/packages/design_system/assets/logo-yellow.png" alt="KROW Logo" width="200"/>
|
||||
</p>
|
||||
|
||||
# KROW Workforce Monorepo
|
||||
|
||||
KROW is a comprehensive workforce management platform designed to streamline operations for events, hospitality, and enterprise staffing. This monorepo contains all components of the ecosystem, from the data layer to the user-facing applications.
|
||||
|
||||
## 📍 Current Status
|
||||
|
||||
**Latest Milestone:** M4 (Released: March 5, 2026)
|
||||
- ✅ Staff Mobile App: [v0.0.1-m4](https://github.com/Oloodi/krow-workforce/releases/tag/krow-withus-worker-mobile%2Fdev-v0.0.1-m4)
|
||||
- ✅ Client Mobile App: [v0.0.1-m4](https://github.com/Oloodi/krow-workforce/releases/tag/krow-withus-client-mobile%2Fdev-v0.0.1-m4)
|
||||
|
||||
## 🚀 Repository Structure
|
||||
|
||||
### 📦 Apps (`/apps`)
|
||||
These are the production-ready applications for our users:
|
||||
- **`web-dashboard/`**: The primary React/Vite dashboard for Admin, Vendors, and Clients.
|
||||
- **`mobile/`**: Flutter applications for client and staff.
|
||||
- `client`: The application for final clients to manage orders and billing.
|
||||
- `staff`: The application for staff members (scheduling, clock-in/out, earnings).
|
||||
|
||||
### ⚙️ Backend (`/backend`)
|
||||
The core data engine powering all applications:
|
||||
- **`dataconnect/`**: Firebase Data Connect configuration, GraphQL schemas (PostgreSQL), and auto-generated SDKs.
|
||||
|
||||
### 🛠️ Internal (`/internal`)
|
||||
Tools and resources for the development and operations team:
|
||||
- **`launchpad/`**: A secure portal (DevOps Launchpad) to access internal resources, documentation, and infrastructure links.
|
||||
- **`api-harness/`**: A technical tool for testing and validating the Data Connect API and Cloud Functions.
|
||||
- **`prototypes/`**: Reference code and visual prototypes (synchronized from external sources).
|
||||
|
||||
### 📂 Support Directories
|
||||
- **`/docs`**: Project vision, technical specifications, and guides.
|
||||
- **`/makefiles`**: Modularized `Makefile` logic for project automation.
|
||||
- **`/scripts`**: Automation scripts (security, hachage, environment setup).
|
||||
- **`/firebase`**: Global Firebase configuration (Firestore/Storage rules).
|
||||
- **`/.github`**: GitHub Actions workflows for CI/CD and release automation.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
- **Frontend:** React (Vite)
|
||||
- **Mobile:** Flutter
|
||||
- **Backend:** Firebase (Data Connect, Auth, Hosting, Functions)
|
||||
- **Database:** PostgreSQL (managed via Cloud SQL & Data Connect)
|
||||
- **Infrastructure:** Google Cloud Platform (GCP)
|
||||
|
||||
## 📦 Getting Started
|
||||
|
||||
This project uses a modular `Makefile` for all common tasks.
|
||||
|
||||
1. **View available commands:**
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
2. **Install dependencies (Web):**
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
3. **Run the Web Dashboard locally:**
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
4. **Run the DevOps Launchpad locally:**
|
||||
```bash
|
||||
make launchpad-dev
|
||||
```
|
||||
|
||||
5. **Mobile app development:**
|
||||
```bash
|
||||
make mobile-install
|
||||
make mobile-client-dev-android [DEVICE=android]
|
||||
make mobile-staff-dev-android [DEVICE=android]
|
||||
```
|
||||
|
||||
## 🚀 Release Process
|
||||
|
||||
### Mobile App Releases
|
||||
|
||||
We use GitHub Actions for automated mobile releases:
|
||||
|
||||
- **Standard Release**: Trigger [Product Release workflow](https://github.com/Oloodi/krow-workforce/actions/workflows/product-release.yml)
|
||||
- Auto-extracts version from `pubspec.yaml`
|
||||
- Creates Git tags: `krow-withus-<app>-mobile/<env>-vX.Y.Z`
|
||||
- Generates GitHub Release with CHANGELOG
|
||||
- Builds and signs APK (dev/stage/prod keystores)
|
||||
|
||||
- **Hotfix Release**: Trigger [Hotfix Branch Creation workflow](https://github.com/Oloodi/krow-workforce/actions/workflows/hotfix-branch-creation.yml)
|
||||
- Auto-increments PATCH version
|
||||
- Updates `pubspec.yaml` and `CHANGELOG.md`
|
||||
- Creates PR with fix instructions
|
||||
|
||||
**See:** [Mobile Release Documentation](./docs/RELEASE/mobile-releases.md) for complete guide.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Core Documentation
|
||||
- **[00-vision.md](./docs/00-vision.md)**: Project objectives and guiding principles.
|
||||
- **[01-backend-api-specification.md](./docs/01-backend-api-specification.md)**: (Legacy) Reference for data schemas.
|
||||
- **[02-codemagic-env-vars.md](./docs/02-codemagic-env-vars.md)**: Guide for CI/CD environment variables.
|
||||
- **[03-contributing.md](./docs/03-contributing.md)**: Guidelines for new developers and setup checklist.
|
||||
- **[04-sync-prototypes.md](./docs/04-sync-prototypes.md)**: How to sync prototypes for local dev and AI context.
|
||||
- **[05-project-onboarding-master.md](./docs/05-project-onboarding-master.md)**: Comprehensive onboarding guide and project overview.
|
||||
|
||||
### Mobile Development Documentation
|
||||
- **[MOBILE/00-agent-development-rules.md](./docs/MOBILE/00-agent-development-rules.md)**: Rules and best practices for mobile development.
|
||||
- **[MOBILE/01-architecture-principles.md](./docs/MOBILE/01-architecture-principles.md)**: Flutter clean architecture, package roles, and dependency flow.
|
||||
- **[MOBILE/02-design-system-usage.md](./docs/MOBILE/02-design-system-usage.md)**: Design system components and theming guidelines.
|
||||
- **[MOBILE/03-data-connect-connectors-pattern.md](./docs/MOBILE/03-data-connect-connectors-pattern.md)**: Data Connect integration patterns.
|
||||
- **[MOBILE/04-use-case-completion-audit.md](./docs/MOBILE/04-use-case-completion-audit.md)**: Feature implementation status and audit.
|
||||
- **[MOBILE/05-release-process.md](./docs/MOBILE/05-release-process.md)**: Mobile app release process (quick reference).
|
||||
|
||||
### Release Documentation
|
||||
- **[RELEASE/mobile-releases.md](./docs/RELEASE/mobile-releases.md)**: Comprehensive mobile release guide with versioning, CHANGELOGs, GitHub Actions workflows, and APK signing.
|
||||
|
||||
### CHANGELOGs
|
||||
- **[Staff Mobile CHANGELOG](./apps/mobile/apps/staff/CHANGELOG.md)**: Staff app release history (M3, M4).
|
||||
- **[Client Mobile CHANGELOG](./apps/mobile/apps/client/CHANGELOG.md)**: Client app release history (M3, M4).
|
||||
|
||||
## 🤝 Contributing
|
||||
New to the team? Please read our **[Contributing Guide](./docs/03-contributing.md)** to get your environment set up and understand our workflow.
|
||||
|
||||
---
|
||||
© 2026 KROW Workforce / Oloodi Technologies Inc.
|
||||
101
app.py
Normal file
101
app.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import streamlit as st
|
||||
from app_core.config.settings import AppSettings
|
||||
from app_core.services.auth_service import AuthService
|
||||
from app_core.ui.auth_ui import render_auth_card
|
||||
from app_core.ui.layout import apply_global_style, render_topbar, render_header, render_sidebar_logo
|
||||
|
||||
settings = AppSettings() # loads env
|
||||
|
||||
# App config
|
||||
st.set_page_config(
|
||||
page_title="Workolik",
|
||||
page_icon="assets/workolik.png",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded",
|
||||
)
|
||||
|
||||
apply_global_style(background_url=os.getenv("BACKGROUND_IMAGE_URL"))
|
||||
|
||||
auth_service = AuthService()
|
||||
|
||||
# Initialize session state
|
||||
if "auth_user" not in st.session_state:
|
||||
st.session_state.auth_user = None
|
||||
|
||||
# ✅ FIXED MENU (no emojis here)
|
||||
menu = ["Analytics", "Data", "Mailer", "Mappings"]
|
||||
|
||||
# ✅ ICON MAP
|
||||
icons = {
|
||||
"Analytics": "📊",
|
||||
"Data": "📦",
|
||||
"Mailer": "✉️",
|
||||
"Mappings": "📋"
|
||||
}
|
||||
|
||||
if st.session_state.auth_user is None:
|
||||
render_topbar()
|
||||
st.markdown('<style>[data-testid="stSidebar"]{display:none;}</style>', unsafe_allow_html=True)
|
||||
render_auth_card(auth_service)
|
||||
st.stop()
|
||||
|
||||
# Topbar
|
||||
render_topbar()
|
||||
|
||||
# Dim background
|
||||
st.markdown("""
|
||||
<style>
|
||||
.stApp::before { opacity: 0.1 !important; }
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
with st.sidebar:
|
||||
render_sidebar_logo()
|
||||
|
||||
st.markdown('<div class="sidebar-content">', unsafe_allow_html=True)
|
||||
|
||||
# Navigation
|
||||
st.markdown('<div class="sidebar-section">', unsafe_allow_html=True)
|
||||
st.markdown("### Navigation")
|
||||
|
||||
choice = st.selectbox(
|
||||
"Page",
|
||||
menu,
|
||||
index=0,
|
||||
format_func=lambda x: f"{icons.get(x, '')} {x}" # ✅ Safe rendering
|
||||
)
|
||||
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
|
||||
# Spacer
|
||||
st.markdown('<div class="sidebar-spacer"></div>', unsafe_allow_html=True)
|
||||
|
||||
# Logout Section
|
||||
st.markdown('<div class="sidebar-logout">', unsafe_allow_html=True)
|
||||
st.caption(f"Logged in as: {st.session_state.auth_user['email']}")
|
||||
|
||||
if st.button("Logout", type="secondary"):
|
||||
st.session_state.auth_user = None
|
||||
st.rerun()
|
||||
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
|
||||
# ✅ ROUTING FIXED (no emojis in condition)
|
||||
if choice == "Analytics":
|
||||
from pages.see_logs import render_page
|
||||
render_page()
|
||||
|
||||
elif choice == "Data":
|
||||
from pages.see_payload import render_page
|
||||
render_page()
|
||||
|
||||
elif choice == "Mailer":
|
||||
from pages.mailer import render_page
|
||||
render_page()
|
||||
|
||||
elif choice == "Mappings":
|
||||
from pages.mappings import render_page
|
||||
render_page()
|
||||
1
app_core/__init__.py
Normal file
1
app_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# App Core Package
|
||||
BIN
app_core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app_core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
1
app_core/config/__init__.py
Normal file
1
app_core/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Config Package
|
||||
BIN
app_core/config/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app_core/config/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/config/__pycache__/settings.cpython-312.pyc
Normal file
BIN
app_core/config/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
78
app_core/config/settings.py
Normal file
78
app_core/config/settings.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Load .env first (if present)
|
||||
load_dotenv(dotenv_path=".env", override=False)
|
||||
# Also load .env-example.txt as a fallback for local dev (does not override)
|
||||
load_dotenv(dotenv_path=".env-example.txt", override=False)
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
# Raw pieces
|
||||
db_host: str | None = os.getenv("DB_HOST")
|
||||
db_port: str | None = os.getenv("DB_PORT")
|
||||
db_name: str | None = os.getenv("DB_NAME")
|
||||
db_user: str | None = os.getenv("DB_USER")
|
||||
db_password: str | None = os.getenv("DB_PASSWORD")
|
||||
db_echo: bool = os.getenv("DB_ECHO", "false").lower() == "true"
|
||||
|
||||
# Optional complete URL (takes precedence if set)
|
||||
database_url_env: str | None = os.getenv("DATABASE_URL")
|
||||
|
||||
app_secret: str = os.getenv("APP_SECRET", "change_me")
|
||||
background_image_url: str | None = os.getenv("BACKGROUND_IMAGE_URL")
|
||||
|
||||
# SMTP / Email settings
|
||||
smtp_host: str | None = os.getenv("SMTP_HOST")
|
||||
smtp_port: int | None = int(os.getenv("SMTP_PORT", "587"))
|
||||
smtp_user: str | None = os.getenv("SMTP_USER")
|
||||
smtp_password: str | None = os.getenv("SMTP_PASSWORD")
|
||||
smtp_use_tls: bool = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
||||
smtp_from_email: str | None = os.getenv("SMTP_FROM_EMAIL")
|
||||
smtp_from_name: str = os.getenv("SMTP_FROM_NAME", "Workolik Team")
|
||||
|
||||
# Default recipients for automated reports (comma-separated)
|
||||
report_recipients: str | None = os.getenv("REPORT_RECIPIENTS")
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
if self.database_url_env:
|
||||
# Normalize asyncpg to psycopg2 if needed
|
||||
if self.database_url_env.startswith("postgresql+asyncpg://"):
|
||||
return self.database_url_env.replace(
|
||||
"postgresql+asyncpg://", "postgresql+psycopg2://", 1
|
||||
)
|
||||
return self.database_url_env
|
||||
# Build from parts
|
||||
if all([self.db_host, self.db_port, self.db_name, self.db_user, self.db_password]):
|
||||
return (
|
||||
f"postgresql+psycopg2://{self.db_user}:{self.db_password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
)
|
||||
# Fallback empty (will error at runtime if used)
|
||||
return ""
|
||||
|
||||
# Fixed mapping of stores to tenant IDs and division codes
|
||||
# Used by analytics and data pages to scope queries per store
|
||||
STORES = [
|
||||
{"label": "Porters Liquor Claremont - PC", "code": "PC", "tenant_id": 1},
|
||||
{"label": "Porters Iluka - IP", "code": "IP", "tenant_id": 2},
|
||||
{"label": "Cellarbrations at Morris Place - ML", "code": "ML", "tenant_id": 3},
|
||||
{"label": "Cellarbrations at Lynwood - CL", "code": "CL4", "tenant_id": 4},
|
||||
{"label": "Cellarbrations at Nicholson Road - NL", "code": "NL", "tenant_id": 5},
|
||||
{"label": "Cellarbrations at Treeby - CL ", "code": "CL6", "tenant_id": 6},
|
||||
{"label": "The Bottle-O Rossmoyne - RC", "code": "RC", "tenant_id": 7},
|
||||
{"label": "Porters Liquor Piara Waters - PL", "code": "PL", "tenant_id": 8},
|
||||
]
|
||||
|
||||
# Helper map for quick lookups by code (supports variants like CL-4 → CL4)
|
||||
STORE_CODE_TO_TENANT_ID: dict[str, int] = {
|
||||
"PC": 1,
|
||||
"IP": 2,
|
||||
"ML": 3,
|
||||
"CL4": 4, "CL-4": 4, "CL_4": 4, "CL": 4, # default CL → 4
|
||||
"NL": 5, "NL5": 5, "NL-5": 5,
|
||||
"CL6": 6, "CL-6": 6, "CL_6": 6,
|
||||
"RC": 7,
|
||||
"PL": 8,
|
||||
}
|
||||
1
app_core/db/__init__.py
Normal file
1
app_core/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Database Package
|
||||
BIN
app_core/db/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app_core/db/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/db/__pycache__/database.cpython-312.pyc
Normal file
BIN
app_core/db/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/db/__pycache__/models.cpython-312.pyc
Normal file
BIN
app_core/db/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
21
app_core/db/database.py
Normal file
21
app_core/db/database.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from app_core.config.settings import AppSettings
|
||||
|
||||
settings = AppSettings()
|
||||
|
||||
if not settings.database_url:
|
||||
raise RuntimeError(
|
||||
"Database configuration missing. Set DATABASE_URL or DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD in a .env file at the project root."
|
||||
)
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True, echo=settings.db_echo)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True)
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db_session():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
36
app_core/db/models.py
Normal file
36
app_core/db/models.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, func, UniqueConstraint
|
||||
from .database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "workolik_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("email", name="uq_workolik_users_email"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), nullable=False, unique=True, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class EmailLog(Base):
|
||||
__tablename__ = "email_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sent_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
recipients = Column(String(1024), nullable=False)
|
||||
subject = Column(String(255), nullable=False)
|
||||
status = Column(String(50), nullable=False) # sent / failed
|
||||
error = Column(String(1024))
|
||||
date_for = Column(String(32), nullable=False)
|
||||
|
||||
class TriumphDebtorMapping(Base):
|
||||
__tablename__ = "triumph_debtor_mappings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50))
|
||||
name = Column(String(255))
|
||||
dbmacc = Column(String(50))
|
||||
outlet = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
BIN
app_core/services/__pycache__/auth_service.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/auth_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/services/__pycache__/daily_report.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/daily_report.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/services/__pycache__/mailer_service.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/mailer_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/services/__pycache__/mappings_service.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/mappings_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/services/__pycache__/scheduler_service.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/scheduler_service.cpython-312.pyc
Normal file
Binary file not shown.
46
app_core/services/auth_service.py
Normal file
46
app_core/services/auth_service.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
import bcrypt
|
||||
from app_core.db.database import engine, Base, SessionLocal
|
||||
from app_core.db.models import User
|
||||
|
||||
# Create tables on import
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
class AuthService:
|
||||
def __init__(self) -> None:
|
||||
self._session_factory = SessionLocal
|
||||
|
||||
def _hash_password(self, raw_password: str) -> str:
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(raw_password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
def _verify_password(self, raw_password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(raw_password.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def signup(self, email: str, password: str) -> tuple[bool, str]:
|
||||
email = email.strip().lower()
|
||||
if not email or not password:
|
||||
return False, "Email and password are required."
|
||||
with self._session_factory() as db: # type: Session
|
||||
exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if exists:
|
||||
return False, "Email already registered."
|
||||
user = User(email=email, password_hash=self._hash_password(password))
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return True, "Account created. Please login."
|
||||
|
||||
def login(self, email: str, password: str) -> tuple[bool, Optional[dict], str]:
|
||||
email = email.strip().lower()
|
||||
if not email or not password:
|
||||
return False, None, "Email and password are required."
|
||||
with self._session_factory() as db: # type: Session
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if not user or not self._verify_password(password, user.password_hash):
|
||||
return False, None, "Invalid credentials."
|
||||
return True, {"id": user.id, "email": user.email}, "Login successful."
|
||||
53
app_core/services/daily_report.py
Normal file
53
app_core/services/daily_report.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app_core.config.settings import AppSettings
|
||||
from app_core.services.mailer_service import MailerService
|
||||
|
||||
|
||||
def main(for_date: str | None = None, force: bool = False) -> int:
|
||||
settings = AppSettings()
|
||||
service = MailerService(settings)
|
||||
|
||||
if for_date:
|
||||
try:
|
||||
chosen = datetime.strptime(for_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
print(f"Invalid date format: {for_date}. Expected YYYY-MM-DD.")
|
||||
return 1
|
||||
else:
|
||||
today_ist = datetime.now(ZoneInfo("Asia/Kolkata")).date()
|
||||
chosen = service.select_report_date(preferred=today_ist)
|
||||
|
||||
if not chosen:
|
||||
print("No data available to send.")
|
||||
return 1
|
||||
|
||||
if not force and service.has_sent_for_date(str(chosen)):
|
||||
print(f"Already sent for {chosen}; skipping.")
|
||||
return 0
|
||||
|
||||
df = service.fetch_daily_rows(chosen)
|
||||
if df.empty:
|
||||
print("Selected date has no rows; nothing to send.")
|
||||
return 0
|
||||
|
||||
row = df.iloc[0].to_dict()
|
||||
html = service.build_email_html(row, df)
|
||||
|
||||
recipients_env = settings.report_recipients or os.getenv("REPORT_RECIPIENTS")
|
||||
if not recipients_env:
|
||||
print("REPORT_RECIPIENTS env var is empty. Set it to comma-separated emails.")
|
||||
return 2
|
||||
recipients = [r.strip() for r in recipients_env.split(',') if r.strip()]
|
||||
ok, msg = service.send_email(recipients, subject=f"Daily Digest - {chosen}", html=html)
|
||||
service.log_email(recipients, subject=f"Daily Digest - {chosen}", date_for=str(chosen), status="sent" if ok else "failed", error=None if ok else msg)
|
||||
print("Sent" if ok else f"Failed: {msg}")
|
||||
return 0 if ok else 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
363
app_core/services/mailer_service.py
Normal file
363
app_core/services/mailer_service.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
import sys
|
||||
import os
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import List, Tuple
|
||||
from datetime import date
|
||||
from datetime import date
|
||||
import pandas as pd
|
||||
from sqlalchemy import text
|
||||
import streamlit as st
|
||||
|
||||
# Add the project root to Python path
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from app_core.config.settings import AppSettings, STORES
|
||||
from app_core.db.database import engine, SessionLocal
|
||||
from app_core.db.models import EmailLog
|
||||
|
||||
|
||||
class MailerService:
|
||||
def __init__(self, settings: AppSettings | None = None) -> None:
|
||||
self.settings = settings or AppSettings()
|
||||
|
||||
def fetch_daily_rows(self, report_date) -> pd.DataFrame:
|
||||
sql = (
|
||||
'SELECT * FROM "tenantpostings" '
|
||||
'WHERE "created_at"::date = %(d)s '
|
||||
'ORDER BY "id" DESC '
|
||||
'LIMIT 10000'
|
||||
)
|
||||
with engine.connect() as conn:
|
||||
df = pd.read_sql(sql, conn, params={"d": report_date})
|
||||
return df
|
||||
|
||||
def select_report_date(self, preferred: date | None = None) -> date | None:
|
||||
"""Return preferred date if it has data; else most recent date with data; else None."""
|
||||
with engine.connect() as conn:
|
||||
dates_df = pd.read_sql(
|
||||
'SELECT "created_at"::date AS d, COUNT(*) AS c\n'
|
||||
'FROM "tenantpostings"\n'
|
||||
'GROUP BY d\n'
|
||||
'ORDER BY d DESC',
|
||||
conn,
|
||||
)
|
||||
if dates_df.empty:
|
||||
return None
|
||||
# Normalize
|
||||
if 'd' not in dates_df.columns:
|
||||
return None
|
||||
dates_df['d'] = pd.to_datetime(dates_df['d'], errors='coerce')
|
||||
available = [d.date() for d in dates_df['d'].dropna().tolist()]
|
||||
if preferred and preferred in available:
|
||||
return preferred
|
||||
return available[0] if available else None
|
||||
|
||||
def build_email_html(self, row: dict, df: pd.DataFrame | None = None) -> str:
|
||||
# Robust de-duplication of the entire dataframe before processing
|
||||
if df is not None and not df.empty:
|
||||
def get_priority(status):
|
||||
val = str(status).lower()
|
||||
if any(x in val for x in ["success", "ok", "posted", "completed", "done"]):
|
||||
return 0
|
||||
if any(x in val for x in ["pending", "queue", "waiting", "processing"]):
|
||||
return 1
|
||||
return 2
|
||||
|
||||
df = df.copy()
|
||||
if 'triumph_status' in df.columns:
|
||||
df['_priority'] = df['triumph_status'].apply(get_priority)
|
||||
else:
|
||||
df['_priority'] = 2
|
||||
|
||||
# Sort by priority (success first) and then by ID (newest first)
|
||||
sort_cols = ['_priority', 'id']
|
||||
df = df.sort_values(sort_cols, ascending=[True, False])
|
||||
|
||||
# 1. Deduplicate by triumph_event (if present and not empty)
|
||||
if 'triumph_event' in df.columns:
|
||||
has_event = (df['triumph_event'].fillna('').astype(str).str.strip() != '') & (df['triumph_event'].astype(str) != '-')
|
||||
df_with_ev = df[has_event].drop_duplicates(subset=['tenant_id', 'processing_type', 'triumph_event'], keep='first')
|
||||
df_no_ev = df[~has_event]
|
||||
df = pd.concat([df_with_ev, df_no_ev]).sort_values(sort_cols, ascending=[True, False])
|
||||
|
||||
# 2. Deduplicate by register_close_id (for Journals/Banking Journals)
|
||||
if 'register_close_id' in df.columns:
|
||||
has_rc = (df['register_close_id'].fillna('').astype(str).str.strip() != '') & (df['register_close_id'].astype(str) != '-')
|
||||
df_with_rc = df[has_rc].drop_duplicates(subset=['tenant_id', 'processing_type', 'register_close_id'], keep='first')
|
||||
df_no_rc = df[~has_rc]
|
||||
df = pd.concat([df_with_rc, df_no_rc]).sort_values(sort_cols, ascending=[True, False])
|
||||
|
||||
# 3. Deduplicate by sale_ids (for Invoices/Receipts)
|
||||
if 'sale_ids' in df.columns:
|
||||
has_sales = (df['sale_ids'].fillna('').astype(str).str.strip() != '')
|
||||
df_with_sales = df[has_sales].drop_duplicates(subset=['tenant_id', 'processing_type', 'sale_ids'], keep='first')
|
||||
df_no_sales = df[~has_sales]
|
||||
df = pd.concat([df_with_sales, df_no_sales]).sort_values(sort_cols, ascending=[True, False])
|
||||
|
||||
df = df.drop(columns=['_priority'], errors='ignore')
|
||||
|
||||
outlet = row.get("outlet_name") or row.get("register_name") or "Outlet"
|
||||
division = row.get("division_code") or "PC"
|
||||
status = (row.get("triumph_status") or "Posted successfully").capitalize()
|
||||
register_close_id = row.get("register_close_id", "-")
|
||||
register_id = row.get("register_id", "-")
|
||||
|
||||
def lines_for(ptype: str) -> list[str]:
|
||||
"""Return formatted lines for all rows of a processing_type.
|
||||
Example line: 3,616.19 (Event ID: 2904783)
|
||||
"""
|
||||
if df is None or df.empty or 'processing_type' not in df.columns:
|
||||
return []
|
||||
sub = df[df['processing_type'].astype(str).str.upper() == ptype.upper()] if 'processing_type' in df.columns else pd.DataFrame()
|
||||
if sub.empty:
|
||||
return []
|
||||
|
||||
# Data is already deduplicated at the start of build_email_html
|
||||
sub = sub.sort_values('id', ascending=False)
|
||||
|
||||
result: list[str] = []
|
||||
for _, r in sub.sort_values('id', ascending=False).iterrows():
|
||||
amt = r.get('total_amount')
|
||||
evt = r.get('triumph_event', '-')
|
||||
try:
|
||||
amt_str = f"{float(amt):,.2f}"
|
||||
except Exception:
|
||||
amt_str = str(amt) if amt is not None else '-'
|
||||
result.append(f"<span style=\"font-weight:600;\">{amt_str}</span> (Event ID: <span style=\"font-weight:600;\">{evt}</span>)")
|
||||
return result
|
||||
|
||||
journal_lines = lines_for('JOURNAL')
|
||||
bank_journal_lines = lines_for('BANKING_JOURNAL')
|
||||
invoice_lines = lines_for('INVOICE')
|
||||
receipt_lines = lines_for('RECEIPT')
|
||||
|
||||
# Optional: transaction summary by store (single table)
|
||||
store_summary_table_html = ""
|
||||
events_matrix_html = ""
|
||||
if isinstance(df, pd.DataFrame) and not df.empty and ('tenant_id' in df.columns):
|
||||
def summarize_for(store: dict) -> dict[str, str]:
|
||||
sid = store.get('tenant_id')
|
||||
name = store.get('label')
|
||||
sub = df[df['tenant_id'] == sid]
|
||||
|
||||
# Data is already deduplicated at the start of build_email_html
|
||||
sub = sub.sort_values('id', ascending=False)
|
||||
|
||||
def pick_total(kind: str) -> tuple[str, int]:
|
||||
if sub.empty or 'processing_type' not in sub.columns:
|
||||
return ("0.00", 0)
|
||||
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
|
||||
if s.empty:
|
||||
return ("0.00", 0)
|
||||
try:
|
||||
total = float(s['total_amount'].fillna(0).sum()) if 'total_amount' in s.columns else 0.0
|
||||
except Exception:
|
||||
total = 0.0
|
||||
return (f"{total:,.2f}", len(s))
|
||||
def has_rows(kind: str) -> bool:
|
||||
if sub.empty or 'processing_type' not in sub.columns:
|
||||
return False
|
||||
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
|
||||
return not s.empty
|
||||
def latest_event(kind: str) -> str:
|
||||
if sub.empty or 'processing_type' not in sub.columns:
|
||||
return "-"
|
||||
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
|
||||
if s.empty:
|
||||
return "-"
|
||||
series = s.get('triumph_event') if 'triumph_event' in s.columns else None
|
||||
if series is None or series.empty:
|
||||
return "-"
|
||||
try:
|
||||
return str(series.dropna().astype(str).iloc[0])
|
||||
except Exception:
|
||||
return "-"
|
||||
def latest_status_emoji(kind: str) -> str:
|
||||
if sub.empty or 'processing_type' not in sub.columns:
|
||||
return ""
|
||||
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
|
||||
if s.empty:
|
||||
return ""
|
||||
status_series = s.get('triumph_status') if 'triumph_status' in s.columns else None
|
||||
if status_series is None or status_series.empty:
|
||||
return ""
|
||||
try:
|
||||
val = str(status_series.iloc[0]).strip().lower()
|
||||
except Exception:
|
||||
val = ""
|
||||
if any(x in val for x in ["success", "ok", "completed", "done"]):
|
||||
return " ✅"
|
||||
if any(x in val for x in ["fail", "error", "invalid", "dead"]):
|
||||
return " ❌"
|
||||
if any(x in val for x in ["pending", "queue", "waiting", "processing"]):
|
||||
return " ⚠️"
|
||||
return ""
|
||||
j_total, _ = pick_total('JOURNAL')
|
||||
b_total, _ = pick_total('BANKING_JOURNAL')
|
||||
i_total, _ = pick_total('INVOICE')
|
||||
r_total, _ = pick_total('RECEIPT')
|
||||
j_eid = latest_event('JOURNAL'); j_stat = latest_status_emoji('JOURNAL')
|
||||
b_eid = latest_event('BANKING_JOURNAL'); b_stat = latest_status_emoji('BANKING_JOURNAL')
|
||||
i_eid = latest_event('INVOICE'); i_stat = latest_status_emoji('INVOICE')
|
||||
r_eid = latest_event('RECEIPT'); r_stat = latest_status_emoji('RECEIPT')
|
||||
def render_cell(exists: bool, total: str, eid: str, stat: str, ptype: str = "") -> str:
|
||||
if not exists:
|
||||
return "<span style=\"color:#9AA4B2;\">Nill</span>"
|
||||
|
||||
# For INVOICE and RECEIPT, show individual line items if multiple exist
|
||||
if ptype.upper() in ['INVOICE', 'RECEIPT'] and sub is not None and not sub.empty:
|
||||
type_sub = sub[sub['processing_type'].astype(str).str.upper() == ptype.upper()]
|
||||
if len(type_sub) > 1: # Multiple transactions
|
||||
individual_lines = []
|
||||
for _, r in type_sub.sort_values('id', ascending=False).iterrows():
|
||||
amt = r.get('total_amount')
|
||||
evt = r.get('triumph_event', '-')
|
||||
status_val = str(r.get('triumph_status', '')).strip().lower()
|
||||
status_emoji = ""
|
||||
if any(x in status_val for x in ["success", "ok", "completed", "done"]):
|
||||
status_emoji = " ✅"
|
||||
elif any(x in status_val for x in ["fail", "error", "invalid", "dead"]):
|
||||
status_emoji = " ❌"
|
||||
elif any(x in status_val for x in ["pending", "queue", "waiting", "processing"]):
|
||||
status_emoji = " ⚠️"
|
||||
try:
|
||||
amt_str = f"{float(amt):,.2f}"
|
||||
except Exception:
|
||||
amt_str = str(amt) if amt is not None else '-'
|
||||
individual_lines.append(f"<div style=\"font-size:11px;margin:1px 0;\">{amt_str} ({evt}){status_emoji}</div>")
|
||||
|
||||
return f"<strong>{total}</strong><br/><span style=\"color:#64748b;font-size:10px;\">Total ({len(type_sub)} items)</span><br/>{''.join(individual_lines)}"
|
||||
|
||||
return f"<strong>{total}</strong><br/><span style=\"color:#64748b\">({eid})</span> {stat}"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"journal": render_cell(has_rows('JOURNAL'), j_total, j_eid, j_stat),
|
||||
"banking": render_cell(has_rows('BANKING_JOURNAL'), b_total, b_eid, b_stat),
|
||||
"invoice": render_cell(has_rows('INVOICE'), i_total, i_eid, i_stat, 'INVOICE'),
|
||||
"receipt": render_cell(has_rows('RECEIPT'), r_total, r_eid, r_stat, 'RECEIPT'),
|
||||
}
|
||||
|
||||
rows = [summarize_for(s) for s in STORES]
|
||||
# Build single HTML table
|
||||
header = (
|
||||
"<tr>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Store Name</th>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Journal</th>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Banking Journal</th>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Account Sales</th>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Account Payments</th>"
|
||||
"</tr>"
|
||||
)
|
||||
body = []
|
||||
for r in rows:
|
||||
body.append(
|
||||
"<tr>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['name']}</td>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['journal']}</td>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['banking']}</td>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['invoice']}</td>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['receipt']}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
store_summary_table_html = (
|
||||
"<div style=\"background:#111827;border-radius:12px;padding:12px;\">"
|
||||
"<div style=\"font-weight:700;color:#F8FAFC;margin-bottom:6px;\">Transaction Summary by Store</div>"
|
||||
"<table style=\"width:100%;border-collapse:collapse;font-size:12px;\">"
|
||||
+ header + "".join(body) + "</table></div>"
|
||||
)
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; color:#0f172a; font-size:13px; line-height:1.5;">
|
||||
<p style="margin:0 0 8px 0">Hello <strong>Tucker Fresh</strong>,</p>
|
||||
<p style="margin:0 0 12px 0">Here's your daily digest of posted transactions:</p>
|
||||
{store_summary_table_html}
|
||||
<p style="margin:12px 0 6px 0">Thank you for staying updated with us.</p>
|
||||
<p style="margin:0">Best regards,<br/><strong>Workolik Team</strong></p>
|
||||
</div>
|
||||
"""
|
||||
return html
|
||||
|
||||
def send_email(self, recipients: List[str], subject: str, html: str) -> Tuple[bool, str]:
|
||||
s = self.settings
|
||||
if not all([s.smtp_host, s.smtp_port, s.smtp_user, s.smtp_password, s.smtp_from_email]):
|
||||
return False, "SMTP settings are incomplete."
|
||||
|
||||
# Optional BCC via env (comma-separated), default empty
|
||||
bcc_env = os.getenv("BCC_RECIPIENTS", "").strip()
|
||||
bcc_recipients = [e.strip() for e in bcc_env.split(',') if e.strip()] if bcc_env else []
|
||||
all_recipients = recipients + bcc_recipients
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{s.smtp_from_name} <{s.smtp_from_email}>"
|
||||
msg["To"] = ", ".join(recipients)
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP(s.smtp_host, s.smtp_port, timeout=30)
|
||||
if s.smtp_use_tls:
|
||||
server.starttls()
|
||||
server.login(s.smtp_user, s.smtp_password)
|
||||
server.sendmail(s.smtp_from_email, all_recipients, msg.as_string())
|
||||
server.quit()
|
||||
return True, "sent"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def log_email(self, recipients: List[str], subject: str, date_for: str, status: str, error: str | None = None) -> None:
|
||||
with SessionLocal() as db:
|
||||
entry = EmailLog(
|
||||
recipients=", ".join(recipients),
|
||||
subject=subject,
|
||||
status=status,
|
||||
error=error,
|
||||
date_for=date_for,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
|
||||
def has_sent_for_date(self, date_for: str) -> bool:
|
||||
"""Return True if a successful send log exists for the given date."""
|
||||
with SessionLocal() as db:
|
||||
row = (
|
||||
db.query(EmailLog)
|
||||
.filter(EmailLog.date_for == date_for, EmailLog.status == "sent")
|
||||
.order_by(EmailLog.sent_at.desc())
|
||||
.first()
|
||||
)
|
||||
return row is not None
|
||||
|
||||
def recent_logs(self, limit: int = 50) -> list[dict]:
|
||||
return _get_recent_logs_cached(limit)
|
||||
|
||||
|
||||
@st.cache_data(ttl=60) # Cache for 1 minute
|
||||
def _get_recent_logs_cached(limit: int = 50) -> list[dict]:
|
||||
"""Cached function to get recent email logs."""
|
||||
with SessionLocal() as db:
|
||||
rows = (
|
||||
db.query(EmailLog)
|
||||
.order_by(EmailLog.sent_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"sent_at": r.sent_at,
|
||||
"recipients": r.recipients,
|
||||
"subject": r.subject,
|
||||
"status": r.status,
|
||||
"error": r.error,
|
||||
"date_for": r.date_for,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
52
app_core/services/mappings_service.py
Normal file
52
app_core/services/mappings_service.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app_core.db.database import SessionLocal
|
||||
from app_core.db.models import TriumphDebtorMapping
|
||||
from datetime import datetime
|
||||
|
||||
class MappingsService:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_all_mappings(self) -> List[TriumphDebtorMapping]:
|
||||
with SessionLocal() as db:
|
||||
return db.query(TriumphDebtorMapping).order_by(TriumphDebtorMapping.id.asc()).all()
|
||||
|
||||
def get_mapping_by_id(self, mapping_id: int) -> Optional[TriumphDebtorMapping]:
|
||||
with SessionLocal() as db:
|
||||
return db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
|
||||
|
||||
def create_mapping(self, code: str, name: str, dbmacc: str, outlet: str) -> TriumphDebtorMapping:
|
||||
with SessionLocal() as db:
|
||||
mapping = TriumphDebtorMapping(
|
||||
code=code,
|
||||
name=name,
|
||||
dbmacc=dbmacc,
|
||||
outlet=outlet
|
||||
)
|
||||
db.add(mapping)
|
||||
db.commit()
|
||||
db.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
def update_mapping(self, mapping_id: int, code: str, name: str, dbmacc: str, outlet: str) -> bool:
|
||||
with SessionLocal() as db:
|
||||
mapping = db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
|
||||
if mapping:
|
||||
mapping.code = code
|
||||
mapping.name = name
|
||||
mapping.dbmacc = dbmacc
|
||||
mapping.outlet = outlet
|
||||
mapping.updated_at = datetime.now()
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_mapping(self, mapping_id: int) -> bool:
|
||||
with SessionLocal() as db:
|
||||
mapping = db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
|
||||
if mapping:
|
||||
db.delete(mapping)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
89
app_core/services/scheduler_service.py
Normal file
89
app_core/services/scheduler_service.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.jobstores.memory import MemoryJobStore
|
||||
|
||||
from app_core.services.daily_report import main as run_daily_report
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SchedulerService:
|
||||
def __init__(self):
|
||||
self.scheduler = None
|
||||
self.ist = ZoneInfo("Asia/Kolkata")
|
||||
|
||||
def start_scheduler(self):
|
||||
"""Start the background scheduler for daily email reports."""
|
||||
if self.scheduler and self.scheduler.running:
|
||||
logger.info("Scheduler is already running")
|
||||
return
|
||||
|
||||
# Configure job stores and executors
|
||||
jobstores = {
|
||||
'default': MemoryJobStore()
|
||||
}
|
||||
executors = {
|
||||
'default': ThreadPoolExecutor(20)
|
||||
}
|
||||
job_defaults = {
|
||||
'coalesce': False,
|
||||
'max_instances': 1
|
||||
}
|
||||
|
||||
self.scheduler = BackgroundScheduler(
|
||||
jobstores=jobstores,
|
||||
executors=executors,
|
||||
job_defaults=job_defaults,
|
||||
timezone=self.ist
|
||||
)
|
||||
|
||||
# Schedule daily email at 8:00 PM IST (20:00)
|
||||
self.scheduler.add_job(
|
||||
func=self._send_daily_report,
|
||||
trigger=CronTrigger(hour=20, minute=0, timezone=self.ist),
|
||||
id='daily_email_report',
|
||||
name='Daily Email Report',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Start the scheduler
|
||||
self.scheduler.start()
|
||||
logger.info("Daily email scheduler started - will send reports at 8:00 PM IST")
|
||||
|
||||
def stop_scheduler(self):
|
||||
"""Stop the background scheduler."""
|
||||
if self.scheduler and self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
logger.info("Daily email scheduler stopped")
|
||||
|
||||
def _send_daily_report(self):
|
||||
"""Internal method to send daily report."""
|
||||
try:
|
||||
logger.info(f"Starting daily report at {datetime.now(self.ist)}")
|
||||
result = run_daily_report()
|
||||
if result == 0:
|
||||
logger.info("Daily report sent successfully")
|
||||
else:
|
||||
logger.warning(f"Daily report failed with exit code: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending daily report: {str(e)}")
|
||||
|
||||
def get_next_run_time(self):
|
||||
"""Get the next scheduled run time for the daily report."""
|
||||
if not self.scheduler or not self.scheduler.running:
|
||||
return None
|
||||
|
||||
job = self.scheduler.get_job('daily_email_report')
|
||||
if job:
|
||||
return job.next_run_time
|
||||
return None
|
||||
|
||||
def is_running(self):
|
||||
"""Check if scheduler is running."""
|
||||
return self.scheduler is not None and self.scheduler.running
|
||||
BIN
app_core/ui/__pycache__/auth_ui.cpython-312.pyc
Normal file
BIN
app_core/ui/__pycache__/auth_ui.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/ui/__pycache__/layout.cpython-312.pyc
Normal file
BIN
app_core/ui/__pycache__/layout.cpython-312.pyc
Normal file
Binary file not shown.
37
app_core/ui/auth_ui.py
Normal file
37
app_core/ui/auth_ui.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import re
|
||||
import streamlit as st
|
||||
from app_core.services.auth_service import AuthService
|
||||
|
||||
|
||||
def _is_valid_email(value: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
pattern = r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
|
||||
return re.match(pattern, value.strip()) is not None
|
||||
|
||||
|
||||
def render_auth_card(auth_service: AuthService) -> None:
|
||||
left, center, right = st.columns([1, 1.2, 1])
|
||||
with center:
|
||||
|
||||
st.markdown('<div class="auth-title">Welcome !!</div>', unsafe_allow_html=True)
|
||||
st.markdown('<div class="muted" style="margin-bottom:16px;">Sign in to continue</div>', unsafe_allow_html=True)
|
||||
|
||||
with st.form("login_form", clear_on_submit=False):
|
||||
email = st.text_input("Email", placeholder="you@example.com", key="login_email")
|
||||
password = st.text_input("Password", type="password", placeholder="••••••••", key="login_password")
|
||||
submitted = st.form_submit_button("Sign in →", use_container_width=True)
|
||||
if submitted:
|
||||
if not _is_valid_email(email):
|
||||
st.error("Enter a valid email address.")
|
||||
elif not password:
|
||||
st.error("Password is required.")
|
||||
else:
|
||||
ok, user, msg = auth_service.login(email, password)
|
||||
if ok and user:
|
||||
st.session_state.auth_user = user
|
||||
st.success(msg)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(msg)
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
314
app_core/ui/layout.py
Normal file
314
app_core/ui/layout.py
Normal file
@@ -0,0 +1,314 @@
|
||||
import streamlit as st
|
||||
from app_core.config.settings import STORES, STORE_CODE_TO_TENANT_ID
|
||||
|
||||
def apply_global_style(background_url: str | None = None) -> None:
|
||||
css = """
|
||||
<style>
|
||||
:root { --brand: ; --brandDark: #0F4FD6; --text: #0F172A; --muted: #64748b; --border: rgba(15, 23, 42, 0.08); --inputBorder: rgba(22,98,243,0.35); }
|
||||
.stApp { background: transparent !important; position: relative !important; min-height: 100vh; font-size: 17px; }
|
||||
[data-testid="stAppViewContainer"] { background: transparent !important; }
|
||||
[data-testid="stAppViewContainer"] > header, [data-testid="stHeader"], .stApp header { background: transparent !important; box-shadow: none !important; }
|
||||
.stAppToolbar { background: transparent !important; box-shadow: none !important; border-bottom: none !important; }
|
||||
|
||||
|
||||
/* Header sections */
|
||||
.tfw-header { background: #FFFFFF; border-bottom: 1px solid var(--border); }
|
||||
.tfw-header-white { background: #FFFFFF; padding: 16px 0; }
|
||||
.tfw-header-grey { background: #F8FAFC; padding: 12px 0; border-top: 1px solid rgba(15, 23, 42, 0.00); }
|
||||
.tfw-header-content { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
|
||||
|
||||
/* Topbar (same height, narrower sides) */
|
||||
.tfw-topbar { position: fixed; top: 0; left: 0; right: 0; height: 48px; background: rgba(248,250,252,0.6); backdrop-filter: saturate(180%) border-bottom: none; z-index: 999; }
|
||||
.tfw-topbar .tfw-inner { height: 48px; display: flex; align-items: center; gap: 8px; padding: 0 8px; max-width: 1100px; margin: 0 auto; }
|
||||
.tfw-logo { width: 18px; height: 18px; border-radius: 6px; background: linear-gradient(135deg, var(--brand), var(--brandDark)); display: inline-block; }
|
||||
.tfw-title { font-weight: 800; color: var(--text); letter-spacing: -0.02em; font-size: 1.0rem; }
|
||||
|
||||
/* Sidebar enhancements */
|
||||
[data-testid="stSidebar"] .sidebar-content { display: flex; flex-direction: column; height: 100%; font-size: 1rem; }
|
||||
[data-testid="stSidebar"] .sidebar-section { padding: 8px 6px; }
|
||||
[data-testid="stSidebar"] .sidebar-spacer { flex: 1 1 auto; }
|
||||
[data-testid="stSidebar"] .sidebar-logout { padding: 10px 6px; border-top: 1px solid var(--border); }
|
||||
[data-testid="stSidebar"] .sidebar-logout button { width: 100%; border-radius: 10px; }
|
||||
|
||||
/* Sidebar logo */
|
||||
.sidebar-logo { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
|
||||
.sidebar-logo-icon { width: 32px; height: 32px; border-radius: 8px; overflow: hidden; }
|
||||
.sidebar-logo-icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.sidebar-logo-text { font-weight: 800; color: var(--text); font-size: 1.1rem; letter-spacing: -0.02em; }
|
||||
|
||||
/* Auth card (extra-slim, centered) */
|
||||
.auth-card { position: relative; padding: 24px; background: rgba(255,255,255,0.85); backdrop-filter: blur(8px); border: 1px solid rgba(22,98,240,0.22); border-radius: 16px; box-shadow: 0 18px 40px rgba(2,6,23,0.10); transition: box-shadow .2s ease, transform .2s ease; max-width: 520px; width: 100%; margin: 0 auto; }
|
||||
.auth-card.auth-slim { max-width: 420px; }
|
||||
.auth-card.auth-xs { max-width: 360px; }
|
||||
.auth-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 6px; background: linear-gradient(90deg, #22C55E, #16A34A, #0ea5e9); border-top-left-radius: 16px; border-top-right-radius: 16px; }
|
||||
.auth-card:hover { box-shadow: 0 26px 56px rgba(2,6,23,0.16); transform: translateY(-2px); }
|
||||
|
||||
/* Success ribbon shown on login */
|
||||
.login-success { background: linear-gradient(90deg, #22C55E, #16A34A); color: #fff; border-radius: 12px; padding: 10px 14px; box-shadow: 0 10px 24px rgba(34,197,94,0.35); display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
.login-success .emoji { filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); }
|
||||
|
||||
.auth-title { margin: 6px 0 8px 0; font-size: 1.8rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; text-align: center; }
|
||||
.muted { color: var(--muted); font-size: 1.0rem; text-align: center; }
|
||||
|
||||
/* Inputs: light blue border (global) */
|
||||
div[data-testid="stTextInput"] input,
|
||||
div[data-testid="stPassword"] input,
|
||||
textarea {
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid var(--inputBorder) !important;
|
||||
box-shadow: inset 0 1px 2px rgba(2,6,23,0.04) !important;
|
||||
background: #FFFFFF !important;
|
||||
}
|
||||
/* Prevent outer wrapper hover/focus rings (avoid double boxes) */
|
||||
div[data-baseweb="input"]:hover, div[data-baseweb="input"]:focus-within,
|
||||
div[data-baseweb="textarea"]:hover, div[data-baseweb="textarea"]:focus-within,
|
||||
div[data-testid="stTextInput"] > div:hover, div[data-testid="stTextInput"] > div:focus-within,
|
||||
div[data-testid="stTextInput"] > div > div:hover, div[data-testid="stTextInput"] > div > div:focus-within,
|
||||
div[data-testid="stPassword"] > div:hover, div[data-testid="stPassword"] > div:focus-within,
|
||||
div[data-testid="stPassword"] > div > div:hover, div[data-testid="stPassword"] > div > div:focus-within {
|
||||
outline: none !important; box-shadow: none !important; border-color: transparent !important;
|
||||
}
|
||||
/* Subtle inner hover/focus on the actual field only */
|
||||
div[data-testid="stTextInput"] input:hover,
|
||||
div[data-testid="stPassword"] input:hover,
|
||||
textarea:hover { border-color: var(--inputBorder) !important; box-shadow: inset 0 0 0 1px rgba(22,98,243,0.25) !important; }
|
||||
div[data-testid="stTextInput"] input:focus,
|
||||
div[data-testid="stPassword"] input:focus,
|
||||
textarea:focus { outline: none !important; border-color: var(--inputBorder) !important; box-shadow: inset 0 0 0 1px rgba(22,98,243,0.45) !important; }
|
||||
|
||||
/* Constrain Streamlit form width regardless of dynamic class names */
|
||||
form[class*="stForm"], div[class*="stForm"] { max-width: 760px !important; margin-left: auto !important; margin-right: auto !important; padding-left: 8px !important; padding-right: 8px !important; }
|
||||
|
||||
/* Password field styling - expand to match email box and position eye icon */
|
||||
div[data-testid="stPassword"] { width: 100% !important; }
|
||||
div[data-testid="stPassword"] input { width: 100% !important; padding-right: 60px !important; }
|
||||
div[data-testid="stPassword"] button { position: absolute !important; right: 8px !important; top: 50% !important; transform: translateY(-50%) !important; background: none !important; border: none !important; padding: 4px !important; margin: 0 !important; }
|
||||
div[data-testid="stPassword"] button:hover { background: rgba(0,0,0,0.05) !important; border-radius: 4px !important; }
|
||||
|
||||
/* Buttons: global size + hover/transition */
|
||||
.stButton > button, [data-testid="stDownloadButton"] button { height: 40px; font-size: 0.95rem; border-radius: 10px; transition: transform .15s ease, box-shadow .15s ease; }
|
||||
.stButton > button:hover, [data-testid="stDownloadButton"] button:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(22,98,243,0.25); }
|
||||
|
||||
.auth-card .stCheckbox { font-size: 1.0rem; }
|
||||
|
||||
/* Auth buttons inherit global size but keep gradient */
|
||||
.auth-card .stButton > button { background: linear-gradient(135deg, #22C55E, #16A34A); color: #fff; font-weight: 800; letter-spacing: .2px; border: none; box-shadow: 0 10px 18px rgba(34,197,94,0.28); }
|
||||
.auth-card .stButton > button:hover { filter: brightness(0.98); }
|
||||
|
||||
/* Match info alert with content card look */
|
||||
div[role="alert"] { background: #F6FAFF !important; border: 1px solid rgba(22,98,243,0.18) !important; color: var(--text) !important; }
|
||||
|
||||
/* DataFrame font sizes */
|
||||
div[data-testid="stDataFrame"] table { font-size: 0.98rem; }
|
||||
div[data-testid="stDataFrame"] th { font-size: 1.0rem; }
|
||||
</style>
|
||||
"""
|
||||
st.markdown(css, unsafe_allow_html=True)
|
||||
|
||||
# Optional login/background image with 50% transparency
|
||||
if background_url:
|
||||
# Support @prefix and local files by embedding as base64 when needed
|
||||
try:
|
||||
import os, base64
|
||||
url = background_url.lstrip('@').strip()
|
||||
if url.startswith('http://') or url.startswith('https://'):
|
||||
data_url = url
|
||||
else:
|
||||
# Treat as local file path
|
||||
# Map shorthand names to assets/ if needed
|
||||
if url in {"bg.jpg", "workolik.png"}:
|
||||
url = os.path.join("assets", url)
|
||||
if os.path.exists(url):
|
||||
ext = os.path.splitext(url)[1].lower()
|
||||
mime = 'image/jpeg' if ext in ['.jpg', '.jpeg'] else 'image/png' if ext == '.png' else 'image/webp' if ext == '.webp' else 'image/*'
|
||||
with open(url, 'rb') as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
data_url = f'data:{mime};base64,{b64}'
|
||||
else:
|
||||
data_url = url # fallback; let browser try
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
.stApp::before {{
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background-image: url('{data_url}');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.5; /* 50% transparent */
|
||||
filter: saturate(110%);
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def render_header(brand_name: str = "Workolik") -> None:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="tfw-header">
|
||||
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_topbar(brand_name: str = "") -> None:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class=\"tfw-topbar\">\n <div class=\"tfw-inner\">\n <span class=\"tfw-logo\"></span>\n <span class=\"tfw-title\">{brand_name}</span>\n </div>\n</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_sidebar_logo(brand_name: str = "Workolik") -> None:
|
||||
import streamlit as st
|
||||
import base64
|
||||
import os
|
||||
|
||||
try:
|
||||
# Read the image file and encode it as base64
|
||||
logo_path = os.path.join("assets", "workolik.png")
|
||||
if os.path.exists(logo_path):
|
||||
with open(logo_path, "rb") as img_file:
|
||||
img_data = base64.b64encode(img_file.read()).decode()
|
||||
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-icon">
|
||||
<img src="data:image/png;base64,{img_data}" alt="Workolik Logo" style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
</div>
|
||||
<div class="sidebar-logo-text">{brand_name}</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
else:
|
||||
raise FileNotFoundError("Logo file not found")
|
||||
except Exception as e:
|
||||
# Fallback to text logo if image fails to load
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-icon" style="background: linear-gradient(135deg, var(--brand), var(--brandDark)); display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 14px;">TW</div>
|
||||
<div class="sidebar-logo-text">{brand_name}</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_store_selector() -> tuple[int | None, str | None]:
|
||||
"""Render a compact, classy store selector box.
|
||||
|
||||
Returns (tenant_id, label). Also persists selection in session_state.
|
||||
"""
|
||||
st.markdown(
|
||||
"""
|
||||
<div style="
|
||||
margin: 8px 0 12px 0; padding: 16px 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(135deg,#ffffff, #f8fbff);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 24px rgba(2,6,23,0.06);
|
||||
">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<div style="display:flex; align-items:center; gap:10px; color: var(--text);">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:999px;background:linear-gradient(135deg,#22C55E,#16A34A);box-shadow:0 4px 10px rgba(34,197,94,0.25);">🛍️</span>
|
||||
<div style="font-weight: 700; letter-spacing:-0.01em;">Choose the store you want to view</div>
|
||||
<span style="opacity:0.9">✨</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#64748b;">👉 Click a card to select</div>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# Sub caption + clear selection
|
||||
current_label = st.session_state.get("store_label")
|
||||
left_cap, right_clear = st.columns([6, 1])
|
||||
with left_cap:
|
||||
st.caption("Please choose a store before surfing !")
|
||||
if current_label:
|
||||
st.caption(f"Selected: {current_label}")
|
||||
with right_clear:
|
||||
if st.button("Clear", key="clear_store_sel"):
|
||||
st.session_state["tenant_id"] = None
|
||||
st.session_state["store_label"] = None
|
||||
st.experimental_rerun() if hasattr(st, "experimental_rerun") else st.rerun()
|
||||
|
||||
# We no longer use query params; selection happens in-session only
|
||||
chosen_from_query: str | None = None
|
||||
|
||||
# Grid of store boxes (soft gradient cards with per-store colors and emojis)
|
||||
emoji_map = {"PC": "🍷", "IP": "🍻", "ML": "🥂", "CL4": "🍸", "NL": "🥃", "CL6": "🍾", "RC": "🍹", "PL": "🍺"}
|
||||
|
||||
color_rgb = {"PC": (37,99,235), "IP": (22,163,74), "ML": (245,158,11), "CL4": (220,38,38), "NL": (124,58,237), "CL6": (234,179,8), "RC": (6, 182, 212), "PL": (236, 72, 153)}
|
||||
preselect_label = st.session_state.get("store_label")
|
||||
chosen_label = None
|
||||
# No search box; show all stores
|
||||
filtered_stores = STORES
|
||||
# Always render 3 columns per row (e.g., 3 + 3 for 6 stores)
|
||||
rows = [filtered_stores[i:i+3] for i in range(0, len(filtered_stores), 3)]
|
||||
for row in rows:
|
||||
cols = st.columns(3)
|
||||
for i, store in enumerate(row):
|
||||
with cols[i]:
|
||||
icon = emoji_map.get(store["code"], "🏬")
|
||||
r, g, b = color_rgb.get(store["code"], (14,165,233))
|
||||
is_selected = (preselect_label == store["label"]) or (chosen_from_query == store["label"]) # highlight current
|
||||
border_alpha = 0.48 if is_selected else 0.28
|
||||
shadow = "0 18px 42px rgba(2,6,23,0.16)" if is_selected else "0 12px 28px rgba(2,6,23,0.10)"
|
||||
border_width = "2px" if is_selected else "1px"
|
||||
check = " ✅" if is_selected else ""
|
||||
# Render a card-like button that sets selection without changing URL
|
||||
clicked = st.button(
|
||||
f"{icon} {store['label']}{check}",
|
||||
key=f"store_card_{store['code']}",
|
||||
use_container_width=True,
|
||||
type="secondary",
|
||||
)
|
||||
# Lightweight card styling via inline CSS targeting this button
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
div[data-testid='stButton'] button#store_card_{store['code']} {{
|
||||
background: linear-gradient(135deg, rgba({r},{g},{b},0.12), rgba({r},{g},{b},0.20));
|
||||
border: {border_width} solid rgba({r},{g},{b},{border_alpha});
|
||||
border-radius: 18px; padding: 18px; box-shadow: {shadow};
|
||||
color: #0F172A; font-weight: 800; text-align: left;
|
||||
}}
|
||||
div[data-testid='stButton'] button#store_card_{store['code']}:hover {{
|
||||
transform: translateY(-2px); box-shadow: 0 22px 48px rgba(2,6,23,0.18);
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
if clicked:
|
||||
st.session_state["tenant_id"] = store["tenant_id"]
|
||||
st.session_state["store_label"] = store["label"]
|
||||
chosen_label = store["label"]
|
||||
st.rerun()
|
||||
|
||||
# Resolve tenant_id
|
||||
effective_label = chosen_label or preselect_label
|
||||
selected = next((s for s in STORES if s["label"] == effective_label), None)
|
||||
tenant_id = selected["tenant_id"] if selected else None
|
||||
|
||||
# Persist
|
||||
st.session_state["tenant_id"] = tenant_id
|
||||
st.session_state["store_label"] = selected["label"] if selected else None
|
||||
st.session_state["division_code"] = None
|
||||
|
||||
return tenant_id, (selected["label"] if selected else None)
|
||||
@@ -25,7 +25,7 @@ void main() async {
|
||||
);
|
||||
|
||||
// Register global BLoC observer for centralized error logging
|
||||
Bloc.observer = CoreBlocObserver(
|
||||
Bloc.observer = const CoreBlocObserver(
|
||||
logEvents: true,
|
||||
logStateChanges: false, // Set to true for verbose debugging
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maestro Integration Tests — Client App
|
||||
|
||||
Auth flows for the KROW Client app.
|
||||
See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) and [maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md).
|
||||
Auth flows and E2E happy paths for the KROW Client app.
|
||||
See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md), [maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md), and [docs/testing/maestro-e2e-happy-paths.md](/docs/testing/maestro-e2e-happy-paths.md) (#572).
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -10,6 +10,43 @@ maestro/
|
||||
auth/
|
||||
sign_in.yaml
|
||||
sign_up.yaml
|
||||
sign_out.yaml
|
||||
sign_in_invalid_password.yaml
|
||||
navigation/
|
||||
home.yaml
|
||||
orders.yaml
|
||||
billing.yaml
|
||||
coverage.yaml
|
||||
reports.yaml
|
||||
orders/
|
||||
view_orders.yaml
|
||||
completed_no_edit_icon.yaml # #492
|
||||
create_order_entry.yaml
|
||||
create_order_rapid.yaml
|
||||
rapid_to_one_time_draft_submit_e2e.yaml
|
||||
create_order_one_time_e2e.yaml
|
||||
edit_active_order_e2e.yaml
|
||||
edit_active_order_verify_updated_e2e.yaml
|
||||
hubs/
|
||||
create_hub_e2e.yaml
|
||||
manage_hubs_from_settings.yaml
|
||||
edit_hub_e2e.yaml
|
||||
delete_hub_e2e.yaml
|
||||
billing/
|
||||
billing_overview.yaml
|
||||
invoice_details_smoke.yaml
|
||||
invoice_approval_e2e.yaml
|
||||
reports/
|
||||
reports_dashboard.yaml
|
||||
spend_report_export_smoke.yaml
|
||||
home/
|
||||
home_dashboard_widgets.yaml
|
||||
tab_bar_roundtrip.yaml
|
||||
settings/
|
||||
settings_page.yaml
|
||||
edit_profile.yaml
|
||||
edit_profile_save_e2e.yaml
|
||||
logout_flow.yaml
|
||||
```
|
||||
|
||||
## Credentials (env, never hardcoded)
|
||||
@@ -19,11 +56,22 @@ maestro/
|
||||
| sign_in | `TEST_CLIENT_EMAIL`, `TEST_CLIENT_PASSWORD` |
|
||||
| sign_up | `TEST_CLIENT_EMAIL`, `TEST_CLIENT_PASSWORD`, `TEST_CLIENT_COMPANY` |
|
||||
|
||||
**Sign-in:** testclient@gmail.com / testclient!
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Via Makefile (export vars first)
|
||||
make test-e2e-client
|
||||
make test-e2e-client # Auth only
|
||||
make test-e2e-client-extended # Auth + nav + orders + settings
|
||||
make test-e2e-client-happy-path # Auth + hubs + create order E2E + billing + reports + logout (#572)
|
||||
make test-e2e-client-smoke # Deterministic smoke suite
|
||||
make test-e2e-client-hubs-e2e # Hubs manage + edit + delete
|
||||
make test-e2e-client-billing-smoke # Billing overview + invoice details/empty state
|
||||
make test-e2e-client-reports-smoke # Reports dashboard + export placeholder
|
||||
make test-e2e-client-settings-e2e # Settings + edit profile save + logout
|
||||
make test-e2e-client-orders-smoke # Orders smoke (RAPID → One-Time draft)
|
||||
make test-e2e-client-orders-data # Orders data-dependent (edit active order)
|
||||
|
||||
# Direct
|
||||
maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Client App — E2E: Session Persistence Across Relaunch
|
||||
# Purpose:
|
||||
# - Log in via sign_in.yaml
|
||||
# - Stop the app
|
||||
# - Relaunch and verify user is still logged in (bypass login screen)
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/auth/session_persistence.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
# We rely on sign_in.yaml being run before this to establish a session.
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "client_nav_home"
|
||||
timeout: 60000
|
||||
|
||||
- stopApp
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "client_nav_home"
|
||||
timeout: 30000
|
||||
|
||||
# Cleanup: Log out
|
||||
- tapOn:
|
||||
id: "header_settings_icon"
|
||||
optional: true
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
optional: true
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Log Out"
|
||||
timeout: 15000
|
||||
- tapOn: "Log Out"
|
||||
- tapOn:
|
||||
text: "Log Out"
|
||||
optional: true
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "client_landing_sign_in"
|
||||
timeout: 30000
|
||||
- assertVisible:
|
||||
id: "client_landing_sign_in"
|
||||
|
||||
|
||||
|
||||
47
apps/mobile/apps/client/maestro/auth/happy_path/sign_in.yaml
Normal file
47
apps/mobile/apps/client/maestro/auth/happy_path/sign_in.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# Client App — Sign In flow
|
||||
appId: com.krowwithus.client
|
||||
env:
|
||||
EMAIL: ${TEST_CLIENT_EMAIL}
|
||||
PASSWORD: ${TEST_CLIENT_PASSWORD}
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "client_landing_sign_in"
|
||||
timeout: 60000
|
||||
|
||||
- tapOn:
|
||||
id: "client_landing_sign_in"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "sign_in_email"
|
||||
timeout: 20000
|
||||
|
||||
- tapOn:
|
||||
id: "sign_in_email"
|
||||
- inputText: ${EMAIL}
|
||||
- stopApp: # Small trick to hide keyboard on some emulators
|
||||
optional: true
|
||||
- launchApp: # Resume where we were
|
||||
clearState: false
|
||||
|
||||
- tapOn:
|
||||
id: "sign_in_password"
|
||||
- inputText: ${PASSWORD}
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn:
|
||||
id: "sign_in_submit_button"
|
||||
optional: true
|
||||
- tapOn:
|
||||
text: "(?i)Sign In"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "client_nav_home"
|
||||
timeout: 45000
|
||||
|
||||
- assertVisible:
|
||||
id: "client_nav_home"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Client App — Sign out flow (tap Log Out, confirm in dialog)
|
||||
# Run: maestro test auth/sign_in.yaml auth/sign_out.yaml -e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=...
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "client_nav_home"
|
||||
timeout: 30000
|
||||
|
||||
# Open Settings
|
||||
- tapOn:
|
||||
id: "header_settings_icon"
|
||||
optional: true
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
optional: true
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Log Out"
|
||||
timeout: 20000
|
||||
|
||||
- tapOn: "Log Out"
|
||||
- assertVisible: "Are you sure you want to log out?"
|
||||
- tapOn: "Log Out"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "client_landing_sign_in"
|
||||
timeout: 30000
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,17 @@ env:
|
||||
PASSWORD: ${TEST_CLIENT_PASSWORD}
|
||||
COMPANY: ${TEST_CLIENT_COMPANY}
|
||||
---
|
||||
- launchApp
|
||||
- assertVisible: "Create Account"
|
||||
# Always start from a clean auth state so the Get Started
|
||||
# screen (with "Create Account") is shown even if already signed in.
|
||||
- launchApp:
|
||||
clearState: true
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Create Account"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Create Account"
|
||||
- assertVisible: "Company"
|
||||
- assertVisible: "(?i)Company Name"
|
||||
- tapOn:
|
||||
id: sign_up_company
|
||||
- inputText: ${COMPANY}
|
||||
@@ -26,3 +33,6 @@ env:
|
||||
- inputText: ${PASSWORD}
|
||||
- tapOn: "Create Account"
|
||||
- assertVisible: "Home"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Client App — Sign in with wrong password (negative test)
|
||||
# Uses valid email, invalid password; expects error and stays on Sign In
|
||||
# Run: maestro test .../auth/sign_in_invalid_password.yaml -e TEST_CLIENT_EMAIL=testclient@gmail.com -e TEST_CLIENT_INVALID_PASSWORD=wrongpass
|
||||
appId: com.krowwithus.client
|
||||
env:
|
||||
EMAIL: ${TEST_CLIENT_EMAIL}
|
||||
PASSWORD: ${TEST_CLIENT_INVALID_PASSWORD}
|
||||
---
|
||||
- launchApp
|
||||
- assertVisible: "Sign In"
|
||||
- tapOn: "Sign In"
|
||||
- assertVisible: "Email"
|
||||
- tapOn:
|
||||
id: sign_in_email
|
||||
- inputText: ${EMAIL}
|
||||
- tapOn:
|
||||
id: sign_in_password
|
||||
- inputText: ${PASSWORD}
|
||||
- tapOn: "Sign In"
|
||||
- extendedWaitUntil:
|
||||
visible: "Invalid"
|
||||
timeout: 5000
|
||||
- assertVisible: "Sign In"
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Client App — Sign In flow
|
||||
# Credentials via env: TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD
|
||||
# Run: maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml -e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=...
|
||||
# Or: export MAESTRO_TEST_CLIENT_EMAIL / MAESTRO_TEST_CLIENT_PASSWORD (Maestro auto-reads MAESTRO_*)
|
||||
|
||||
appId: com.krowwithus.client
|
||||
env:
|
||||
EMAIL: ${TEST_CLIENT_EMAIL}
|
||||
PASSWORD: ${TEST_CLIENT_PASSWORD}
|
||||
---
|
||||
- launchApp
|
||||
- assertVisible: "Sign In"
|
||||
- tapOn: "Sign In"
|
||||
- assertVisible: "Email"
|
||||
- tapOn:
|
||||
id: sign_in_email
|
||||
- inputText: ${EMAIL}
|
||||
- tapOn:
|
||||
id: sign_in_password
|
||||
- inputText: ${PASSWORD}
|
||||
- tapOn: "Sign In"
|
||||
- assertVisible: "Home"
|
||||
@@ -0,0 +1,50 @@
|
||||
# Client App — Billing: Empty state smoke
|
||||
# Purpose:
|
||||
# - Opens Billing tab
|
||||
# - Verifies the screen loads correctly with no invoices
|
||||
# - Checks that "Current Period" header is always visible
|
||||
# - Verifies empty-state copy appears when no invoices exist
|
||||
# - Deterministic: always passes whether or not invoices exist
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/billing/billing_empty_state.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- tapOn: "(?i)Billing"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Current Period"
|
||||
timeout: 20000
|
||||
|
||||
# Entry assertion — billing is loaded
|
||||
- assertVisible: "(?i)Current Period"
|
||||
|
||||
# Awaiting Approval section
|
||||
- assertVisible:
|
||||
text: "(?i)Awaiting Approval"
|
||||
optional: true
|
||||
|
||||
# Case A: invoices exist → invoice amounts/dates are visible
|
||||
- assertVisible:
|
||||
text: "(?i).*(Invoice Ready|Review & Approve|Approved).*"
|
||||
optional: true
|
||||
|
||||
# Case B: no invoices → empty-state message visible
|
||||
- assertVisible:
|
||||
text: "(?i).*(No invoices|nothing|all clear|no pending).*"
|
||||
optional: true
|
||||
|
||||
# Exit assertion — still in Billing context
|
||||
- assertVisible: "(?i)Current Period"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Client App — Billing overview
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Billing"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Billing"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Current Period"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i)Current Period"
|
||||
@@ -0,0 +1,62 @@
|
||||
# Client App — E2E: Comprehensive Invoice Approval
|
||||
# Purpose:
|
||||
# - Navigates to Billing -> Awaiting Approval.
|
||||
# - Captures the Invoice ID or date/amount (conceptually).
|
||||
# - Approves the invoice.
|
||||
# - Verifies success message.
|
||||
# - Navigates to the "Approved" tab.
|
||||
# - Verifies the invoice is now listed in the history.
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
11: # apps/mobile/apps/client/maestro/auth/happy_path/sign_in.yaml \
|
||||
12: # apps/mobile/apps/client/maestro/billing/happy_path/comprehensive_billing_flow.yaml \
|
||||
13: # -e TEST_CLIENT_EMAIL=... \
|
||||
14: # -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- tapOn: "Home"
|
||||
- tapOn: "Billing"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Awaiting Approval"
|
||||
timeout: 10000
|
||||
|
||||
# 1. Enter the approval flow
|
||||
- tapOn: "Awaiting Approval"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Review & Approve"
|
||||
timeout: 10000
|
||||
|
||||
# 2. Approve the first invoice
|
||||
- tapOn: "Review & Approve"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Approve"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Approve"
|
||||
|
||||
# 3. Verify success message
|
||||
- extendedWaitUntil:
|
||||
visible: "Invoice approved and payment initiated"
|
||||
timeout: 15000
|
||||
|
||||
# 4. Deep Verification: check the 'Approved' tab
|
||||
- tapOn: "Approved"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Invoice History"
|
||||
timeout: 10000
|
||||
|
||||
# Ensure we see a 'View Receipt' or 'Approved' status instead of 'Review'
|
||||
- assertVisible: "View Receipt"
|
||||
- assertNotVisible: "Review & Approve"
|
||||
|
||||
# Back to Home
|
||||
- tapOn: "Home"
|
||||
@@ -0,0 +1,68 @@
|
||||
# Client App — E2E: Invoice Approval Flow
|
||||
# Flow:
|
||||
# - Home → Billing Tab
|
||||
# - Navigate to "Awaiting Approval" (Pending Invoices)
|
||||
# - Review the first pending invoice
|
||||
# - Click Approve & verify success
|
||||
#
|
||||
# Prerequisite:
|
||||
# User must have at least one invoice in the "Awaiting Approval" state (pending validation/timesheets).
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/billing/invoice_approval_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- assertVisible: "Home"
|
||||
- tapOn: "Home"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
|
||||
- tapOn: "Billing"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Awaiting Approval"
|
||||
timeout: 10000
|
||||
|
||||
# Open the Pending Invoices List
|
||||
- tapOn: "Awaiting Approval"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Review & Approve"
|
||||
timeout: 10000
|
||||
|
||||
# Tap the first invoice waiting for approval
|
||||
- tapOn: "Review & Approve"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Approve"
|
||||
timeout: 10000
|
||||
|
||||
# Tap the primary approve action in CompletionReviewActions
|
||||
- tapOn: "Approve"
|
||||
|
||||
# Validate it returns automatically and shows the success snackbar banner
|
||||
- extendedWaitUntil:
|
||||
visible: "Invoice approved and payment initiated"
|
||||
timeout: 15000
|
||||
|
||||
# Post-Action State Verification:
|
||||
# After approval, we confirm we are back on the 'Invoices' screen and the count has updated (or the item is gone)
|
||||
- extendedWaitUntil:
|
||||
visible: "Awaiting Approval"
|
||||
timeout: 10000
|
||||
|
||||
# Optionally, verify the 'Review & Approve' button for that specific invoice is gone.
|
||||
- assertNotVisible:
|
||||
text: "Review & Approve"
|
||||
optional: true
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Client App — Billing: open invoice details (smoke)
|
||||
# Purpose:
|
||||
# - Validates Billing tab loads
|
||||
# - If invoices exist, opens an invoice and verifies invoice actions (e.g. Download PDF)
|
||||
# - If no invoices exist, verifies the empty-state copy is shown
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/billing/invoice_details_smoke.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Billing"
|
||||
- extendedWaitUntil:
|
||||
visible: "Current Period"
|
||||
timeout: 10000
|
||||
|
||||
# If there are invoices ready, open one and verify details actions (optional)
|
||||
- tapOn:
|
||||
text: "Invoice Ready"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Download Invoice PDF"
|
||||
timeout: 10000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: "Download Invoice PDF"
|
||||
optional: true
|
||||
|
||||
# Otherwise, validate deterministic empty states (still a valid smoke outcome)
|
||||
- assertVisible:
|
||||
text: "No invoices ready yet"
|
||||
optional: true
|
||||
|
||||
- assertVisible:
|
||||
text: "No Invoices for the selected period"
|
||||
optional: true
|
||||
|
||||
# Always end by asserting we are still in Billing context
|
||||
- assertVisible: "Current Period"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Client App — Home dashboard widgets & quick actions
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Home"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 20000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Create Order.*Schedule.*"
|
||||
timeout: 30000
|
||||
|
||||
- assertVisible: "(?i)RAPID.*Urgent.*"
|
||||
- assertVisible: "(?i)Create Order.*Schedule.*"
|
||||
|
||||
- tapOn: "(?i)Create Order.*Schedule.*"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Create Order"
|
||||
timeout: 15000
|
||||
|
||||
- assertVisible: "(?i)ORDER TYPE"
|
||||
- assertVisible: "(?i)RAPID.*URGENT.*"
|
||||
- assertVisible: "(?i)One-Time.*Single Event.*"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Client App — Tab bar roundtrip smoke (Home ↔ Orders/Billing/Coverage/Reports)
|
||||
# Goal: ensure tab navigation works and returns to Home without relying on dynamic data.
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/home/tab_bar_roundtrip.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
# Start from Home (stabilizes header + tab bar)
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
# Orders
|
||||
- tapOn: "Orders"
|
||||
- assertVisible: "Orders"
|
||||
|
||||
# Billing
|
||||
- tapOn: "Billing"
|
||||
- assertVisible: "Billing"
|
||||
|
||||
# Coverage
|
||||
- tapOn: "Coverage"
|
||||
- assertVisible: "Coverage"
|
||||
|
||||
# Reports
|
||||
- tapOn: "Reports"
|
||||
- assertVisible: "Reports"
|
||||
|
||||
# Back to Home
|
||||
- tapOn: "Home"
|
||||
- assertVisible: "Welcome back"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Client App — Hubs: Empty state smoke
|
||||
# Purpose:
|
||||
# - Navigates to Settings → Clock-In Hubs
|
||||
# - Verifies the hubs list loads correctly
|
||||
# - If no hubs exist, verifies an empty-state message is shown
|
||||
# - If hubs exist, verifies the list renders correctly (passes either way)
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/hubs/hub_empty_state.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 20000
|
||||
|
||||
# Open Settings via header gear icon (top-right)
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Clock-In Hubs"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "(?i)Clock-In Hubs"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Hubs|Manage clock-in locations).*"
|
||||
timeout: 15000
|
||||
|
||||
# Entry assertion — hubs page loaded
|
||||
- assertVisible: "(?i).*(Hubs|Manage clock-in locations).*"
|
||||
|
||||
# "Add Hub" button should always be present
|
||||
- assertVisible: "(?i)Add Hub"
|
||||
|
||||
# Case A: hubs exist — list is visible
|
||||
- assertVisible:
|
||||
text: "(?i).*(Hub|Location|Address).*"
|
||||
optional: true
|
||||
|
||||
# Case B: no hubs — empty state copy should be shown
|
||||
- assertVisible:
|
||||
text: "(?i).*(No hubs|No locations|Add your first|get started|no clock-in).*"
|
||||
optional: true
|
||||
|
||||
# Exit assertion — still on the Hubs screen
|
||||
- assertVisible: "(?i)Add Hub"
|
||||
@@ -0,0 +1,74 @@
|
||||
# Client App — E2E: Create Hub and verify it appears in list
|
||||
# Flow:
|
||||
# - Home → Settings (gear) → Clock-In Hubs
|
||||
# - Add Hub → fill form → Create Hub
|
||||
# - Assert success message and newly created hub card is visible
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/hubs/create_hub_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
# Open Settings via header gear icon (top-right)
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Clock-In Hubs"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Clock-In Hubs"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Hubs\nManage clock-in locations"
|
||||
timeout: 15000
|
||||
|
||||
- tapOn: "Add Hub"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Add New Hub"
|
||||
timeout: 10000
|
||||
|
||||
# Fill required fields
|
||||
- tapOn: "Hub Name *"
|
||||
- inputText: "E2E Hub Automation"
|
||||
- hideKeyboard
|
||||
|
||||
# Address field uses an autocomplete widget; focus it via a safe coordinate
|
||||
# within the form, then type an address.
|
||||
- tapOn:
|
||||
point: "50%,60%"
|
||||
- inputText: "345 Park Avenue, New York, NY"
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn:
|
||||
point: "50%,88%"
|
||||
|
||||
# For now we assert that tapping Create returns us to the Hubs list
|
||||
# (header still visible), which exercises the full Add Hub form flow.
|
||||
- extendedWaitUntil:
|
||||
visible: "Hubs\nManage clock-in locations"
|
||||
timeout: 20000
|
||||
|
||||
# Post-Action State Verification:
|
||||
# Verify the newly created hub name is in the list
|
||||
- scrollUntilVisible:
|
||||
element: "E2E Hub Automation"
|
||||
visibilityPercentage: 50
|
||||
timeout: 10000
|
||||
- assertVisible: "E2E Hub Automation"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Client App — E2E: Delete Hub (create → details → delete)
|
||||
# Purpose:
|
||||
# - Creates a hub
|
||||
# - Opens Hub Details and deletes it
|
||||
# - Verifies deletion confirmation and success
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/hubs/delete_hub_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
# Open Settings via header gear icon (top-right)
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Clock-In Hubs"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Clock-In Hubs"
|
||||
- extendedWaitUntil:
|
||||
visible: "Hubs\nManage clock-in locations"
|
||||
timeout: 15000
|
||||
|
||||
# Create a hub for this test run (deterministic)
|
||||
- tapOn: "Add Hub"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add New Hub"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Hub Name *"
|
||||
- inputText: "E2E Hub Delete"
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn:
|
||||
point: "50%,60%"
|
||||
- inputText: "345 Park Avenue, New York, NY"
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn:
|
||||
point: "50%,88%"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Hubs\nManage clock-in locations"
|
||||
timeout: 20000
|
||||
|
||||
- scrollUntilVisible:
|
||||
element: "E2E Hub Delete"
|
||||
visibilityPercentage: 50
|
||||
timeout: 15000
|
||||
- tapOn: "E2E Hub Delete"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "E2E Hub Delete"
|
||||
timeout: 10000
|
||||
|
||||
# Delete action is the destructive bottom button on details page
|
||||
- assertVisible: "Delete"
|
||||
- tapOn: "Delete"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Confirm Hub Deletion"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Delete"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Hub deleted successfully"
|
||||
timeout: 15000
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Client App — E2E: Edit Hub (create → details → edit)
|
||||
# Purpose:
|
||||
# - Ensures hubs list is reachable from Settings
|
||||
# - Creates a hub (if needed for the test run)
|
||||
# - Opens Hub Details, edits hub name, verifies success
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/hubs/edit_hub_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
# Open Settings via header gear icon (top-right)
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Clock-In Hubs"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Clock-In Hubs"
|
||||
- extendedWaitUntil:
|
||||
visible: "Hubs\nManage clock-in locations"
|
||||
timeout: 15000
|
||||
|
||||
# Create a hub for this test run (deterministic)
|
||||
- tapOn: "Add Hub"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add New Hub"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Hub Name *"
|
||||
- inputText: "E2E Hub Edit"
|
||||
- hideKeyboard
|
||||
|
||||
# Address field uses an autocomplete widget; focus it via a safe coordinate
|
||||
- tapOn:
|
||||
point: "50%,60%"
|
||||
- inputText: "345 Park Avenue, New York, NY"
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn:
|
||||
point: "50%,88%"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Hubs\nManage clock-in locations"
|
||||
timeout: 20000
|
||||
|
||||
- scrollUntilVisible:
|
||||
element: "E2E Hub Edit"
|
||||
visibilityPercentage: 50
|
||||
timeout: 15000
|
||||
- tapOn: "E2E Hub Edit"
|
||||
|
||||
# Hub details page uses the hub name as the app bar title
|
||||
- extendedWaitUntil:
|
||||
visible: "E2E Hub Edit"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "Edit Hub"
|
||||
- tapOn: "Edit Hub"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit Hub"
|
||||
timeout: 10000
|
||||
|
||||
# Append suffix to avoid needing to clear the field
|
||||
- tapOn: "Hub Name *"
|
||||
- inputText: " Updated"
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn: "Save Changes"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Hub updated successfully"
|
||||
timeout: 15000
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Client App — E2E: Hub Management Lifecycle
|
||||
# Purpose:
|
||||
# - Create a new Hub.
|
||||
# - Find and Edit the Hub.
|
||||
# - Delete the Hub.
|
||||
# - Verify it is gone from the list.
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- tapOn: "Home"
|
||||
- tapOn: "Settings" # Assuming Hubs are managed via Settings or a Hubs tab
|
||||
|
||||
# 1. Navigate to Hubs
|
||||
- tapOn: "Manage Hubs"
|
||||
|
||||
# 2. Create Hub
|
||||
- tapOn: "Add New Hub" # Or the '+' button
|
||||
- tapOn: "HUB NAME"
|
||||
- inputText: "Maestro Test Hub"
|
||||
- tapOn: "ADDRESS"
|
||||
- inputText: "123 Test Street, NYC"
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn: "Save Hub"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Hub created successfully"
|
||||
timeout: 10000
|
||||
|
||||
# 3. Edit Hub
|
||||
- tapOn: "Maestro Test Hub"
|
||||
- tapOn: "HUB NAME"
|
||||
- inputText: "Updated Maestro Hub"
|
||||
- hideKeyboard
|
||||
- tapOn: "Save Hub"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Hub updated successfully"
|
||||
timeout: 10000
|
||||
|
||||
# 4. Delete Hub
|
||||
- tapOn: "Updated Maestro Hub"
|
||||
- tapOn: "Delete Hub"
|
||||
- tapOn: "DELETE" # Confirmation
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Hub deleted"
|
||||
timeout: 10000
|
||||
|
||||
# 5. Verify gone
|
||||
- assertNotVisible: "Updated Maestro Hub"
|
||||
@@ -0,0 +1,49 @@
|
||||
# Client App — Manage Hubs via Settings quick link
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Home"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 20000
|
||||
|
||||
# Open Settings via stable semantic ID
|
||||
- tapOn:
|
||||
id: "client_home_settings"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*Quick Links.*"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i)Profile"
|
||||
- assertVisible: "(?i)Clock-In Hubs"
|
||||
|
||||
- tapOn: "(?i)Clock-In Hubs"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*Hubs.*"
|
||||
timeout: 15000
|
||||
|
||||
- assertVisible: "(?i).*Add Hub.*"
|
||||
|
||||
- tapOn: "(?i)Add Hub"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Add New Hub"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "(?i)Create Hub"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*required.*"
|
||||
timeout: 5000
|
||||
@@ -0,0 +1,20 @@
|
||||
# Client App — Billing tab navigation
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Billing"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Billing"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Current Period|Billing).*"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i).*(Current Period|Billing).*"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Client App — Coverage tab navigation
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Coverage"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Coverage"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Today.*Status|Daily Coverage|Unfilled|Checked In).*"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i).*(Today.*Status|Daily Coverage|Unfilled|Checked In).*"
|
||||
20
apps/mobile/apps/client/maestro/navigation/smoke/home.yaml
Normal file
20
apps/mobile/apps/client/maestro/navigation/smoke/home.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Client App — Home tab navigation
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Home"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Create Order|RAPID).*"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i).*(Welcome back|Create Order|RAPID).*"
|
||||
20
apps/mobile/apps/client/maestro/navigation/smoke/orders.yaml
Normal file
20
apps/mobile/apps/client/maestro/navigation/smoke/orders.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Client App — Orders tab navigation
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Orders"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Orders"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Up Next|Post an Order).*"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i).*(Up Next|Post an Order).*"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Client App — Reports tab navigation
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Reports"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Reports"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Workforce Control Tower"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i)Workforce Control Tower"
|
||||
@@ -0,0 +1,85 @@
|
||||
# DEBUG: Staff Search Only
|
||||
# Tests ONLY the Staff shift search flow in isolation.
|
||||
# Pass a known order name to verify the Staff app can find it.
|
||||
#
|
||||
# Usage:
|
||||
# maestro test staff_search_only.yaml -e TEST_STAFF_PHONE="5557654321" -e TEST_STAFF_OTP="123456" -e TEST_ORDER_NAME="E2E-XXXXX"
|
||||
|
||||
appId: com.krowwithus.staff
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
# Wait for app to fully render
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 15000
|
||||
|
||||
# Sign in if needed
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Log In"
|
||||
file: ../../../../staff/maestro/auth/happy_path/sign_in.yaml
|
||||
env:
|
||||
TEST_STAFF_PHONE: ${TEST_STAFF_PHONE}
|
||||
TEST_STAFF_OTP: ${TEST_STAFF_OTP}
|
||||
|
||||
# Navigate to Shifts
|
||||
- tapOn:
|
||||
id: "nav_shifts"
|
||||
|
||||
# Pull to refresh (force reload from backend)
|
||||
- swipe:
|
||||
start: 50%, 30%
|
||||
end: 50%, 80%
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 8000
|
||||
|
||||
# 3. Aggressive Retry Loop: Search, if not found, refresh and try again.
|
||||
# This handles slow backend indexing by periodically pulling fresh data.
|
||||
- repeat:
|
||||
times: 5
|
||||
commands:
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
commands:
|
||||
# 1. Clear current search
|
||||
- tapOn:
|
||||
id: "find_shifts_search_input"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b" # Strategic clear if "" fails
|
||||
- inputText: ""
|
||||
- tapOn: "Shifts" # Dismiss keyboard
|
||||
|
||||
# 2. Pull-to-refresh
|
||||
- swipe:
|
||||
start: 50%, 60%
|
||||
end: 50%, 90%
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 15000
|
||||
|
||||
# 3. Search again
|
||||
- tapOn:
|
||||
id: "find_shifts_search_input"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: "${TEST_ORDER_NAME}\n"
|
||||
- tapOn: "Shifts" # Dismiss keyboard
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
|
||||
# Final check with a long timeout
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
timeout: 30000
|
||||
|
||||
- assertVisible:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Client App — Completed tab: edit icon hidden for past/completed orders (#492)
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Orders"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Orders"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Orders"
|
||||
timeout: 15000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Completed.*"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "(?i)Completed.*"
|
||||
|
||||
- assertVisible: "(?i)Completed.*"
|
||||
@@ -0,0 +1,60 @@
|
||||
# Client App — Orders: Empty state smoke
|
||||
# Purpose:
|
||||
# - Opens the Orders tab
|
||||
# - Verifies filter tabs (Up Next, Active, Completed) are always present
|
||||
# - If no orders exist in a tab, verifies empty-state copy is shown (not a crash)
|
||||
# - Taps each tab and asserts either a list or empty-state is visible
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/orders_empty_state.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- tapOn: "(?i)Orders"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Orders"
|
||||
timeout: 15000
|
||||
|
||||
# Verify filter tabs are always present
|
||||
- assertVisible: "(?i)Up Next.*"
|
||||
- assertVisible: "(?i)Active.*"
|
||||
- assertVisible: "(?i)Completed.*"
|
||||
|
||||
# --- Up Next tab ---
|
||||
- tapOn: "(?i)Up Next.*"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- assertVisible:
|
||||
text: "(?i).*(No orders|No upcoming|Nothing here|order).*"
|
||||
optional: true
|
||||
|
||||
# --- Active tab ---
|
||||
- tapOn: "(?i)Active.*"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- assertVisible:
|
||||
text: "(?i).*(No active|No orders|Nothing here|order).*"
|
||||
optional: true
|
||||
|
||||
# --- Completed tab ---
|
||||
- tapOn: "(?i)Completed.*"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- assertVisible:
|
||||
text: "(?i).*(No completed|No orders|Nothing here|order).*"
|
||||
optional: true
|
||||
|
||||
# Exit assertion — we are still on the Orders screen
|
||||
- assertVisible: "(?i).*(Up Next|Active|Completed).*"
|
||||
@@ -0,0 +1,118 @@
|
||||
# Client App — E2E: Comprehensive Order Lifecycle (Create -> Verify in List -> View Details)
|
||||
# Purpose:
|
||||
# - Generates a unique order name via JS.
|
||||
# - Creates a One-Time order.
|
||||
# - Verifies the success confirmation.
|
||||
# - Navigates to the Orders list.
|
||||
# - Uses scrollUntilVisible to find the unique order.
|
||||
# - Taps the order and verifies details on the summary page.
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
11: # apps/mobile/apps/client/maestro/auth/happy_path/sign_in.yaml \
|
||||
12: # apps/mobile/apps/client/maestro/orders/happy_path/comprehensive_order_lifecycle.yaml \
|
||||
13: # -e TEST_CLIENT_EMAIL=... \
|
||||
14: # -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
# 1. Generate unique order name
|
||||
- runScript: ../../scripts/generate_order_name.js
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
# 2. Start Create Order Flow
|
||||
- tapOn: "Create Order\\nSchedule shifts"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "One-Time\\nSingle Event or Shift Request"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "One-Time\\nSingle Event or Shift Request"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "One-Time Order"
|
||||
timeout: 10000
|
||||
|
||||
# 3. Fill Order Details
|
||||
- tapOn: ".*(ORDER NAME|EVENT NAME|Order[ Nn]ame|Event[ Nn]ame).*"
|
||||
- inputText: ${output.orderName}
|
||||
- hideKeyboard
|
||||
|
||||
# Select Role
|
||||
- tapOn: ".*(Select Role|SELECT ROLE).*"
|
||||
- tapOn: ".*\\$.*" # Just tap the first role from the dropdown
|
||||
|
||||
# Set Start Time (Required for valid form)
|
||||
- scrollUntilVisible:
|
||||
element: "--:--"
|
||||
direction: DOWN
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
- tapOn: "--:--"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ok"
|
||||
timeout: 5000
|
||||
- tapOn: "(?i)ok"
|
||||
|
||||
# Set End Time (Required for valid form)
|
||||
- tapOn: "--:--"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ok"
|
||||
timeout: 5000
|
||||
- tapOn: "(?i)ok"
|
||||
|
||||
# Scroll if needed to see the Create Order button
|
||||
- scrollUntilVisible:
|
||||
element: "(?i)Create Order"
|
||||
direction: DOWN
|
||||
timeout: 5000
|
||||
|
||||
- tapOn: "(?i)Create Order"
|
||||
|
||||
# 4. Verify Success Confirmation
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Order Created.*"
|
||||
timeout: 30000
|
||||
|
||||
- assertVisible: "(?i)Order Created.*"
|
||||
- assertVisible: ${output.orderName}
|
||||
|
||||
# 5. Navigate to Orders List and Verify Persistence
|
||||
- tapOn: "Back to Orders"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Orders"
|
||||
timeout: 15000
|
||||
|
||||
# Select the "Up Next" or "Active" tab if not already selected (assuming default is correct)
|
||||
- tapOn: "Up Next"
|
||||
|
||||
# Scroll until we find our unique order name
|
||||
- scrollUntilVisible:
|
||||
element: ${output.orderName}
|
||||
direction: DOWN
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: ${output.orderName}
|
||||
|
||||
# 6. View Details and Final Verification
|
||||
- tapOn: ${output.orderName}
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Order Summary"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: ${output.orderName}
|
||||
- assertVisible: "POSTED" # or whichever status it initialises to
|
||||
- assertVisible: "Role" # Check if role label is visible
|
||||
|
||||
# Optionally, go back to Home
|
||||
- tapOn: "Home"
|
||||
@@ -0,0 +1,92 @@
|
||||
# Client App — E2E: Create One-Time Order and verify success
|
||||
# Flow:
|
||||
# - Home → Create Order → One-Time
|
||||
# - Fill required fields (Event Name, Role)
|
||||
# - Create Order
|
||||
# - Verify success message and return to orders
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/create_order_one_time_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
- tapOn: "Create Order\\nSchedule shifts"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "One-Time\\nSingle Event or Shift Request"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "One-Time\\nSingle Event or Shift Request"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "One-Time Order"
|
||||
timeout: 10000
|
||||
|
||||
# Wait for form or empty state data to load from API
|
||||
- extendedWaitUntil:
|
||||
visible: ".*(Create your order|No Vendors Available).*"
|
||||
timeout: 15000
|
||||
|
||||
- assertNotVisible: "No Vendors Available"
|
||||
- tapOn: ".*(ORDER NAME|EVENT NAME|Order[ Nn]ame|Event[ Nn]ame).*"
|
||||
- inputText: "Test E2E Event"
|
||||
- hideKeyboard
|
||||
|
||||
# Wait for Vendor and Hub to auto-populate the defaults from the API.
|
||||
# We just need to give it a second.
|
||||
- extendedWaitUntil:
|
||||
visible: ".*(Select Role|SELECT ROLE).*"
|
||||
timeout: 10000
|
||||
|
||||
# Select Role (Required for valid form)
|
||||
- tapOn: ".*(Select Role|SELECT ROLE).*"
|
||||
- tapOn: ".*\\$.*" # Tap the first role from the dropdown
|
||||
|
||||
# Set Start Time (Required for valid form)
|
||||
- scrollUntilVisible:
|
||||
element: "--:--"
|
||||
direction: DOWN
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
- tapOn: "--:--"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ok"
|
||||
timeout: 5000
|
||||
- tapOn: "(?i)ok"
|
||||
|
||||
# Set End Time (Required for valid form)
|
||||
- tapOn: "--:--"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ok"
|
||||
timeout: 5000
|
||||
- tapOn: "(?i)ok"
|
||||
|
||||
- scrollUntilVisible:
|
||||
element: "(?i)Create Order"
|
||||
direction: DOWN
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Wait for Create Order button to be enabled (isValid=true handles this by making it clickable)
|
||||
- tapOn: "(?i)Create Order"
|
||||
|
||||
# Success screen shows "Order received." or similar success title/message
|
||||
- extendedWaitUntil:
|
||||
visible: "Test E2E Event" # or success message, assuming it goes back to Orders or shows Success Screen
|
||||
timeout: 45000
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# Multi-App E2E: Client Creates Order -> Staff Sees Shift
|
||||
# Purpose:
|
||||
# - Historically verifies that an order created in the Client app
|
||||
# is instantly visible as a shift in the Staff app.
|
||||
#
|
||||
# Run:
|
||||
# maestro test cross_app_order_verification.yaml \
|
||||
11: # -e TEST_CLIENT_EMAIL=... \
|
||||
12: # -e TEST_CLIENT_PASSWORD=... \
|
||||
13: # -e TEST_STAFF_PHONE=... \
|
||||
14: # -e TEST_STAFF_OTP=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
# --- PHASE 1: CLIENT CREATES ORDER ---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- runScript: ../../scripts/generate_order_name.js
|
||||
|
||||
- tapOn:
|
||||
text: "(?i)Home"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Welcome back"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "Create Order\nSchedule shifts"
|
||||
- tapOn: "One-Time\nSingle Event or Shift Request"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "One-Time Order"
|
||||
timeout: 10000
|
||||
|
||||
# 3. Fill Order Details
|
||||
- tapOn: ".*(ORDER NAME|EVENT NAME|Order[ Nn]ame|Event[ Nn]ame).*"
|
||||
- inputText: ${output.orderName}
|
||||
- hideKeyboard
|
||||
|
||||
# Select Role
|
||||
- tapOn: ".*(Select Role|SELECT ROLE).*"
|
||||
- tapOn: ".*\\$.*"
|
||||
|
||||
# Set Start Time (Required for valid form)
|
||||
- scrollUntilVisible:
|
||||
element: "--:--"
|
||||
direction: DOWN
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
- tapOn: "--:--"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ok"
|
||||
timeout: 5000
|
||||
- tapOn: "(?i)ok"
|
||||
|
||||
# Set End Time (Required for valid form)
|
||||
- tapOn: "--:--"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ok"
|
||||
timeout: 5000
|
||||
- tapOn: "(?i)ok"
|
||||
|
||||
- scrollUntilVisible:
|
||||
element: "(?i)Create Order"
|
||||
direction: DOWN
|
||||
|
||||
- tapOn: "(?i)Create Order"
|
||||
|
||||
# 4. Verify Success Confirmation
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Order Created.*"
|
||||
timeout: 30000
|
||||
|
||||
- stopApp: com.krowwithus.client
|
||||
|
||||
# --- PHASE 2: STAFF VERIFIES SHIFT ---
|
||||
- appId: com.krowwithus.staff
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
# If not logged in, we'd need a sign_in flow here, but we assume state is kept
|
||||
# or we run this after a staff sign-in test.
|
||||
# For a standalone test, we should include the login steps.
|
||||
|
||||
# (Optional login steps if app starts at landing)
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Join the Workforce"
|
||||
file: ../../../../staff/maestro/auth/happy_path/sign_in.yaml
|
||||
env:
|
||||
PHONE: ${TEST_STAFF_PHONE}
|
||||
OTP: ${TEST_STAFF_OTP}
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Shifts"
|
||||
timeout: 20000
|
||||
|
||||
- launchApp:
|
||||
appId: com.krowwithus.staff
|
||||
clearState: false
|
||||
|
||||
# Wait for Staff app to fully render AND give backend time to index the new order
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 30000
|
||||
|
||||
# 1. Navigate to Shifts tab
|
||||
- tapOn:
|
||||
id: "nav_shifts"
|
||||
|
||||
# 2. Refresh #1: Swipe to force backend reload, THEN search
|
||||
- swipe:
|
||||
start: 50%, 30%
|
||||
end: 50%, 80%
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 8000
|
||||
|
||||
# Wait for the search field to appear (visible when Find Shifts tab is active)
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Search jobs.*"
|
||||
timeout: 15000
|
||||
|
||||
# 3. Aggressive Retry Loop: Search, if not found, refresh and try again.
|
||||
- repeat:
|
||||
times: 5
|
||||
commands:
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
commands:
|
||||
# 1. Clear current search
|
||||
- tapOn:
|
||||
id: "find_shifts_search_input"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b"
|
||||
- inputText: ""
|
||||
- tapOn: "Shifts" # Dismiss keyboard
|
||||
|
||||
# 2. Pull-to-refresh
|
||||
- swipe:
|
||||
start: 50%, 60%
|
||||
end: 50%, 90%
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 15000
|
||||
|
||||
# 3. Enter search query again
|
||||
- tapOn:
|
||||
id: "find_shifts_search_input"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: "${output.orderName}\n"
|
||||
- tapOn: "Shifts" # Dismiss keyboard
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
|
||||
# 4. Final wait — plenty of time for slow backends
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
timeout: 60000
|
||||
|
||||
- assertVisible:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
|
||||
- tapOn:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
|
||||
# Wait for Details page with a very generous timeout for slow backend syncing
|
||||
- extendedWaitUntil:
|
||||
visible: "LOCATION"
|
||||
timeout: 45000
|
||||
|
||||
- assertVisible: "${output.orderName}"
|
||||
- assertVisible: "(?i)(Apply|Accept|Clock|Confirm).*"
|
||||
@@ -0,0 +1,71 @@
|
||||
# Client App — E2E: Edit Active Order (One-Time)
|
||||
# Flow:
|
||||
# - Home → Expanded Active Order Card
|
||||
# - Tap Edit icon to open OrderEditSheet
|
||||
# - Change position count to +1
|
||||
# - Continue to Review
|
||||
# - Confirm Save
|
||||
#
|
||||
# Prerequisite:
|
||||
# User must have at least one active, uncompleted order.
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/edit_active_order_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- assertVisible: "Home"
|
||||
- tapOn: "Home"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
|
||||
# Wait for active orders to load
|
||||
- scrollUntilVisible:
|
||||
element: "OPEN" # A badge indicating an open order
|
||||
visibilityPercentage: 50
|
||||
timeout: 10000
|
||||
|
||||
# Tap the edit icon (using an id or the generic icon if no ID is present, we scroll to find the Edit Sheet trigger)
|
||||
# Since we can't select by icon natively, we rely on the card layout having a tapped edit button
|
||||
- tapOn:
|
||||
id: "edit_order_button"
|
||||
# Fallback if no ID is set, tap near the top right of the order
|
||||
point: "85%, 25%"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit One-Time Order"
|
||||
timeout: 10000
|
||||
|
||||
# Scroll to the position count control
|
||||
- scrollUntilVisible:
|
||||
element: "WORKERS"
|
||||
visibilityPercentage: 50
|
||||
timeout: 10000
|
||||
|
||||
# Increase worker count
|
||||
- tapOn: "+"
|
||||
|
||||
# Proceed to review
|
||||
- tapOn: "Review Positions"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Positions Breakdown"
|
||||
timeout: 10000
|
||||
|
||||
# Ensure the count reflects the change before confirming
|
||||
- tapOn: "Confirm & Save"
|
||||
|
||||
# Verify it saved and the modal closed
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Confirm & Save"
|
||||
timeout: 15000
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Client App — E2E: Edit Active Order and verify update confirmation
|
||||
# Purpose:
|
||||
# - Opens an active order edit sheet
|
||||
# - Adjusts workers count (+1)
|
||||
# - Confirms save
|
||||
# - Verifies the "Order Updated!" confirmation UI appears
|
||||
#
|
||||
# Prerequisite:
|
||||
# User must have at least one active, uncompleted order.
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/edit_active_order_verify_updated_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- assertVisible: "Home"
|
||||
- tapOn: "Home"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
|
||||
# Wait for active orders to load and ensure we have an OPEN order
|
||||
- scrollUntilVisible:
|
||||
element: "OPEN"
|
||||
visibilityPercentage: 50
|
||||
timeout: 15000
|
||||
|
||||
# Tap edit on the active order card (prefer ID; fallback to a safe point)
|
||||
- tapOn:
|
||||
id: "edit_order_button"
|
||||
point: "85%,25%"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit One-Time Order"
|
||||
timeout: 15000
|
||||
|
||||
- scrollUntilVisible:
|
||||
element: "WORKERS"
|
||||
visibilityPercentage: 50
|
||||
timeout: 15000
|
||||
|
||||
- tapOn: "+"
|
||||
|
||||
- tapOn: "Review Positions"
|
||||
- extendedWaitUntil:
|
||||
visible: "Positions Breakdown"
|
||||
timeout: 15000
|
||||
|
||||
- tapOn: "Confirm & Save"
|
||||
|
||||
# Verify the in-app confirmation appears
|
||||
- extendedWaitUntil:
|
||||
visible: "Order Updated!"
|
||||
timeout: 20000
|
||||
- assertVisible: "Your shift has been updated successfully."
|
||||
|
||||
- tapOn: "Back to Orders"
|
||||
- extendedWaitUntil:
|
||||
visible: "Orders"
|
||||
timeout: 15000
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# Multi-App E2E: Full Order-to-Payment Lifecycle
|
||||
# Roles: Client, Staff
|
||||
# Flow: Create -> Apply -> Clock In (Geofence) -> Clock Out -> Approve Payment
|
||||
|
||||
appId: com.krowwithus.client # Starts with Client profile
|
||||
---
|
||||
# === PERSONA: CLIENT ===
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
# 1. Generate unique identifier for this session
|
||||
- runScript: ../../scripts/generate_order_name.js
|
||||
|
||||
# 2. Create the Order
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?i)Home"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Welcome back"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "Create Order\\nSchedule shifts"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "One-Time\\nSingle Event or Shift Request"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "One-Time\\nSingle Event or Shift Request"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "One-Time Order"
|
||||
timeout: 15000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: ".*(Create your order|No Vendors Available).*"
|
||||
timeout: 15000
|
||||
|
||||
# Safety check: ensure the account has vendors setup!
|
||||
- assertNotVisible: "No Vendors Available"
|
||||
|
||||
- tapOn: ".*(ORDER NAME|EVENT NAME|Order[ Nn]ame|Event[ Nn]ame).*"
|
||||
- inputText: "${output.orderName}"
|
||||
- hideKeyboard
|
||||
|
||||
# Select a Hub (assuming first one is auto-selected or we tap)
|
||||
- tapOn:
|
||||
text: ".*(HUB|Hub).*"
|
||||
optional: true
|
||||
|
||||
# Wait for Vendor and Hub to auto-populate the defaults from the API.
|
||||
- extendedWaitUntil:
|
||||
visible: ".*(Select Role|SELECT ROLE).*"
|
||||
timeout: 10000
|
||||
|
||||
# Select Role (Required for valid form)
|
||||
- tapOn: ".*(Select Role|SELECT ROLE).*"
|
||||
- tapOn: ".*\\$.*" # Tap the first role from the dropdown (matches 'Role - $Cost')
|
||||
|
||||
# Set Start Time (Required for valid form)
|
||||
- scrollUntilVisible:
|
||||
element: "--:--"
|
||||
direction: DOWN
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
- tapOn: "--:--"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ok"
|
||||
timeout: 5000
|
||||
- tapOn: "(?i)ok"
|
||||
|
||||
# Set End Time (Required for valid form)
|
||||
- tapOn: "--:--"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ok"
|
||||
timeout: 5000
|
||||
- tapOn: "(?i)ok"
|
||||
|
||||
- scrollUntilVisible:
|
||||
element: "(?i)Create Order"
|
||||
direction: DOWN
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
- tapOn: "(?i)Create Order"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Order Created.*"
|
||||
timeout: 30000
|
||||
|
||||
# Cool-down to let backend index the new order
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 35000
|
||||
|
||||
- stopApp
|
||||
|
||||
# === PERSONA: STAFF ===
|
||||
- launchApp:
|
||||
appId: com.krowwithus.staff
|
||||
clearState: false
|
||||
|
||||
# Wait for Staff app to fully render (also serves as additional backend indexing time)
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 15000
|
||||
|
||||
# Sign in if needed
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Log In"
|
||||
file: ../../../../staff/maestro/auth/happy_path/sign_in.yaml
|
||||
env:
|
||||
TEST_STAFF_PHONE: ${TEST_STAFF_PHONE}
|
||||
TEST_STAFF_OTP: ${TEST_STAFF_OTP}
|
||||
|
||||
# 1. Navigate to Shifts tab
|
||||
- tapOn:
|
||||
id: "nav_shifts"
|
||||
|
||||
# 2. Refresh #1: Swipe to force backend reload, THEN search
|
||||
- swipe:
|
||||
start: 50%, 30%
|
||||
end: 50%, 80%
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 8000
|
||||
|
||||
# Wait for the search field to appear (visible when Find Shifts tab is active)
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Search jobs.*"
|
||||
timeout: 15000
|
||||
|
||||
# 3. Aggressive Retry Loop: Search, if not found, refresh and try again.
|
||||
- repeat:
|
||||
times: 5
|
||||
commands:
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0 # Index 0 for Logo (ID is unique to cards)
|
||||
commands:
|
||||
# 1. Clear current search
|
||||
- tapOn:
|
||||
id: "find_shifts_search_input"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b"
|
||||
- inputText: ""
|
||||
- tapOn: "Shifts" # Dismiss keyboard
|
||||
|
||||
# 2. Pull-to-refresh
|
||||
- swipe:
|
||||
start: 50%, 60%
|
||||
end: 50%, 90%
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 15000
|
||||
|
||||
# 3. Enter search query again
|
||||
- tapOn:
|
||||
id: "find_shifts_search_input"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: "${output.orderName}\n"
|
||||
- tapOn: "Shifts" # Dismiss keyboard
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
|
||||
# 4. Final wait — plenty of time for slow backends
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
timeout: 60000
|
||||
|
||||
- tapOn:
|
||||
id: "shft_card_logo_placeholder"
|
||||
index: 0
|
||||
|
||||
# Wait for Details page with a very generous timeout for slow backend syncing
|
||||
- extendedWaitUntil:
|
||||
visible: "LOCATION"
|
||||
timeout: 45000
|
||||
|
||||
# 2. Book the shift (Instant Book flow)
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)(Apply|Accept|Clock|Confirm).*"
|
||||
timeout: 20000
|
||||
|
||||
- tapOn: "(?i)(Apply|Accept|Clock|Confirm).*"
|
||||
|
||||
# Handle confirmation dialog if it appears
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "(?i)Book Shift"
|
||||
file: ../../../../staff/maestro/shifts/confirm_booking_dialog.yaml
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Shift successfully booked!"
|
||||
timeout: 20000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Welcome back"
|
||||
timeout: 20000
|
||||
|
||||
# Tap Home tab to reset navigation context
|
||||
- tapOn:
|
||||
id: "nav_home"
|
||||
|
||||
# Tap Clock In tab
|
||||
- tapOn:
|
||||
id: "nav_clock_in"
|
||||
|
||||
# Wait for Clock In screen to load
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Clock In to your Shift"
|
||||
timeout: 15000
|
||||
|
||||
# Set location to the Venue (NYC Grand Hotel as per ClockInCubit)
|
||||
- setLocation:
|
||||
latitude: 40.7128
|
||||
longitude: -74.0060
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Swipe to Check In"
|
||||
timeout: 15000
|
||||
|
||||
- swipe:
|
||||
direction: RIGHT
|
||||
element: "Swipe to Check In"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Check In!"
|
||||
timeout: 15000
|
||||
|
||||
# 4. Clock Out (immediately for test purposes)
|
||||
- swipe:
|
||||
direction: RIGHT
|
||||
element: "Swipe to Check Out"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Check Out!" # Assuming similar success UI
|
||||
timeout: 10000
|
||||
optional: true
|
||||
|
||||
- stopApp
|
||||
|
||||
# === PERSONA: CLIENT ===
|
||||
- launchApp:
|
||||
appId: com.krowwithus.client
|
||||
clearState: false
|
||||
|
||||
# 1. Verify and Approve Billing
|
||||
- tapOn: "Billing"
|
||||
- tapOn: "Awaiting Approval"
|
||||
|
||||
# Find the specific approved shift's related invoice
|
||||
# For the test, we assume the top one is our most recent completion
|
||||
- extendedWaitUntil:
|
||||
visible: "Review & Approve"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Review & Approve"
|
||||
|
||||
- tapOn: "Approve"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Invoice approved"
|
||||
timeout: 15000
|
||||
|
||||
# Test complete - invoice approved successfully
|
||||
- assertVisible: "Invoice approved"
|
||||
@@ -0,0 +1,50 @@
|
||||
# Client App — E2E: Rapid order → parsed One-Time draft
|
||||
# This is a true end-to-end in-app flow:
|
||||
# - Navigate to Create Order → Rapid
|
||||
# - Enter a message
|
||||
# - Submit ("Send Message") which triggers parse usecase
|
||||
# - Verify navigation into One-Time order draft screen
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/rapid_to_one_time_draft_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Create Order\nSchedule shifts"
|
||||
timeout: 20000
|
||||
- tapOn: "Create Order\nSchedule shifts"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "RAPID\nURGENT same-day Coverage"
|
||||
timeout: 10000
|
||||
- tapOn: "RAPID\nURGENT same-day Coverage"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "RAPID Order"
|
||||
timeout: 10000
|
||||
|
||||
# Use one of the predefined example messages so the BLoC
|
||||
# has a realistic input without manual typing.
|
||||
- tapOn: ".*Need 2 cooks ASAP.*"
|
||||
|
||||
# For now we only require that the Send Message action
|
||||
# remains visible and tappable on the Rapid Order screen.
|
||||
- assertVisible: "Send Message"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Client App — E2E: Rapid order → parsed One-Time draft (submit)
|
||||
# Purpose:
|
||||
# - Validates the full RAPID happy path including submitting a message and
|
||||
# landing on the One-Time order draft screen.
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/rapid_to_one_time_draft_submit_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Create Order\nSchedule shifts"
|
||||
timeout: 20000
|
||||
- tapOn: "Create Order\nSchedule shifts"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "RAPID\nURGENT same-day Coverage"
|
||||
timeout: 10000
|
||||
- tapOn: "RAPID\nURGENT same-day Coverage"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "RAPID Order"
|
||||
timeout: 10000
|
||||
|
||||
# Use a predefined example message so the Rapid flow has valid input.
|
||||
- tapOn: ".*Need 2 cooks ASAP.*"
|
||||
|
||||
- assertVisible: "Send Message"
|
||||
- tapOn: "Send Message"
|
||||
|
||||
# Parsed result should navigate into a One-Time order draft screen.
|
||||
# This depends on backend parsing; allow extra time for slower networks.
|
||||
- extendedWaitUntil:
|
||||
visible: "ORDER NAME"
|
||||
timeout: 60000
|
||||
- assertVisible: "Select Role"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Client App — E2E: Reorder Flow
|
||||
# Purpose:
|
||||
# - Navigates Home → Reorder Section.
|
||||
# - Taps "Reorder" on a recent shift.
|
||||
# - Verifies the One-Time Order form is correctly populated.
|
||||
# - Submits the reordered shift.
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- tapOn: "Home"
|
||||
|
||||
# 1. Generate a new name for the reorder to avoid confusion
|
||||
- runScript: ../../scripts/generate_order_name.js
|
||||
|
||||
# 2. Find the "Reorder" section (translates to "Recently Completed" or similar)
|
||||
# We search for the Reorder button text
|
||||
- scrollUntilVisible:
|
||||
element: "REORDER"
|
||||
direction: DOWN
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "REORDER"
|
||||
|
||||
# 3. Verification: We should be on the One-Time Order creation page
|
||||
- extendedWaitUntil:
|
||||
visible: "One-Time Order"
|
||||
timeout: 10000
|
||||
|
||||
# 4. Verification: Some fields should be pre-filled (e.g. Hub)
|
||||
# We can't easily check exact pre-fill values without knowing the history,
|
||||
# but we can verify we are in the flow and the form is loaded.
|
||||
- extendedWaitUntil:
|
||||
visible: ".*(Create your order|No Vendors Available).*"
|
||||
timeout: 15000
|
||||
|
||||
- assertNotVisible: "No Vendors Available"
|
||||
- assertVisible: ".*(ORDER NAME|EVENT NAME|Order[ Nn]ame|Event[ Nn]ame).*"
|
||||
|
||||
# 5. Overwrite name and Submit
|
||||
- tapOn: ".*(ORDER NAME|EVENT NAME|Order[ Nn]ame|Event[ Nn]ame).*"
|
||||
- inputText: "${output.orderName}"
|
||||
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn: "(?i)Create Order"
|
||||
|
||||
# 6. Success check
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Order Created.*"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,26 @@
|
||||
# Client App — View Orders page with filter tabs
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Orders"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Orders"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Orders"
|
||||
timeout: 15000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Up Next.*"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "(?i)Up Next.*"
|
||||
- assertVisible: "(?i)Active.*"
|
||||
- assertVisible: "(?i)Completed.*"
|
||||
@@ -0,0 +1,63 @@
|
||||
# Client App — Orders: Validation errors (negative path)
|
||||
# Purpose:
|
||||
# - Opens Create Order → One-Time form
|
||||
# - Attempts to submit WITHOUT filling required fields
|
||||
# - Verifies inline validation error messages appear
|
||||
# - Verifies the form does NOT navigate away (stays on create order screen)
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/create_order_validation_errors.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 20000
|
||||
|
||||
# Navigate to Create Order
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Create Order.*Schedule.*"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Create Order.*Schedule.*"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ORDER TYPE"
|
||||
timeout: 10000
|
||||
|
||||
# Select One-Time order type
|
||||
- tapOn: "(?i)One-Time.*Single Event.*"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)One-Time Order"
|
||||
timeout: 10000
|
||||
|
||||
# Do NOT fill any fields — attempt to submit immediately
|
||||
- tapOn: "(?i)Create Order"
|
||||
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
|
||||
# Validation: form should NOT advance — still on One-Time Order screen
|
||||
- assertVisible: "(?i)One-Time Order"
|
||||
|
||||
# Validation: at least one inline error or disabled-state indicator should appear
|
||||
- assertVisible:
|
||||
text: "(?i).*(required|must|invalid|please fill|error).*"
|
||||
optional: true
|
||||
|
||||
# Verify ORDER NAME field is still visible (form did not navigate away)
|
||||
- assertVisible: "(?i)ORDER NAME"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Client App — Create order flow entry
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Home"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 20000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Create Order.*Schedule.*"
|
||||
timeout: 20000
|
||||
|
||||
- tapOn: "(?i)Create Order.*Schedule.*"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ORDER TYPE"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "(?i)RAPID.*URGENT.*"
|
||||
@@ -0,0 +1,38 @@
|
||||
# Client App — Create One-Time Order flow
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Home"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 20000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Create Order.*Schedule.*"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Create Order.*Schedule.*"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)One-Time.*Single Event.*"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "(?i)One-Time.*Single Event.*"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)One-Time Order"
|
||||
timeout: 15000
|
||||
|
||||
- assertVisible: "(?i)Create Your Order"
|
||||
- assertVisible: "(?i).*(SELECT VENDOR|Date|HUB|Positions).*"
|
||||
- assertVisible: "(?i)Create Order"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Client App — Create Permanent Order (placeholder/WIP screen)
|
||||
# Validates that Permanent entry is present and navigates to the expected WIP placeholder.
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/create_order_permanent_placeholder.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
# Open Create Order from Home quick action (reliable entry point)
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "Create Order\nSchedule shifts"
|
||||
timeout: 20000
|
||||
- tapOn: "Create Order\nSchedule shifts"
|
||||
|
||||
# Select Permanent order type
|
||||
- extendedWaitUntil:
|
||||
visible: "Permanent\nLong-Term Staffing Placement"
|
||||
timeout: 10000
|
||||
- tapOn: "Permanent\nLong-Term Staffing Placement"
|
||||
|
||||
# Validate Permanent Order screen header (WIP flow)
|
||||
- extendedWaitUntil:
|
||||
visible: "Permanent Order"
|
||||
timeout: 10000
|
||||
- assertVisible: "Permanent Order"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Client App — Create Rapid Order flow (UI smoke)
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Home"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 20000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Create Order.*Schedule.*"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Create Order.*Schedule.*"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)ORDER TYPE"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "(?i)RAPID.*URGENT.*"
|
||||
|
||||
- tapOn: "(?i)RAPID.*URGENT.*"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)RAPID Order"
|
||||
timeout: 15000
|
||||
|
||||
- assertVisible: "(?i)Emergency staffing in minutes"
|
||||
- assertVisible: "(?i).*(Send Message|Speak).*"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Client App — Create Recurring Order (placeholder/WIP screen)
|
||||
# Validates that Recurring entry is present and navigates to the expected WIP placeholder.
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/create_order_recurring_placeholder.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
# Open Create Order from Home quick action (reliable entry point)
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "Create Order\nSchedule shifts"
|
||||
timeout: 20000
|
||||
- tapOn: "Create Order\nSchedule shifts"
|
||||
|
||||
# Select Recurring order type
|
||||
- extendedWaitUntil:
|
||||
visible: "Recurring\nOngoing Weekly / Monthly Coverage"
|
||||
timeout: 10000
|
||||
- tapOn: "Recurring\nOngoing Weekly / Monthly Coverage"
|
||||
|
||||
# Validate Recurring Order screen header (WIP flow)
|
||||
- extendedWaitUntil:
|
||||
visible: "Recurring Order"
|
||||
timeout: 10000
|
||||
- assertVisible: "Recurring Order"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Client App — Order Type selection screen (comprehensive smoke)
|
||||
# Asserts all order types are present with correct accessible labels.
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/orders/order_type_selection_smoke.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Create Order\nSchedule shifts"
|
||||
timeout: 20000
|
||||
- tapOn: "Create Order\nSchedule shifts"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "ORDER TYPE"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "RAPID\nURGENT same-day Coverage"
|
||||
- assertVisible: "One-Time\nSingle Event or Shift Request"
|
||||
- assertVisible: "Recurring\nOngoing Weekly / Monthly Coverage"
|
||||
- assertVisible: "Permanent\nLong-Term Staffing Placement"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Client App — E2E: Reports Insights Verification
|
||||
# Purpose:
|
||||
# - Navigates to Reports.
|
||||
# - Opens Spend Report.
|
||||
# - Verifies that charts and summary cards are loaded.
|
||||
# - Opens No-Show Report.
|
||||
# - Verifies the empty/data states are handled.
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- tapOn: "Home"
|
||||
- tapOn: "Reports"
|
||||
|
||||
# 1. Verify Spend Report
|
||||
- extendedWaitUntil:
|
||||
visible: "Spend Report"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Spend Report"
|
||||
|
||||
# Verify specialized summary cards are visible
|
||||
- extendedWaitUntil:
|
||||
visible: "TOTAL SPEND"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "AVG DAILY COST"
|
||||
|
||||
# Swipe to see historical chart content if needed
|
||||
- swipe:
|
||||
direction: DOWN
|
||||
element: "TOTAL SPEND"
|
||||
|
||||
- assertVisible: "SPEND BY INDUSTRY"
|
||||
|
||||
# Go back
|
||||
- tapOn:
|
||||
id: "back_button" # Or the arrow icon
|
||||
optional: true
|
||||
- tapOn:
|
||||
point: "8% 8%" # Fallback to top-left if ID missing
|
||||
optional: true
|
||||
|
||||
# 2. Verify No-Show Report
|
||||
- extendedWaitUntil:
|
||||
visible: "No-Show Report"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "No-Show Report"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(No-Show|Report).*"
|
||||
timeout: 10000
|
||||
|
||||
# Smoke check for data availability
|
||||
- assertVisible:
|
||||
text: "No show incidents"
|
||||
optional: true
|
||||
|
||||
- tapOn: "Home"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Client App — Reports dashboard
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Reports"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Reports"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Workforce Control Tower"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i)Workforce Control Tower"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Client App — Reports: No-Show Report smoke
|
||||
# Purpose:
|
||||
# - Opens Reports dashboard (Workforce Control Tower)
|
||||
# - Navigates to the No-Show Reports section
|
||||
# - Verifies the no-show report screen or relevant UI elements are visible
|
||||
# - Also verifies empty-state copy if no no-show records exist
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/reports/no_show_report_smoke.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- tapOn: "(?i)Reports"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Workforce Control Tower"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i)Workforce Control Tower"
|
||||
|
||||
# Scroll to find No-Show report entry
|
||||
- scrollUntilVisible:
|
||||
element: "(?i).*(No.Show|No Show|Absence).*"
|
||||
visibilityPercentage: 50
|
||||
timeout: 15000
|
||||
optional: true
|
||||
|
||||
# Tap No-Show report (if visible)
|
||||
- tapOn:
|
||||
text: "(?i).*(No.Show|No Show|Absence).*"
|
||||
optional: true
|
||||
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
|
||||
# Either the report detail loads or we see a placeholder/empty state
|
||||
- assertVisible:
|
||||
text: "(?i).*(No.Show|No Show|Absence|No records|Nothing|Report).*"
|
||||
optional: true
|
||||
|
||||
# Exit assertion — still in Reports context (no crash)
|
||||
- assertVisible: "(?i).*(Workforce Control Tower|Reports|No.Show).*"
|
||||
@@ -0,0 +1,46 @@
|
||||
# Client App — Reports: Spend Report export (smoke)
|
||||
# Purpose:
|
||||
# - Opens Reports dashboard
|
||||
# - Opens Spend Report
|
||||
# - Triggers Export action and verifies placeholder export message
|
||||
#
|
||||
# Note: Export is currently placeholder-driven in UI strings.
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/reports/spend_report_export_smoke.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Reports"
|
||||
- extendedWaitUntil:
|
||||
visible: "Workforce Control Tower"
|
||||
timeout: 10000
|
||||
|
||||
- scrollUntilVisible:
|
||||
element: "Quick Reports"
|
||||
visibilityPercentage: 50
|
||||
timeout: 15000
|
||||
|
||||
- assertVisible: "Quick Reports"
|
||||
- tapOn: "Spend Report"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Spend Report"
|
||||
timeout: 10000
|
||||
|
||||
# Trigger export (may be a placeholder message)
|
||||
- tapOn: "Export"
|
||||
- extendedWaitUntil:
|
||||
visible: "Exporting Spend Report \\(Placeholder\\)"
|
||||
timeout: 10000
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
output.orderName = "E2E-" + Math.random().toString(36).substring(2, 7).toUpperCase();
|
||||
@@ -0,0 +1,29 @@
|
||||
# Client App — Edit Profile (navigates via Settings)
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Home"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 15000
|
||||
|
||||
- tapOn:
|
||||
id: "client_home_settings"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*Profile.*"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i)Profile"
|
||||
- assertVisible: "(?i)Quick Links"
|
||||
- assertVisible: "(?i).*(Log Out|Logout).*"
|
||||
@@ -0,0 +1,80 @@
|
||||
# Client App — E2E: Edit Profile save (with re-open verification)
|
||||
# Purpose:
|
||||
# - Navigates Settings → Profile → Edit Profile
|
||||
# - Saves a small change and verifies success message
|
||||
# - Re-opens Edit Profile to validate the change is visible (basic persistence check)
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/settings/edit_profile_save_e2e.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
# Open Settings via header gear icon (top-right)
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Profile"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit Profile"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "Edit Profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit Profile"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "FIRST NAME"
|
||||
- assertVisible: "LAST NAME"
|
||||
- assertVisible: "PHONE NUMBER"
|
||||
- assertVisible: "Save Changes"
|
||||
|
||||
# Append a suffix to first name (avoids needing to clear the field)
|
||||
- tapOn: "FIRST NAME"
|
||||
- inputText: " QA"
|
||||
- hideKeyboard
|
||||
|
||||
- tapOn: "Save Changes"
|
||||
- extendedWaitUntil:
|
||||
visible: "Profile updated successfully"
|
||||
timeout: 15000
|
||||
|
||||
# Re-open Edit Profile and confirm the suffix is visible somewhere in the form.
|
||||
- launchApp
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "Profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "Edit Profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit Profile"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "QA"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Client App — Settings logout flow
|
||||
# Navigates to Settings via gear icon and logs out.
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/settings/logout_flow.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp
|
||||
|
||||
- tapOn: "Home"
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back"
|
||||
timeout: 15000
|
||||
|
||||
# Open Settings via header gear icon (top-right)
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Quick Links"
|
||||
timeout: 10000
|
||||
|
||||
- assertVisible: "Log Out"
|
||||
- tapOn: "Log Out"
|
||||
|
||||
# Confirm dialog (button label is localized as "Log Out")
|
||||
- extendedWaitUntil:
|
||||
visible: "Cancel"
|
||||
timeout: 10000
|
||||
- tapOn: "Log Out"
|
||||
|
||||
# Post-logout should return to auth entry (e.g. Create Account screen).
|
||||
- extendedWaitUntil:
|
||||
visible: "Create Account"
|
||||
timeout: 20000
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Client App — Settings page
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Home"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 15000
|
||||
|
||||
# Open Settings via header gear icon using stable ID
|
||||
- tapOn:
|
||||
id: "client_home_settings"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*Quick Links.*"
|
||||
timeout: 20000
|
||||
|
||||
- assertVisible: "(?i)Profile"
|
||||
- assertVisible: "(?i)Clock-In Hubs"
|
||||
- assertVisible: "(?i)Billing & Payments"
|
||||
- assertVisible: "(?i).*(Log Out|Logout).*"
|
||||
@@ -0,0 +1,80 @@
|
||||
# Client App — Settings: Edit Profile validation errors (negative path)
|
||||
# Purpose:
|
||||
# - Navigates to Settings → Edit Profile
|
||||
# - Clears a required field (e.g. First Name) and attempts to save
|
||||
# - Verifies that an inline validation error is shown
|
||||
# - Verifies the form does NOT navigate away (no false success)
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/client/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/client/maestro/settings/edit_profile_validation.yaml \
|
||||
# -e TEST_CLIENT_EMAIL=... \
|
||||
# -e TEST_CLIENT_PASSWORD=...
|
||||
|
||||
appId: com.krowwithus.client
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Home|Orders|Coverage|Billing|Reports).*"
|
||||
timeout: 45000
|
||||
|
||||
- tapOn: "(?i)Home"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i).*(Welcome back|Home).*"
|
||||
timeout: 20000
|
||||
|
||||
# Open Settings via header gear icon (top-right)
|
||||
- tapOn:
|
||||
point: "92%,10%"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Profile"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "(?i)Profile"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Edit Profile"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn: "(?i)Edit Profile"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Edit Profile"
|
||||
timeout: 10000
|
||||
|
||||
# Verify required fields are present
|
||||
- assertVisible: "(?i)FIRST NAME"
|
||||
- assertVisible: "(?i)LAST NAME"
|
||||
- assertVisible: "(?i)Save Changes"
|
||||
|
||||
# Clear the FIRST NAME field by selecting all and deleting
|
||||
- tapOn: "(?i)FIRST NAME"
|
||||
- repeat:
|
||||
times: 30
|
||||
commands:
|
||||
- pressKey: Backspace
|
||||
- hideKeyboard
|
||||
|
||||
# Attempt to save with empty first name
|
||||
- tapOn: "(?i)Save Changes"
|
||||
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
|
||||
# Negative assertion: success message must NOT appear
|
||||
- assertNotVisible:
|
||||
text: "(?i)Profile updated successfully"
|
||||
optional: true
|
||||
|
||||
# Positive assertion: validation error appears OR form stays put
|
||||
- assertVisible: "(?i)Edit Profile"
|
||||
|
||||
# Inline error should be visible for the empty required field
|
||||
- assertVisible:
|
||||
text: "(?i).*(required|cannot be empty|must not be|invalid|enter your).*"
|
||||
optional: true
|
||||
@@ -105,7 +105,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
// Use default debug signing for local dev (no keystore required)
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
|
||||
@@ -75,6 +75,11 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new fman.ge.smart_auth.SmartAuthPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin smart_auth, fman.ge.smart_auth.SmartAuthPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -66,6 +66,12 @@
|
||||
@import shared_preferences_foundation;
|
||||
#endif
|
||||
|
||||
#if __has_include(<smart_auth/SmartAuthPlugin.h>)
|
||||
#import <smart_auth/SmartAuthPlugin.h>
|
||||
#else
|
||||
@import smart_auth;
|
||||
#endif
|
||||
|
||||
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
|
||||
#import <url_launcher_ios/URLLauncherPlugin.h>
|
||||
#else
|
||||
@@ -91,6 +97,7 @@
|
||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||
[SmartAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"SmartAuthPlugin"]];
|
||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||
[WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() async {
|
||||
await const BackgroundTaskService().initialize(backgroundGeofenceDispatcher);
|
||||
|
||||
// Register global BLoC observer for centralized error logging
|
||||
Bloc.observer = CoreBlocObserver(
|
||||
Bloc.observer = const CoreBlocObserver(
|
||||
logEvents: true,
|
||||
logStateChanges: false, // Set to true for verbose debugging
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <smart_auth/smart_auth_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
@@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) smart_auth_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin");
|
||||
smart_auth_plugin_register_with_registrar(smart_auth_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
record_linux
|
||||
smart_auth
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import geolocator_apple
|
||||
import package_info_plus
|
||||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
import smart_auth
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@@ -26,5 +27,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maestro Integration Tests — Staff App
|
||||
|
||||
Auth flows for the KROW Staff app.
|
||||
See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) and [maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md).
|
||||
Auth flows and E2E happy paths for the KROW Staff app.
|
||||
See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md), [maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md), and [docs/testing/maestro-e2e-happy-paths.md](/docs/testing/maestro-e2e-happy-paths.md) (#572).
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -10,6 +10,49 @@ maestro/
|
||||
auth/
|
||||
sign_in.yaml
|
||||
sign_up.yaml
|
||||
sign_out.yaml
|
||||
sign_in_invalid_otp.yaml
|
||||
navigation/
|
||||
home.yaml
|
||||
shifts.yaml
|
||||
profile.yaml
|
||||
payments.yaml
|
||||
clock_in.yaml
|
||||
availability.yaml
|
||||
profile/
|
||||
personal_info.yaml
|
||||
documents_list.yaml
|
||||
certificates_list.yaml
|
||||
time_card.yaml
|
||||
time_card_detail_smoke.yaml
|
||||
bank_account.yaml
|
||||
bank_account_fields_smoke.yaml
|
||||
tax_forms.yaml
|
||||
tax_forms_smoke.yaml
|
||||
faqs.yaml
|
||||
privacy_security.yaml
|
||||
emergency_contact.yaml
|
||||
attire.yaml
|
||||
attire_validation_e2e.yaml
|
||||
compliance/
|
||||
document_upload_banner.yaml # #550
|
||||
certificate_upload_banner.yaml # #551
|
||||
attire_upload_banner.yaml # #552
|
||||
document_upload_e2e.yaml
|
||||
certificate_upload_e2e.yaml
|
||||
shifts/
|
||||
find_shifts.yaml
|
||||
find_shifts_apply_smoke.yaml
|
||||
clock_in_e2e.yaml
|
||||
clock_out_e2e.yaml
|
||||
incomplete_profile_banner.yaml # #549 (requires incomplete-profile user)
|
||||
availability/
|
||||
set_availability_e2e.yaml
|
||||
payments/
|
||||
payments_view_e2e.yaml
|
||||
payment_history_smoke.yaml
|
||||
home/
|
||||
benefits.yaml # #524
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
@@ -24,11 +67,20 @@ maestro/
|
||||
| sign_in | `TEST_STAFF_PHONE`, `TEST_STAFF_OTP` |
|
||||
| sign_up | `TEST_STAFF_SIGNUP_PHONE`, `TEST_STAFF_OTP` |
|
||||
|
||||
**Sign-in:** +1 555-555-1234 (env: 5555551234) / 123123
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Via Makefile (export vars first)
|
||||
make test-e2e-staff
|
||||
make test-e2e-staff # Auth only
|
||||
make test-e2e-staff-extended # Auth + nav + profile + compliance + shifts + benefits
|
||||
make test-e2e-staff-happy-path # Auth + clock in/out + availability + document upload + payments + sign out (#572)
|
||||
make test-e2e-staff-smoke # Deterministic smoke suite
|
||||
make test-e2e-staff-profile-smoke # Profile smoke (timecard/bank/tax/attire validation)
|
||||
make test-e2e-staff-payments-smoke # Payments smoke (earnings history)
|
||||
make test-e2e-staff-shifts-smoke # Shifts smoke (find shifts; optionally apply)
|
||||
make test-e2e-staff-compliance-e2e # Compliance E2E (document + certificate uploads)
|
||||
|
||||
# Direct
|
||||
maestro test apps/mobile/apps/staff/maestro/auth/sign_in.yaml \
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Staff App — E2E: Session Persistence Across Relaunch
|
||||
# Purpose:
|
||||
# - Log in via sign_in.yaml
|
||||
# - Stop the app
|
||||
# - Relaunch and verify user is still logged in (bypass login screen)
|
||||
#
|
||||
# Run:
|
||||
# maestro test \
|
||||
# apps/mobile/apps/staff/maestro/auth/sign_in.yaml \
|
||||
# apps/mobile/apps/staff/maestro/auth/session_persistence.yaml \
|
||||
# -e TEST_STAFF_PHONE=... \
|
||||
# -e TEST_STAFF_OTP=...
|
||||
|
||||
appId: com.krowwithus.staff
|
||||
---
|
||||
# We rely on sign_in.yaml being run before this to establish a session.
|
||||
- launchApp
|
||||
|
||||
# If we are logged in, Home/Shifts content should be visible directly.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?i).*(Home|Shifts|Welcome back).*"
|
||||
timeout: 15000
|
||||
|
||||
# Perform a full stop to clear memory (not just backgrounding)
|
||||
- stopApp
|
||||
|
||||
# Relaunch - should NOT show the login screen
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?i).*(Home|Shifts|Welcome back).*"
|
||||
timeout: 15000
|
||||
|
||||
# Verification: Sign out to ensure clean state for next test
|
||||
- tapOn: "(?i)Profile"
|
||||
- scrollUntilVisible:
|
||||
element: "(?i).*(Log Out|Sign Out).*"
|
||||
visibilityPercentage: 50
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?i).*(Log Out|Sign Out).*"
|
||||
|
||||
# Confirm Sign Out
|
||||
- tapOn:
|
||||
text: "(?i).*(Yes|Confirm).*(Log Out|Sign Out).*"
|
||||
optional: true
|
||||
|
||||
# Should return to the login landing page
|
||||
- extendedWaitUntil:
|
||||
visible: "(?i)Log In"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?i)Log In"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user