Compare commits

...

32 Commits

Author SHA1 Message Date
1d28c13c4b Resolved README conflict 2026-04-07 12:34:25 +05:30
510fba47b5 Initial commit 2026-04-07 12:31:22 +05:30
39263a4af5 chore: fix 273+ analysis issues and repair corrupted core files 2026-03-20 21:05:23 +05:30
d3159bc2ae Merge branch 'origin/dev' into feature/session-persistence-new 2026-03-20 12:44:25 +05:30
Achintha Isuru
0735e3513c Merge pull request #673 from Oloodi/595-sred-research-implement-cross-platform-nfc-clocking-interface-fe
feat: Integrate staff order booking, UTC timestamp handling, and shift UI improvements
2026-03-20 00:01:35 -04:00
Achintha Isuru
b10ef57d37 feat: Add hourly rate field to order position arguments and update related blocs 2026-03-19 23:59:00 -04:00
Achintha Isuru
1e4c8982a5 feat: Add additional fields to OrderItem and update cost calculation in ViewOrderCard 2026-03-19 23:55:57 -04:00
Achintha Isuru
4cd83a9281 feat: Implement UTC conversion for order date and time serialization in order use cases 2026-03-19 23:34:29 -04:00
Achintha Isuru
207831eb3e feat: Adjust layout in ShiftCardBody to improve icon and title alignment 2026-03-19 21:15:52 -04:00
Achintha Isuru
0ff2949c1e feat: Refactor ShiftCard components to include client name and improve layout consistency 2026-03-19 21:13:35 -04:00
Achintha Isuru
591b5d7b88 feat: Enhance CancelledShift and PendingAssignment models with additional fields for client and pay details 2026-03-19 16:44:54 -04:00
Achintha Isuru
a544b051cc feat: Update shift card styles and remove cancellation reason display 2026-03-19 16:40:07 -04:00
230942e776 dev merge 2026-03-19 15:25:37 +05:30
e671827dc4 Merge dev into feature branch 2026-03-19 13:16:04 +05:30
67df7957c6 Merge branch 'dev' of https://github.com/Oloodi/krow-workforce into feature/session-persistence-new 2026-03-19 10:27:42 +05:30
aef4491f97 Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new 2026-03-18 12:51:23 +05:30
ca927fd05e Merge remote-tracking branch 'origin/dev' into feature/session-persistence-new 2026-03-18 12:28:39 +05:30
9a44eed2a6 Merge remote-tracking branch 'origin/dev' into 592-migrate-frontend-applications-to-v2-backend-and-database 2026-03-18 11:53:58 +05:30
2b498607d1 Merge branch 'dev' into feature/session-persistence-new 2026-03-17 18:40:22 +05:30
68b0055cfe comprehensive cases 2026-03-17 15:24:36 +05:30
e3d8d30b1b Merge branch 'dev' into feature/session-persistence-new 2026-03-11 10:56:48 +05:30
9642eb0268 ci: resolve merge conflicts and enforce manual trigger policy across all workflows 2026-03-11 10:55:34 +05:30
41a222ea11 fix 2026-03-11 10:46:37 +05:30
b9574eb96a #636 2026-03-10 14:59:31 +05:30
52bb1d1af4 maestro improvise 2026-03-09 22:10:40 +05:30
91cedf54c9 testcases 2026-03-06 17:19:12 +05:30
b43e28e09d Implement Maestro E2E Tests for Mobile Happy Paths & Document #572 2026-03-05 17:50:46 +05:30
9c07bd7e0e new testcases 2026-03-04 19:24:13 +05:30
277eff3da0 Merge dev into feature/session-persistence-new 2026-03-04 10:19:28 +05:30
7ce837b49a maestra new cases 2026-03-03 17:07:41 +05:30
e7e11771b3 Merge branch 'dev' of https://github.com/Oloodi/krow-workforce into feature/session-persistence-new 2026-03-03 11:07:15 +05:30
c0a69707e6 maestro cases 2026-03-02 19:18:35 +05:30
271 changed files with 9871 additions and 1153 deletions

36
.env Normal file
View 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

View File

@@ -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
View 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 }}"

View File

@@ -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

View File

@@ -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
View 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
View 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
View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
# App Core Package

Binary file not shown.

View File

@@ -0,0 +1 @@
# Config Package

Binary file not shown.

Binary file not shown.

View 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
View File

@@ -0,0 +1 @@
# Database Package

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
app_core/db/database.py Normal file
View 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
View 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())

View 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."

View 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())

View 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
]

View 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

View 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

Binary file not shown.

Binary file not shown.

37
app_core/ui/auth_ui.py Normal file
View 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
View 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)

View File

@@ -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
);

View File

@@ -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 \

View File

@@ -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"

View 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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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.*"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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).*"

View File

@@ -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).*"

View 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).*"

View 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).*"

View File

@@ -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"

View File

@@ -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

View File

@@ -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.*"

View File

@@ -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).*"

View File

@@ -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"

View File

@@ -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

View File

@@ -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).*"

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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.*"

View File

@@ -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"

View File

@@ -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.*"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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).*"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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).*"

View File

@@ -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

View File

@@ -0,0 +1 @@
output.orderName = "E2E-" + Math.random().toString(36).substring(2, 7).toUpperCase();

View File

@@ -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).*"

View File

@@ -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"

View File

@@ -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

View File

@@ -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).*"

View File

@@ -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

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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"]];
}

View File

@@ -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
);

View File

@@ -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);

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
record_linux
smart_auth
url_launcher_linux
)

View File

@@ -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"))
}

View File

@@ -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 \

View File

@@ -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