diff --git a/e2e/critical-flows/auth.spec.ts b/e2e/critical-flows/auth.spec.ts new file mode 100644 index 0000000..f4d893c --- /dev/null +++ b/e2e/critical-flows/auth.spec.ts @@ -0,0 +1,313 @@ +/** + * Authentication Flow E2E Tests + * + * Critical flows tested: + * 1. Login page loads correctly + * 2. Email validation + * 3. OTP screen navigation + * 4. OTP input and verification + * 5. Wrong OTP handling + * 6. Resend OTP cooldown + * 7. Back navigation from OTP + * 8. Full login flow with bypass OTP + */ + +import { test, expect } from '@playwright/test'; +import { + LoginPage, + OtpPage, + EnterNamePage, + BeneficiariesPage, +} from '../helpers/page-objects'; +import { + enableConsoleLogging, + waitForAppLoad, + TEST_CREDENTIALS, +} from '../helpers/test-helpers'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Authentication Flow', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + }); + + test('1. Login page loads with all elements', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Verify all required elements + await expect(loginPage.emailInput).toBeVisible(); + await expect(loginPage.continueButton).toBeVisible(); + await expect(loginPage.inviteCodeLink).toBeVisible(); + + console.log('✅ Login page loaded correctly'); + }); + + test('2. Empty email shows validation', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Try to submit with empty email + await loginPage.submit(); + + // Should stay on login page + await page.waitForTimeout(1000); + await expect(loginPage.welcomeText).toBeVisible(); + + console.log('✅ Empty email validation works'); + }); + + test('3. Invalid email format shows validation', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Try invalid email + await loginPage.enterEmail(TEST_CREDENTIALS.invalid.email); + await loginPage.submit(); + + // Should stay on login page or show error + await page.waitForTimeout(1000); + await expect(loginPage.welcomeText).toBeVisible(); + + console.log('✅ Invalid email format handled'); + }); + + test('4. Valid email navigates to OTP screen', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Enter valid email + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + + // Should navigate to OTP screen + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + + // Verify email is shown on OTP screen + await expect(page.getByText(TEST_CREDENTIALS.existingUser.email)).toBeVisible(); + + console.log('✅ OTP screen appears for valid email'); + }); + + test('5. OTP screen has all required elements', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + + // Verify all OTP screen elements + await expect(otpPage.verifyButton).toBeVisible(); + await expect(otpPage.resendButton).toBeVisible(); + await expect(otpPage.useDifferentEmailLink).toBeVisible(); + + console.log('✅ OTP screen has all elements'); + }); + + test('6. OTP input accepts keyboard input', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + await loginPage.loginWithEmail('keyboard.test@example.com'); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + + // Type OTP code + await otpPage.enterCode('123456'); + + // Wait for auto-submit or verify button should be clickable + await page.waitForTimeout(1000); + + console.log('✅ OTP input accepts keyboard input'); + }); + + test('7. Wrong OTP shows error and stays on page', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + await loginPage.loginWithEmail('wrong.otp@example.com'); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + + // Enter wrong OTP + await otpPage.enterCode(TEST_CREDENTIALS.invalid.wrongOtp); + + // Wait for auto-submit + await page.waitForTimeout(3000); + + // Should show error or stay on OTP page + const stillOnOtp = await otpPage.headerText.isVisible({ timeout: 2000 }).catch(() => false); + expect(stillOnOtp).toBe(true); + + console.log('✅ Wrong OTP handled correctly'); + }); + + test('8. Resend shows cooldown timer', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + await loginPage.loginWithEmail('resend.test@example.com'); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + + // Click resend + await otpPage.resend(); + + // Should show cooldown timer + await otpPage.expectResendCooldown(); + + console.log('✅ Resend cooldown works'); + }); + + test('9. Back to login clears state', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + await loginPage.loginWithEmail('back.test@example.com'); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + + // Go back to login + await otpPage.goBackToLogin(); + + // Should be on login page again + await loginPage.expectLoaded(); + + console.log('✅ Back navigation works'); + }); + + test('10. Full login flow with bypass OTP', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Enter email + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + + // Wait for OTP screen + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + + // Enter bypass OTP + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + + // Wait for navigation after OTP verification + await page.waitForTimeout(3000); + + // Should be on one of: dashboard, enter-name, add-loved-one, or beneficiaries + const isLoggedIn = await Promise.race([ + page.getByText('My Loved Ones').isVisible({ timeout: 5000 }).catch(() => false), + page.getByText('Dashboard').isVisible({ timeout: 5000 }).catch(() => false), + page.getByText('What should we call you?').isVisible({ timeout: 5000 }).catch(() => false), + page.getByText(/Add.*Loved One/i).isVisible({ timeout: 5000 }).catch(() => false), + ]); + + // At minimum, should not be on login or OTP page + const stillOnAuth = await loginPage.welcomeText.isVisible({ timeout: 1000 }).catch(() => false) || + await otpPage.headerText.isVisible({ timeout: 1000 }).catch(() => false); + + expect(stillOnAuth).toBe(false); + + console.log('✅ Full login flow completed'); + }); + + test('11. Rate limiting protection', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Make multiple rapid requests + for (let i = 0; i < 3; i++) { + await loginPage.enterEmail(`rate.limit.${i}@test.com`); + await loginPage.submit(); + await page.waitForTimeout(500); + await page.goto(loginPage.page.url()); // Reset + await page.waitForLoadState('networkidle'); + } + + // App should still be responsive + await expect(loginPage.welcomeText).toBeVisible({ timeout: 10000 }); + + console.log('✅ Rate limiting handled gracefully'); + }); +}); + +test.describe('New User Onboarding Flow', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + }); + + test('New user is prompted for name after first login', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Use a unique email that likely doesn't exist + const newEmail = `new.user.${Date.now()}@test.com`; + await loginPage.loginWithEmail(newEmail); + + // Wait for OTP screen + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + + // Enter bypass OTP + await otpPage.enterCode(TEST_CREDENTIALS.newUser.bypassOtp); + + // Wait for navigation + await page.waitForTimeout(3000); + + // New user should see either enter-name or add-loved-one screen + const isNewUserFlow = await Promise.race([ + page.getByText('What should we call you?').isVisible({ timeout: 5000 }).catch(() => false), + page.getByText(/Add.*Loved One/i).isVisible({ timeout: 5000 }).catch(() => false), + ]); + + console.log(`New user flow detected: ${isNewUserFlow}`); + console.log('✅ New user onboarding flow works'); + }); + + test('Enter name form validates input', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // This test assumes we can get to enter-name screen + // In real scenario, would use a known new user or mock + const newEmail = `name.test.${Date.now()}@test.com`; + await loginPage.loginWithEmail(newEmail); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.newUser.bypassOtp); + + await page.waitForTimeout(3000); + + // If on enter-name screen, test validation + const enterNamePage = new EnterNamePage(page); + const isOnEnterName = await enterNamePage.headerText.isVisible({ timeout: 2000 }).catch(() => false); + + if (isOnEnterName) { + // Try to submit without name + await enterNamePage.submit(); + await page.waitForTimeout(500); + + // Should still be on enter-name screen + await expect(enterNamePage.headerText).toBeVisible(); + + console.log('✅ Enter name validation works'); + } else { + console.log('⚠️ Not on enter-name screen, skipping validation test'); + } + }); +}); diff --git a/e2e/critical-flows/beneficiary.spec.ts b/e2e/critical-flows/beneficiary.spec.ts new file mode 100644 index 0000000..a20b660 --- /dev/null +++ b/e2e/critical-flows/beneficiary.spec.ts @@ -0,0 +1,375 @@ +/** + * Beneficiary Management E2E Tests + * + * Critical flows tested: + * 1. Beneficiary list loads correctly + * 2. Beneficiary detail page navigation + * 3. Add new beneficiary flow + * 4. Beneficiary card displays correct info + * 5. Equipment/sensors access + * 6. Subscription access + * 7. Share functionality access + */ + +import { test, expect } from '@playwright/test'; +import { + LoginPage, + OtpPage, + BeneficiariesPage, + BeneficiaryDetailPage, + EquipmentPage, + SubscriptionPage, + AddLovedOnePage, +} from '../helpers/page-objects'; +import { + enableConsoleLogging, + TEST_CREDENTIALS, + BASE_URL, +} from '../helpers/test-helpers'; + +// Run tests serially to maintain login state +test.describe.configure({ mode: 'serial' }); + +test.describe('Beneficiary Management', () => { + // Login once before all tests + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + + // Wait for successful login + await page.waitForTimeout(5000); + await page.close(); + }); + + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + + // Login for each test + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Check if already logged in (redirected to app) + const isOnLogin = await loginPage.welcomeText.isVisible({ timeout: 2000 }).catch(() => false); + + if (isOnLogin) { + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + await page.waitForTimeout(3000); + } + }); + + test('1. Beneficiary list page loads', async ({ page }) => { + // Navigate to beneficiaries + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + // Should see beneficiaries or dashboard + const hasBeneficiaries = await page.getByText('My Loved Ones').isVisible({ timeout: 5000 }).catch(() => false); + const hasDashboard = await page.getByText('Dashboard').isVisible({ timeout: 5000 }).catch(() => false); + + expect(hasBeneficiaries || hasDashboard).toBe(true); + + console.log('✅ Beneficiary list/dashboard loaded'); + }); + + test('2. Beneficiary cards display correctly', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Find beneficiary cards + const beneficiaryCards = page.locator('[data-testid="beneficiary-card"]'); + const cardsCount = await beneficiaryCards.count(); + + // If no beneficiaries, should show add option + if (cardsCount === 0) { + const addButton = await page.getByText(/Add.*Loved One|Add Beneficiary/i).isVisible({ timeout: 2000 }); + console.log(`No beneficiaries found, add button visible: ${addButton}`); + } else { + console.log(`Found ${cardsCount} beneficiary cards`); + } + + console.log('✅ Beneficiary cards displayed'); + }); + + test('3. Navigate to beneficiary detail', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Find and click on a beneficiary + const beneficiaryCards = page.locator('[data-testid="beneficiary-card"]'); + const cardsCount = await beneficiaryCards.count(); + + if (cardsCount > 0) { + await beneficiaryCards.first().click(); + await page.waitForTimeout(2000); + + // Should be on detail page + const detailPage = new BeneficiaryDetailPage(page); + await detailPage.expectLoaded(); + + console.log('✅ Beneficiary detail page loaded'); + } else { + // Try clicking on beneficiary name directly + const firstBeneficiary = page.locator('div').filter({ hasText: /Grandma|Mom|Dad|Test/i }).first(); + if (await firstBeneficiary.isVisible({ timeout: 2000 })) { + await firstBeneficiary.click(); + await page.waitForTimeout(2000); + } + + console.log('⚠️ No beneficiary cards found with testid'); + } + }); + + test('4. Beneficiary detail shows menu options', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Navigate to a beneficiary + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const detailPage = new BeneficiaryDetailPage(page); + + // Check for key menu options + const hasSubscription = await detailPage.subscriptionButton.isVisible({ timeout: 2000 }).catch(() => false); + const hasEquipment = await detailPage.equipmentButton.isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Subscription visible: ${hasSubscription}, Equipment visible: ${hasEquipment}`); + + expect(hasSubscription || hasEquipment).toBe(true); + + console.log('✅ Beneficiary menu options displayed'); + } else { + console.log('⚠️ No beneficiary to test'); + } + }); + + test('5. Navigate to equipment/sensors page', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Navigate to a beneficiary + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const detailPage = new BeneficiaryDetailPage(page); + + // Go to equipment + if (await detailPage.equipmentButton.isVisible({ timeout: 2000 })) { + await detailPage.goToEquipment(); + await page.waitForTimeout(2000); + + const equipmentPage = new EquipmentPage(page); + await equipmentPage.expectLoaded(); + + console.log('✅ Equipment page loaded'); + } else { + console.log('⚠️ Equipment button not visible'); + } + } else { + console.log('⚠️ No beneficiary to test'); + } + }); + + test('6. Navigate to subscription page', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Navigate to a beneficiary + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const detailPage = new BeneficiaryDetailPage(page); + + // Go to subscription + if (await detailPage.subscriptionButton.isVisible({ timeout: 2000 })) { + await detailPage.goToSubscription(); + await page.waitForTimeout(2000); + + const subscriptionPage = new SubscriptionPage(page); + await subscriptionPage.expectLoaded(); + + console.log('✅ Subscription page loaded'); + } else { + console.log('⚠️ Subscription button not visible'); + } + } else { + console.log('⚠️ No beneficiary to test'); + } + }); + + test('7. Equipment page shows sensor summary', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Navigate to beneficiary -> equipment + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const detailPage = new BeneficiaryDetailPage(page); + if (await detailPage.equipmentButton.isVisible({ timeout: 2000 })) { + await detailPage.goToEquipment(); + await page.waitForTimeout(2000); + + const equipmentPage = new EquipmentPage(page); + + // Check for summary elements + const hasTotal = await equipmentPage.totalSensors.isVisible({ timeout: 2000 }).catch(() => false); + const hasEmpty = await equipmentPage.emptySensorState.isVisible({ timeout: 2000 }).catch(() => false); + + expect(hasTotal || hasEmpty).toBe(true); + + console.log('✅ Equipment summary displayed'); + } + } + }); + + test('8. Add sensor button is visible on equipment page', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Navigate to beneficiary -> equipment + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const detailPage = new BeneficiaryDetailPage(page); + if (await detailPage.equipmentButton.isVisible({ timeout: 2000 })) { + await detailPage.goToEquipment(); + await page.waitForTimeout(2000); + + const equipmentPage = new EquipmentPage(page); + + // Add sensor button should be visible + const hasAddButton = await equipmentPage.addSensorsButton.isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Add sensors button visible: ${hasAddButton}`); + + console.log('✅ Add sensor functionality accessible'); + } + } + }); +}); + +test.describe('Add Beneficiary Flow', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + }); + + test('Add loved one screen loads for new users', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Use new unique email + const newEmail = `add.beneficiary.${Date.now()}@test.com`; + await loginPage.loginWithEmail(newEmail); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.newUser.bypassOtp); + + await page.waitForTimeout(5000); + + // Should eventually reach add-loved-one screen for new users + const addLovedOnePage = new AddLovedOnePage(page); + const isOnAddPage = await addLovedOnePage.headerText.isVisible({ timeout: 5000 }).catch(() => false); + + console.log(`Add loved one screen visible: ${isOnAddPage}`); + console.log('✅ Add beneficiary flow accessible'); + }); +}); + +test.describe('Beneficiary Status Display', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + + // Login + const loginPage = new LoginPage(page); + await loginPage.goto(); + const isOnLogin = await loginPage.welcomeText.isVisible({ timeout: 2000 }).catch(() => false); + + if (isOnLogin) { + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + await page.waitForTimeout(3000); + } + }); + + test('Subscription status is displayed on detail page', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + // Check for subscription status indicator + const hasActiveStatus = await page.getByText(/Active|Demo|Trial|Expired/i).isVisible({ timeout: 2000 }).catch(() => false); + const hasSubscriptionButton = await page.getByText('Subscription').isVisible({ timeout: 2000 }).catch(() => false); + + expect(hasActiveStatus || hasSubscriptionButton).toBe(true); + + console.log('✅ Subscription status displayed'); + } + }); + + test('Equipment status is displayed correctly', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + // Navigate to equipment + const equipmentButton = page.getByText(/Equipment|Sensors/i); + if (await equipmentButton.isVisible({ timeout: 2000 })) { + await equipmentButton.click(); + await page.waitForTimeout(2000); + + // Check for status displays + const hasOnline = await page.getByText(/Online/i).isVisible({ timeout: 2000 }).catch(() => false); + const hasOffline = await page.getByText(/Offline/i).isVisible({ timeout: 2000 }).catch(() => false); + const hasTotal = await page.getByText(/Total/i).isVisible({ timeout: 2000 }).catch(() => false); + const hasEmpty = await page.getByText('No Sensors Connected').isVisible({ timeout: 2000 }).catch(() => false); + + expect(hasOnline || hasOffline || hasTotal || hasEmpty).toBe(true); + + console.log('✅ Equipment status displayed correctly'); + } + } + }); +}); diff --git a/e2e/critical-flows/profile.spec.ts b/e2e/critical-flows/profile.spec.ts new file mode 100644 index 0000000..a3f726e --- /dev/null +++ b/e2e/critical-flows/profile.spec.ts @@ -0,0 +1,386 @@ +/** + * Profile Management E2E Tests + * + * Critical flows tested: + * 1. Profile page loads correctly + * 2. Edit profile navigation + * 3. Profile settings access + * 4. Logout functionality + * 5. Notification settings + * 6. Language settings + * 7. Help and About pages + */ + +import { test, expect } from '@playwright/test'; +import { + LoginPage, + OtpPage, + ProfilePage, +} from '../helpers/page-objects'; +import { + enableConsoleLogging, + TEST_CREDENTIALS, + BASE_URL, +} from '../helpers/test-helpers'; + +// Run tests serially +test.describe.configure({ mode: 'serial' }); + +test.describe('Profile Page', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + + // Login + const loginPage = new LoginPage(page); + await loginPage.goto(); + const isOnLogin = await loginPage.welcomeText.isVisible({ timeout: 2000 }).catch(() => false); + + if (isOnLogin) { + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + await page.waitForTimeout(3000); + } + }); + + test('1. Profile page loads correctly', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Navigate to profile (bottom tab or menu) + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const profilePage = new ProfilePage(page); + await profilePage.expectLoaded(); + + console.log('✅ Profile page loaded'); + } else { + // Try menu icon + const menuIcon = page.locator('[data-testid="profile-menu"]'); + if (await menuIcon.isVisible({ timeout: 2000 })) { + await menuIcon.click(); + await page.waitForTimeout(2000); + } + } + }); + + test('2. Profile shows user information', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + // Check for user info display + const hasEmail = await page.getByText(/@/).isVisible({ timeout: 2000 }).catch(() => false); + const hasEditOption = await page.getByText(/Edit/i).isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Email visible: ${hasEmail}, Edit visible: ${hasEditOption}`); + + console.log('✅ Profile info displayed'); + } + }); + + test('3. Edit profile navigation works', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const profilePage = new ProfilePage(page); + + if (await profilePage.editButton.isVisible({ timeout: 2000 })) { + await profilePage.editProfile(); + await page.waitForTimeout(2000); + + // Should be on edit profile page + const hasNameInput = await page.getByPlaceholder(/name/i).isVisible({ timeout: 3000 }).catch(() => false); + const hasPhoneInput = await page.getByPlaceholder(/phone/i).isVisible({ timeout: 3000 }).catch(() => false); + + expect(hasNameInput || hasPhoneInput).toBe(true); + + console.log('✅ Edit profile page loaded'); + } else { + console.log('⚠️ Edit button not visible'); + } + } + }); + + test('4. Notification settings accessible', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const profilePage = new ProfilePage(page); + + if (await profilePage.notificationsItem.isVisible({ timeout: 2000 })) { + await profilePage.goToNotifications(); + await page.waitForTimeout(2000); + + // Should be on notifications settings + const hasToggle = await page.locator('input[type="checkbox"], [role="switch"]').isVisible({ timeout: 2000 }).catch(() => false); + const hasNotificationText = await page.getByText(/notification|push|alert/i).isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Toggle: ${hasToggle}, Text: ${hasNotificationText}`); + console.log('✅ Notifications settings accessible'); + } else { + console.log('⚠️ Notifications item not visible'); + } + } + }); + + test('5. Language settings accessible', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const profilePage = new ProfilePage(page); + + if (await profilePage.languageItem.isVisible({ timeout: 2000 })) { + await profilePage.goToLanguage(); + await page.waitForTimeout(2000); + + // Should be on language settings + const hasLanguageOptions = await page.getByText(/English|Русский|Spanish/i).isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Language options: ${hasLanguageOptions}`); + console.log('✅ Language settings accessible'); + } else { + console.log('⚠️ Language item not visible'); + } + } + }); + + test('6. Help page accessible', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const profilePage = new ProfilePage(page); + + if (await profilePage.helpItem.isVisible({ timeout: 2000 })) { + await profilePage.goToHelp(); + await page.waitForTimeout(2000); + + // Should be on help page + const hasHelpContent = await page.getByText(/FAQ|Support|Contact|Help/i).isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Help content: ${hasHelpContent}`); + console.log('✅ Help page accessible'); + } else { + console.log('⚠️ Help item not visible'); + } + } + }); + + test('7. About page accessible', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const profilePage = new ProfilePage(page); + + if (await profilePage.aboutItem.isVisible({ timeout: 2000 })) { + await profilePage.goToAbout(); + await page.waitForTimeout(2000); + + // Should be on about page + const hasVersion = await page.getByText(/Version|WellNuo|About/i).isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Version info: ${hasVersion}`); + console.log('✅ About page accessible'); + } else { + console.log('⚠️ About item not visible'); + } + } + }); + + test('8. Logout button visible', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const profilePage = new ProfilePage(page); + + // Scroll down to find logout button + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(500); + + const hasLogout = await profilePage.logoutButton.isVisible({ timeout: 3000 }).catch(() => false); + expect(hasLogout).toBe(true); + + console.log('✅ Logout button visible'); + } + }); +}); + +test.describe('Logout Flow', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + const isOnLogin = await loginPage.welcomeText.isVisible({ timeout: 2000 }).catch(() => false); + + if (isOnLogin) { + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + await page.waitForTimeout(3000); + } + }); + + test('Logout clears session and returns to login', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const profilePage = new ProfilePage(page); + + // Scroll to logout + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(500); + + if (await profilePage.logoutButton.isVisible({ timeout: 3000 })) { + await profilePage.logout(); + await page.waitForTimeout(3000); + + // Should be back on login page + const loginPage = new LoginPage(page); + await loginPage.expectLoaded(); + + console.log('✅ Logout successful, returned to login'); + } else { + console.log('⚠️ Logout button not found'); + } + } + }); +}); + +test.describe('Profile Edit Flow', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + const isOnLogin = await loginPage.welcomeText.isVisible({ timeout: 2000 }).catch(() => false); + + if (isOnLogin) { + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + await page.waitForTimeout(3000); + } + }); + + test('Profile edit form has required fields', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const editButton = page.getByText('Edit Profile'); + if (await editButton.isVisible({ timeout: 2000 })) { + await editButton.click(); + await page.waitForTimeout(2000); + + // Check for form fields + const hasFirstName = await page.getByPlaceholder(/first.*name/i).isVisible({ timeout: 2000 }).catch(() => false); + const hasLastName = await page.getByPlaceholder(/last.*name/i).isVisible({ timeout: 2000 }).catch(() => false); + const hasPhone = await page.getByPlaceholder(/phone/i).isVisible({ timeout: 2000 }).catch(() => false); + const hasSaveButton = await page.getByText(/Save|Update/i).isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`First: ${hasFirstName}, Last: ${hasLastName}, Phone: ${hasPhone}, Save: ${hasSaveButton}`); + + expect(hasFirstName || hasLastName || hasPhone).toBe(true); + + console.log('✅ Profile edit form has fields'); + } + } + }); + + test('Profile edit validates input', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const profileTab = page.getByText('Profile').first(); + if (await profileTab.isVisible({ timeout: 3000 })) { + await profileTab.click(); + await page.waitForTimeout(2000); + + const editButton = page.getByText('Edit Profile'); + if (await editButton.isVisible({ timeout: 2000 })) { + await editButton.click(); + await page.waitForTimeout(2000); + + // Clear name field + const nameInput = page.getByPlaceholder(/first.*name/i); + if (await nameInput.isVisible({ timeout: 2000 })) { + await nameInput.clear(); + + // Try to save + const saveButton = page.getByText(/Save|Update/i); + if (await saveButton.isVisible({ timeout: 2000 })) { + await saveButton.click(); + await page.waitForTimeout(1000); + + // Should show validation or stay on edit page + const stillOnEdit = await nameInput.isVisible({ timeout: 2000 }); + expect(stillOnEdit).toBe(true); + + console.log('✅ Profile edit validates input'); + } + } + } + } + }); +}); diff --git a/e2e/critical-flows/subscription.spec.ts b/e2e/critical-flows/subscription.spec.ts new file mode 100644 index 0000000..04db329 --- /dev/null +++ b/e2e/critical-flows/subscription.spec.ts @@ -0,0 +1,314 @@ +/** + * Subscription & Purchase Flow E2E Tests + * + * Critical flows tested: + * 1. Subscription page loads correctly + * 2. Subscription status display + * 3. Subscribe button functionality + * 4. Stripe payment sheet opens (if available) + * 5. Demo mode activation + * 6. Subscription management + */ + +import { test, expect } from '@playwright/test'; +import { + LoginPage, + OtpPage, + BeneficiaryDetailPage, + SubscriptionPage, +} from '../helpers/page-objects'; +import { + enableConsoleLogging, + TEST_CREDENTIALS, + BASE_URL, +} from '../helpers/test-helpers'; + +// Run tests serially +test.describe.configure({ mode: 'serial' }); + +test.describe('Subscription Flow', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + + // Login + const loginPage = new LoginPage(page); + await loginPage.goto(); + const isOnLogin = await loginPage.welcomeText.isVisible({ timeout: 2000 }).catch(() => false); + + if (isOnLogin) { + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + await page.waitForTimeout(3000); + } + }); + + test('1. Subscription page loads correctly', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Navigate to beneficiary + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + // Navigate to subscription + const subscriptionButton = page.getByText('Subscription'); + if (await subscriptionButton.isVisible({ timeout: 2000 })) { + await subscriptionButton.click(); + await page.waitForTimeout(2000); + + const subscriptionPage = new SubscriptionPage(page); + await subscriptionPage.expectLoaded(); + + console.log('✅ Subscription page loaded'); + } else { + console.log('⚠️ Subscription button not visible'); + } + } else { + console.log('⚠️ No beneficiary to test'); + } + }); + + test('2. Subscription shows current status', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const subscriptionButton = page.getByText('Subscription'); + if (await subscriptionButton.isVisible({ timeout: 2000 })) { + await subscriptionButton.click(); + await page.waitForTimeout(2000); + + const subscriptionPage = new SubscriptionPage(page); + + // Check for status indicators + const isActive = await subscriptionPage.isSubscriptionActive(); + const isDemo = await subscriptionPage.isDemoMode(); + const hasSubscribeButton = await subscriptionPage.subscribeButton.isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Active: ${isActive}, Demo: ${isDemo}, Subscribe button: ${hasSubscribeButton}`); + + // At least one status should be visible + expect(isActive || isDemo || hasSubscribeButton).toBe(true); + + console.log('✅ Subscription status displayed'); + } + } + }); + + test('3. Subscribe button is clickable', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const subscriptionButton = page.getByText('Subscription'); + if (await subscriptionButton.isVisible({ timeout: 2000 })) { + await subscriptionButton.click(); + await page.waitForTimeout(2000); + + const subscriptionPage = new SubscriptionPage(page); + const hasSubscribeButton = await subscriptionPage.subscribeButton.isVisible({ timeout: 2000 }).catch(() => false); + + if (hasSubscribeButton) { + // Click subscribe but don't complete payment + await subscriptionPage.subscribe(); + await page.waitForTimeout(3000); + + // Should open payment sheet or show payment options + const hasPaymentUI = await Promise.race([ + page.getByText(/Card number|Payment/i).isVisible({ timeout: 5000 }).catch(() => false), + page.getByText(/Stripe/i).isVisible({ timeout: 5000 }).catch(() => false), + page.locator('[data-testid="payment-sheet"]').isVisible({ timeout: 5000 }).catch(() => false), + ]); + + console.log(`Payment UI visible: ${hasPaymentUI}`); + console.log('✅ Subscribe button works'); + } else { + console.log('⚠️ No subscribe button (already subscribed)'); + } + } + } + }); + + test('4. Demo mode option available', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const subscriptionButton = page.getByText('Subscription'); + if (await subscriptionButton.isVisible({ timeout: 2000 })) { + await subscriptionButton.click(); + await page.waitForTimeout(2000); + + // Check for demo mode option + const hasDemoOption = await page.getByText(/Demo|Try.*Free|Free Trial/i).isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Demo option visible: ${hasDemoOption}`); + console.log('✅ Demo mode check complete'); + } + } + }); + + test('5. Subscription plan options displayed', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const subscriptionButton = page.getByText('Subscription'); + if (await subscriptionButton.isVisible({ timeout: 2000 })) { + await subscriptionButton.click(); + await page.waitForTimeout(2000); + + // Check for pricing/plan info + const hasPricing = await page.getByText(/\$|€|month|year|plan/i).isVisible({ timeout: 2000 }).catch(() => false); + + console.log(`Pricing info visible: ${hasPricing}`); + console.log('✅ Plan options check complete'); + } + } + }); +}); + +test.describe('Purchase Flow (Auth)', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + }); + + test('Purchase screen accessible from auth flow', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.expectLoaded(); + + // Use new email to trigger new user flow + const newEmail = `purchase.test.${Date.now()}@test.com`; + await loginPage.loginWithEmail(newEmail); + + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.newUser.bypassOtp); + + await page.waitForTimeout(5000); + + // New user should eventually reach purchase screen or see purchase option + const hasPurchaseOption = await Promise.race([ + page.getByText(/Purchase|Buy|Subscribe|Equipment/i).isVisible({ timeout: 10000 }).catch(() => false), + page.getByText(/Demo/i).isVisible({ timeout: 10000 }).catch(() => false), + page.getByText('Add a Loved One').isVisible({ timeout: 10000 }).catch(() => false), + ]); + + console.log(`Purchase/onboarding option visible: ${hasPurchaseOption}`); + console.log('✅ Purchase flow accessible'); + }); +}); + +test.describe('Stripe Integration', () => { + test.beforeEach(async ({ page }) => { + enableConsoleLogging(page); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + const isOnLogin = await loginPage.welcomeText.isVisible({ timeout: 2000 }).catch(() => false); + + if (isOnLogin) { + await loginPage.loginWithEmail(TEST_CREDENTIALS.existingUser.email); + const otpPage = new OtpPage(page); + await otpPage.expectLoaded(); + await otpPage.enterCode(TEST_CREDENTIALS.existingUser.bypassOtp); + await page.waitForTimeout(3000); + } + }); + + test('Stripe payment elements load correctly', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const subscriptionButton = page.getByText('Subscription'); + if (await subscriptionButton.isVisible({ timeout: 2000 })) { + await subscriptionButton.click(); + await page.waitForTimeout(2000); + + const subscribeButton = page.getByText('Subscribe'); + if (await subscribeButton.isVisible({ timeout: 2000 })) { + await subscribeButton.click(); + await page.waitForTimeout(5000); + + // Check for Stripe elements + const hasCardInput = await page.getByPlaceholder(/Card number/i).isVisible({ timeout: 5000 }).catch(() => false); + const hasStripeFrame = await page.locator('iframe[name*="stripe"]').isVisible({ timeout: 5000 }).catch(() => false); + + console.log(`Card input: ${hasCardInput}, Stripe frame: ${hasStripeFrame}`); + console.log('✅ Stripe integration check complete'); + } else { + console.log('⚠️ Subscribe button not visible'); + } + } + } + }); + + test('Payment sheet can be closed', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const firstCard = page.locator('[data-testid="beneficiary-card"]').first(); + if (await firstCard.isVisible({ timeout: 3000 })) { + await firstCard.click(); + await page.waitForTimeout(2000); + + const subscriptionButton = page.getByText('Subscription'); + if (await subscriptionButton.isVisible({ timeout: 2000 })) { + await subscriptionButton.click(); + await page.waitForTimeout(2000); + + const subscribeButton = page.getByText('Subscribe'); + if (await subscribeButton.isVisible({ timeout: 2000 })) { + await subscribeButton.click(); + await page.waitForTimeout(3000); + + // Try to close payment sheet + const closeButton = page.getByText(/Close|Cancel|X/i); + if (await closeButton.isVisible({ timeout: 2000 })) { + await closeButton.click(); + await page.waitForTimeout(1000); + + // Should be back on subscription page + const backOnSubscription = await page.getByText('Subscribe').isVisible({ timeout: 2000 }).catch(() => false); + console.log(`Back on subscription page: ${backOnSubscription}`); + } + + console.log('✅ Payment sheet close check complete'); + } + } + } + }); +}); diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts new file mode 100644 index 0000000..2808772 --- /dev/null +++ b/e2e/helpers/index.ts @@ -0,0 +1,6 @@ +/** + * E2E Test Helpers - Main Export + */ + +export * from './test-helpers'; +export * from './page-objects'; diff --git a/e2e/helpers/page-objects.ts b/e2e/helpers/page-objects.ts new file mode 100644 index 0000000..d217c67 --- /dev/null +++ b/e2e/helpers/page-objects.ts @@ -0,0 +1,367 @@ +/** + * Page Object Models for WellNuo E2E Tests + * Encapsulates page-specific selectors and actions + */ + +import { Page, expect, Locator } from '@playwright/test'; +import { BASE_URL } from './test-helpers'; + +/** + * Login Page Object + */ +export class LoginPage { + readonly page: Page; + readonly welcomeText: Locator; + readonly emailInput: Locator; + readonly continueButton: Locator; + readonly inviteCodeLink: Locator; + + constructor(page: Page) { + this.page = page; + this.welcomeText = page.getByText('Welcome to WellNuo'); + this.emailInput = page.getByPlaceholder('Enter your email'); + this.continueButton = page.getByText('Continue'); + this.inviteCodeLink = page.getByText('I have an invite code'); + } + + async goto() { + await this.page.goto(BASE_URL); + await this.page.waitForLoadState('networkidle'); + } + + async expectLoaded() { + await expect(this.welcomeText).toBeVisible({ timeout: 10000 }); + await expect(this.emailInput).toBeVisible(); + await expect(this.continueButton).toBeVisible(); + } + + async enterEmail(email: string) { + await this.emailInput.fill(email); + } + + async submit() { + await this.continueButton.click(); + } + + async loginWithEmail(email: string) { + await this.enterEmail(email); + await this.submit(); + } +} + +/** + * OTP Verification Page Object + */ +export class OtpPage { + readonly page: Page; + readonly headerText: Locator; + readonly verifyButton: Locator; + readonly resendButton: Locator; + readonly useDifferentEmailLink: Locator; + + constructor(page: Page) { + this.page = page; + this.headerText = page.getByText('Check your email'); + this.verifyButton = page.getByText('Verify'); + this.resendButton = page.getByText('Resend'); + this.useDifferentEmailLink = page.getByText('Use a different email'); + } + + async expectLoaded() { + await expect(this.headerText).toBeVisible({ timeout: 15000 }); + await expect(this.verifyButton).toBeVisible(); + await expect(this.resendButton).toBeVisible(); + } + + async enterCode(code: string) { + // OTP input is hidden, type using keyboard + await this.page.click('body'); + await this.page.keyboard.type(code, { delay: 50 }); + } + + async verify() { + await this.verifyButton.click(); + } + + async resend() { + await this.resendButton.click(); + } + + async goBackToLogin() { + await this.useDifferentEmailLink.click(); + } + + async expectResendCooldown() { + await expect(this.page.getByText(/Resend in \d+s/)).toBeVisible({ timeout: 5000 }); + } + + async expectErrorMessage() { + await expect( + this.page.getByText(/Invalid|incorrect|error/i) + ).toBeVisible({ timeout: 5000 }); + } +} + +/** + * Enter Name Page Object (new user onboarding) + */ +export class EnterNamePage { + readonly page: Page; + readonly headerText: Locator; + readonly nameInput: Locator; + readonly continueButton: Locator; + + constructor(page: Page) { + this.page = page; + this.headerText = page.getByText('What should we call you?'); + this.nameInput = page.getByPlaceholder(/name/i); + this.continueButton = page.getByText('Continue'); + } + + async expectLoaded() { + await expect(this.headerText).toBeVisible({ timeout: 10000 }); + await expect(this.nameInput).toBeVisible(); + } + + async enterName(name: string) { + await this.nameInput.fill(name); + } + + async submit() { + await this.continueButton.click(); + } +} + +/** + * Add Loved One Page Object + */ +export class AddLovedOnePage { + readonly page: Page; + readonly headerText: Locator; + readonly firstNameInput: Locator; + readonly lastNameInput: Locator; + readonly addButton: Locator; + + constructor(page: Page) { + this.page = page; + this.headerText = page.getByText(/Add.*Loved One/i); + this.firstNameInput = page.getByPlaceholder(/first.*name/i); + this.lastNameInput = page.getByPlaceholder(/last.*name/i); + this.addButton = page.getByText(/Add|Continue/i); + } + + async expectLoaded() { + await expect(this.headerText).toBeVisible({ timeout: 10000 }); + } + + async enterBeneficiaryDetails(firstName: string, lastName?: string) { + await this.firstNameInput.fill(firstName); + if (lastName && await this.lastNameInput.isVisible()) { + await this.lastNameInput.fill(lastName); + } + } + + async submit() { + await this.addButton.click(); + } +} + +/** + * Beneficiaries List Page Object + */ +export class BeneficiariesPage { + readonly page: Page; + readonly headerText: Locator; + readonly addButton: Locator; + + constructor(page: Page) { + this.page = page; + this.headerText = page.getByText('My Loved Ones'); + this.addButton = page.getByText(/Add|Plus/i); + } + + async expectLoaded() { + await expect(this.headerText).toBeVisible({ timeout: 15000 }); + } + + async getBeneficiaryCards(): Promise { + return this.page.locator('[data-testid="beneficiary-card"]'); + } + + async clickBeneficiary(name: string) { + await this.page.getByText(name).click(); + } + + async clickFirstBeneficiary() { + const cards = await this.getBeneficiaryCards(); + await cards.first().click(); + } +} + +/** + * Beneficiary Detail Page Object + */ +export class BeneficiaryDetailPage { + readonly page: Page; + readonly subscriptionButton: Locator; + readonly equipmentButton: Locator; + readonly shareButton: Locator; + readonly menuButton: Locator; + + constructor(page: Page) { + this.page = page; + this.subscriptionButton = page.getByText('Subscription'); + this.equipmentButton = page.getByText(/Equipment|Sensors/i); + this.shareButton = page.getByText('Share'); + this.menuButton = page.locator('[data-testid="menu-button"]'); + } + + async expectLoaded() { + await expect(this.subscriptionButton.or(this.equipmentButton)).toBeVisible({ timeout: 10000 }); + } + + async goToSubscription() { + await this.subscriptionButton.click(); + } + + async goToEquipment() { + await this.equipmentButton.click(); + } + + async goToShare() { + await this.shareButton.click(); + } + + async openMenu() { + await this.menuButton.click(); + } +} + +/** + * Subscription Page Object + */ +export class SubscriptionPage { + readonly page: Page; + readonly subscribeButton: Locator; + readonly activeStatus: Locator; + readonly demoStatus: Locator; + readonly cancelButton: Locator; + + constructor(page: Page) { + this.page = page; + this.subscribeButton = page.getByText('Subscribe'); + this.activeStatus = page.getByText('Subscription Active'); + this.demoStatus = page.getByText(/Demo/i); + this.cancelButton = page.getByText(/Cancel.*Subscription/i); + } + + async expectLoaded() { + await expect( + this.subscribeButton.or(this.activeStatus).or(this.demoStatus) + ).toBeVisible({ timeout: 10000 }); + } + + async subscribe() { + await this.subscribeButton.click(); + } + + async isSubscriptionActive(): Promise { + return await this.activeStatus.isVisible({ timeout: 2000 }).catch(() => false); + } + + async isDemoMode(): Promise { + return await this.demoStatus.isVisible({ timeout: 2000 }).catch(() => false); + } +} + +/** + * Equipment/Sensors Page Object + */ +export class EquipmentPage { + readonly page: Page; + readonly totalSensors: Locator; + readonly onlineSensors: Locator; + readonly offlineSensors: Locator; + readonly addSensorsButton: Locator; + readonly emptySensorState: Locator; + + constructor(page: Page) { + this.page = page; + this.totalSensors = page.getByText(/Total/i); + this.onlineSensors = page.getByText(/Online/i); + this.offlineSensors = page.getByText(/Offline/i); + this.addSensorsButton = page.getByText(/Add.*Sensor/i); + this.emptySensorState = page.getByText('No Sensors Connected'); + } + + async expectLoaded() { + await expect( + this.totalSensors.or(this.emptySensorState) + ).toBeVisible({ timeout: 10000 }); + } + + async getSensorCount(): Promise { + const totalText = await this.totalSensors.textContent(); + const match = totalText?.match(/\d+/); + return match ? parseInt(match[0], 10) : 0; + } + + async addSensors() { + await this.addSensorsButton.click(); + } + + async hasSensors(): Promise { + return !(await this.emptySensorState.isVisible({ timeout: 2000 }).catch(() => false)); + } +} + +/** + * Profile Page Object + */ +export class ProfilePage { + readonly page: Page; + readonly editButton: Locator; + readonly logoutButton: Locator; + readonly notificationsItem: Locator; + readonly languageItem: Locator; + readonly helpItem: Locator; + readonly aboutItem: Locator; + + constructor(page: Page) { + this.page = page; + this.editButton = page.getByText('Edit Profile'); + this.logoutButton = page.getByText('Log Out'); + this.notificationsItem = page.getByText('Notifications'); + this.languageItem = page.getByText('Language'); + this.helpItem = page.getByText('Help'); + this.aboutItem = page.getByText('About'); + } + + async expectLoaded() { + await expect(this.logoutButton).toBeVisible({ timeout: 10000 }); + } + + async editProfile() { + await this.editButton.click(); + } + + async logout() { + await this.logoutButton.click(); + } + + async goToNotifications() { + await this.notificationsItem.click(); + } + + async goToLanguage() { + await this.languageItem.click(); + } + + async goToHelp() { + await this.helpItem.click(); + } + + async goToAbout() { + await this.aboutItem.click(); + } +} diff --git a/e2e/helpers/test-helpers.ts b/e2e/helpers/test-helpers.ts new file mode 100644 index 0000000..d21d00b --- /dev/null +++ b/e2e/helpers/test-helpers.ts @@ -0,0 +1,236 @@ +/** + * E2E Test Helpers for WellNuo + * Common utilities for Playwright tests + */ + +import { Page, expect } from '@playwright/test'; + +// E2E tests run against local Expo web server +// Start with: npm run web (expo start --web) +export const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8081'; + +// Test credentials (use bypass OTP for testing) +export const TEST_CREDENTIALS = { + // Test user with existing beneficiary + existingUser: { + email: 'e2e.test@wellnuo.com', + bypassOtp: '000000', + }, + // Test user for new registration + newUser: { + email: `e2e.new.${Date.now()}@test.com`, + bypassOtp: '000000', + }, + // Invalid test cases + invalid: { + email: 'invalid-email', + wrongOtp: '999999', + }, +}; + +/** + * Wait for app to fully load + */ +export async function waitForAppLoad(page: Page, timeout = 15000): Promise { + await page.waitForLoadState('networkidle', { timeout }); +} + +/** + * Navigate to login page and verify it loaded + */ +export async function goToLogin(page: Page): Promise { + await page.goto(BASE_URL); + await waitForAppLoad(page); + await expect(page.getByText('Welcome to WellNuo')).toBeVisible({ timeout: 10000 }); +} + +/** + * Enter email and proceed to OTP screen + */ +export async function enterEmailAndSubmit(page: Page, email: string): Promise { + await page.getByPlaceholder('Enter your email').fill(email); + await page.getByText('Continue').click(); +} + +/** + * Enter OTP code using keyboard (handles hidden input) + */ +export async function enterOtpCode(page: Page, code: string): Promise { + // Click on the OTP container to focus + await page.click('body'); + await page.keyboard.type(code, { delay: 50 }); +} + +/** + * Wait for OTP screen to appear + */ +export async function waitForOtpScreen(page: Page): Promise { + await expect(page.getByText('Check your email')).toBeVisible({ timeout: 15000 }); +} + +/** + * Complete login flow (email + OTP) + */ +export async function loginWithOtp( + page: Page, + email: string, + otp: string = '000000' +): Promise { + await goToLogin(page); + await enterEmailAndSubmit(page, email); + await waitForOtpScreen(page); + await enterOtpCode(page, otp); + // Wait for navigation after OTP + await page.waitForTimeout(2000); +} + +/** + * Wait for dashboard/main screen + */ +export async function waitForDashboard(page: Page): Promise { + // Could be beneficiary list or dashboard depending on user state + await expect( + page.getByText('My Loved Ones').or(page.getByText('Dashboard')) + ).toBeVisible({ timeout: 15000 }); +} + +/** + * Wait for enter-name screen (new user onboarding) + */ +export async function waitForEnterNameScreen(page: Page): Promise { + await expect( + page.getByText('What should we call you?').or(page.getByPlaceholder('Your name')) + ).toBeVisible({ timeout: 10000 }); +} + +/** + * Wait for add-loved-one screen + */ +export async function waitForAddLovedOneScreen(page: Page): Promise { + await expect( + page.getByText('Add a Loved One').or(page.getByText('loved one')) + ).toBeVisible({ timeout: 10000 }); +} + +/** + * Check if still on login page + */ +export async function isOnLoginPage(page: Page): Promise { + return await page.getByText('Welcome to WellNuo').isVisible({ timeout: 1000 }).catch(() => false); +} + +/** + * Check if still on OTP page + */ +export async function isOnOtpPage(page: Page): Promise { + return await page.getByText('Check your email').isVisible({ timeout: 1000 }).catch(() => false); +} + +/** + * Navigate to beneficiary detail + */ +export async function goToBeneficiaryDetail(page: Page, beneficiaryName?: string): Promise { + if (beneficiaryName) { + await page.getByText(beneficiaryName).click(); + } else { + // Click first beneficiary card + await page.locator('[data-testid="beneficiary-card"]').first().click(); + } + await page.waitForTimeout(1000); +} + +/** + * Navigate to subscription screen for a beneficiary + */ +export async function goToSubscription(page: Page): Promise { + await page.getByText('Subscription').click(); + await page.waitForTimeout(1000); +} + +/** + * Navigate to equipment screen for a beneficiary + */ +export async function goToEquipment(page: Page): Promise { + await page.getByText('Equipment').or(page.getByText('Sensors')).click(); + await page.waitForTimeout(1000); +} + +/** + * Navigate to profile + */ +export async function goToProfile(page: Page): Promise { + await page.getByText('Profile').click(); + await page.waitForTimeout(1000); +} + +/** + * Click back button + */ +export async function goBack(page: Page): Promise { + const backButton = page.locator('[data-testid="back-button"]').or( + page.getByText('Use a different email') + ); + if (await backButton.isVisible()) { + await backButton.click(); + await page.waitForTimeout(500); + } else { + // Try browser back + await page.goBack(); + } +} + +/** + * Log browser console messages (for debugging) + */ +export function enableConsoleLogging(page: Page): void { + page.on('console', msg => { + if (msg.type() !== 'log') return; + console.log(`BROWSER: ${msg.text()}`); + }); + page.on('pageerror', err => console.log(`ERROR: ${err.message}`)); +} + +/** + * Take named screenshot + */ +export async function screenshot(page: Page, name: string): Promise { + await page.screenshot({ + path: `screenshots/${name}.png`, + quality: 50, + scale: 'css', + }); +} + +/** + * Verify error message is displayed + */ +export async function expectErrorMessage(page: Page, pattern: RegExp | string): Promise { + if (typeof pattern === 'string') { + await expect(page.getByText(pattern)).toBeVisible({ timeout: 5000 }); + } else { + await expect(page.getByText(pattern)).toBeVisible({ timeout: 5000 }); + } +} + +/** + * Wait for toast/notification + */ +export async function waitForToast(page: Page, text: string | RegExp): Promise { + await expect(page.getByText(text)).toBeVisible({ timeout: 5000 }); +} + +/** + * Check API response in network + */ +export async function interceptApiCall( + page: Page, + urlPattern: string | RegExp, + callback: (response: { status: number; body: unknown }) => void +): Promise { + page.on('response', async response => { + if (response.url().match(urlPattern)) { + const body = await response.json().catch(() => null); + callback({ status: response.status(), body }); + } + }); +} diff --git a/package-lock.json b/package-lock.json index 6058438..fa7be8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "zustand": "^5.0.10" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "^13.3.3", "@types/jest": "^30.0.0", @@ -5487,6 +5488,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -19042,13 +19059,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.58.1" }, "bin": { "playwright": "cli.js" @@ -19061,9 +19078,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index c30a320..3bd4145 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,11 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:e2e": "npx playwright test --project=chromium", + "test:e2e:mobile": "npx playwright test --project=mobile-chrome", + "test:e2e:all": "npx playwright test", + "test:e2e:critical": "npx playwright test --project=chromium critical-flows/", + "test:e2e:report": "npx playwright show-report", "lint": "expo lint", "postinstall": "patch-package" }, @@ -74,6 +79,7 @@ "zustand": "^5.0.10" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "^13.3.3", "@types/jest": "^30.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index 4e96d3e..a39cc7c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,30 +1,63 @@ import { defineConfig, devices } from '@playwright/test'; +/** + * E2E tests for WellNuo Expo web app + * + * To run tests: + * 1. Start Expo web server: npm run web + * 2. Run tests: npm run test:e2e + * + * Or with custom URL: + * E2E_BASE_URL=http://localhost:3000 npm run test:e2e + */ export default defineConfig({ testDir: './e2e', fullyParallel: false, // Sequential tests to avoid rate limiting forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: 1, // Single worker to prevent 429 errors - reporter: 'html', - use: { - baseURL: 'https://wellnuo.smartlaunchhub.com/app', - trace: 'on-first-retry', - screenshot: 'off', // Disabled for faster testing - video: 'off', // Disabled - using console logs instead + reporter: [ + ['html', { open: 'never' }], + ['list'], + ], + timeout: 60000, // 60 second timeout per test + expect: { + timeout: 10000, // 10 second timeout for assertions }, + use: { + // Use environment variable or default to local Expo web server + baseURL: process.env.E2E_BASE_URL || 'http://localhost:8081', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 15000, + navigationTimeout: 30000, + }, + // Optionally start Expo web server before tests + webServer: process.env.E2E_START_SERVER ? { + command: 'npm run web', + url: 'http://localhost:8081', + reuseExistingServer: !process.env.CI, + timeout: 120000, + } : undefined, projects: [ + // Critical flows - desktop Chrome (primary) { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + testMatch: /critical-flows\/.*.spec.ts/, }, + // Mobile Chrome for responsive testing { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, + testMatch: /critical-flows\/.*.spec.ts/, }, + // Legacy tests (original e2e files) { - name: 'mobile-safari', - use: { ...devices['iPhone 12'] }, + name: 'legacy', + use: { ...devices['Desktop Chrome'] }, + testMatch: /^(?!critical-flows).*.spec.ts/, }, ], });