diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 891bb130..5f1a5f5a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -78,7 +78,7 @@ reviews: - Flag any hardcoded strings; they should be in the constants file. - Check for edge cases like null values or empty arrays. - Suggest performance optimizations where appropriate. - + Mobile best practices: - Proper use of hooks (useRouter, useFonts, useAssets) - Accessibility: touch targets min 44x44, screen reader support @@ -99,4 +99,22 @@ reviews: - Validate deep linking configurations Internationalization: - - User-visible strings should be externalized to resource files (useT()) + - User-visible strings should be externalized to JSON resource files in common/messages via + ``` + const t = useT() + const message = t('key', 'english string') + ``` + + - path: "tests/e2e/**/*.ts" + instructions: | + Playwright E2E test guidelines for this repo: + - Page objects live in `tests/e2e/web/pages/`. Each class wraps one page/route, holds only `private readonly` Locators, and exposes action methods. + - All tests must use the `app` fixture (type `App`) from `tests/e2e/web/fixtures/base.ts`. Never instantiate page objects directly in a test. + - Cross-page flows (actions spanning multiple pages) belong as methods on the `App` class, not as standalone helper functions. + - Action methods in page objects must assert `expect(locator).toBeVisible()` before interacting. + - Never use `page.waitForTimeout()`. Use Playwright's built-in auto-waiting or `waitForURL` / `waitForSelector`. + - No hardcoded credentials in spec files; use `SPEC_CONFIG.ts` or account fixtures. + - Test account cleanup must be done in fixture teardown (after `await use(...)`), not in `afterEach` hooks. + - File and class names must use PascalCase (e.g., `CompatibilityPage.ts` / `class CompatibilityPage`). + - No DB or Firebase calls inside page object classes; those belong in `tests/e2e/utils/`. + - Flag any new page object not yet registered in `App`. diff --git a/.gitignore b/.gitignore index d393c542..1d80b32a 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ test-results **/coverage *my-release-key.keystore + +.vscode/settings.json \ No newline at end of file diff --git a/docs/TESTING.md b/docs/TESTING.md index 43c987dc..7a71f563 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -348,6 +348,7 @@ jest.mock('path/to/module') * This creates an object containing all named exports from ./path/to/module */ import * as mockModule from 'path/to/module' + ;(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue) ``` @@ -705,6 +706,66 @@ Use this priority order for selecting elements in Playwright tests: This hierarchy mirrors how users actually interact with your application, making tests more reliable and meaningful. +### Page Object Model (POM) + +Tests often receive multiple page objects as fixtures (e.g. `homePage`, `authPage`, `profilePage`). This is the **Page +Object Model** pattern — a way to organize selectors and actions by the area of the app they belong to. + +**Page objects are not separate browser tabs.** They are all wrappers around the same underlying `page` instance. Each +class simply encapsulates the selectors and actions relevant to one part of the UI: + +```typescript +class ProfilePage { + constructor(private page: Page) {} + + async verifyDisplayName(name: string) { + await expect(this.page.getByTestId('display-name')).toHaveText(name) + } +} + +class SettingsPage { + constructor(private page: Page) {} // same page instance + + async deleteAccount() { + await this.page.getByRole('button', {name: 'Delete account'}).click() + } +} +``` + +**Why use POM instead of raw `page`?** + +Without it, tests are full of inline selectors that are brittle and hard to read. POM moves implementation details into +dedicated classes so that the test itself reads like a plain-English description of user behavior. When a selector +changes, you fix it in one place. + +```typescript +// ❌ Without POM — noisy and brittle +await page.locator('#email-input').fill(account.email) +await page.locator('#submit-btn').click() +await page.locator('[data-testid="skip-onboarding"]').click() +// ...50 more lines of noise + +// ✅ With POM — readable and maintainable +await registerWithEmail(homePage, authPage, fakerAccount) +await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount) +await profilePage.verifyDisplayName(fakerAccount.display_name) +``` + +**What happens if you call a method on the "wrong" page object?** + +Nothing special — it still runs. Since all page objects share the same `page`, the method simply acts on whatever is +currently rendered in the browser. Page objects do not track which screen you're on; that's your responsibility as the +test author. If you call `profilePage.verifyDisplayName()` while the browser is showing the settings screen, the locator +won't find its element and the test will **time out**. + +```typescript +// ⚠️ This fails at runtime if navigation hasn't happened yet +await settingsPage.deleteAccount() // navigates away from profile +await profilePage.verifyDisplayName(name) // locator not found → timeout +``` + +Always ensure navigation has completed before calling methods that depend on a specific screen being visible. + ### Setting up test data Since the tests run in parallel (i.e., at the same time) and share the same database and Firebase emulator, it can @@ -802,7 +863,9 @@ These are seeded automatically by `yarn test:db:seed`: ## Troubleshooting -For comprehensive troubleshooting guidance beyond testing-specific issues, see the [Troubleshooting Guide](TROUBLESHOOTING.md) which covers development environment setup, database and emulator issues, API problems, and more. +For comprehensive troubleshooting guidance beyond testing-specific issues, see +the [Troubleshooting Guide](TROUBLESHOOTING.md) which covers development environment setup, database and emulator +issues, API problems, and more. ### Port already in use @@ -885,4 +948,5 @@ To download the Playwright report from a failed CI run: 3. Download `playwright-report` 4. Open `index.html` in your browser -For performance testing guidance and benchmarking strategies, see the [Performance Optimization Guide](PERFORMANCE_OPTIMIZATION.md). +For performance testing guidance and benchmarking strategies, see +the [Performance Optimization Guide](PERFORMANCE_OPTIMIZATION.md). diff --git a/tests/e2e/backend/utils/userInformation.ts b/tests/e2e/backend/utils/userInformation.ts index a0fd5707..e159f217 100644 --- a/tests/e2e/backend/utils/userInformation.ts +++ b/tests/e2e/backend/utils/userInformation.ts @@ -12,8 +12,9 @@ import { SUBSTANCE_PREFERENCE_CHOICES, } from 'common/choices' -class UserAccountInformation { +class UserAccountInformationForSeeding { name = faker.person.fullName() + userName = faker.internet.displayName() email = faker.internet.email() user_id = faker.string.alpha(28) password = faker.internet.password() @@ -54,4 +55,4 @@ class UserAccountInformation { } } -export default UserAccountInformation +export default UserAccountInformationForSeeding diff --git a/tests/e2e/utils/databaseUtils.ts b/tests/e2e/utils/databaseUtils.ts index 8370d0fd..067613b6 100644 --- a/tests/e2e/utils/databaseUtils.ts +++ b/tests/e2e/utils/databaseUtils.ts @@ -6,7 +6,8 @@ export async function deleteFromDb(user_id: string) { const result = await db.query(deleteEntryById, [user_id]) if (!result.length) { - throw new Error(`No user found with id: ${user_id}`) + console.debug(`No user found with id: ${user_id}`) + return } console.log('Deleted data: ', { @@ -19,8 +20,8 @@ export async function deleteFromDb(user_id: string) { export async function userInformationFromDb(account: any) { const db = createSupabaseDirectClient() const queryUserById = ` - SELECT p.* - FROM users AS p + SELECT * + FROM users WHERE username = $1 ` const userResults = await db.query(queryUserById, [account.username]) diff --git a/tests/e2e/utils/firebaseUtils.ts b/tests/e2e/utils/firebaseUtils.ts index 87a847f7..a7c8f0ef 100644 --- a/tests/e2e/utils/firebaseUtils.ts +++ b/tests/e2e/utils/firebaseUtils.ts @@ -2,7 +2,10 @@ import axios from 'axios' import {config} from '../web/SPEC_CONFIG' -export async function firebaseLogin(email: string, password: string) { +export async function firebaseLoginEmailPassword( + email: string | undefined, + password: string | undefined, +) { const login = await axios.post( `${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGN_IN_PASSWORD}`, { @@ -13,15 +16,28 @@ export async function firebaseLogin(email: string, password: string) { ) return login } + export async function getUserId(email: string, password: string) { try { - const loginInfo = await firebaseLogin(email, password) + const loginInfo = await firebaseLoginEmailPassword(email, password) return loginInfo.data.localId } catch { return } } +export async function findUser(idToken: string) { + const response = await axios.post( + `${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.ACCOUNT_LOOKUP}`, + { + idToken, + }, + ) + if (response?.data?.users?.length > 0) { + return response.data.users[0] + } +} + export async function firebaseSignUp(email: string, password: string) { try { const response = await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGNUP}`, { @@ -46,8 +62,15 @@ export async function firebaseSignUp(email: string, password: string) { } } -export async function deleteAccount(login: any) { - await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.DELETE}`, { - idToken: login.data.idToken, - }) +export async function deleteAccount(idToken: any) { + try { + await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.DELETE}`, { + idToken: idToken, + }) + } catch (err: any) { + if (err.response?.data?.error?.message?.includes('USER_NOT_FOUND')) { + return + } + throw err + } } diff --git a/tests/e2e/utils/seedDatabase.ts b/tests/e2e/utils/seedDatabase.ts index 084535ce..3da70fd3 100644 --- a/tests/e2e/utils/seedDatabase.ts +++ b/tests/e2e/utils/seedDatabase.ts @@ -7,7 +7,7 @@ import {createSupabaseDirectClient} from 'shared/supabase/init' import {insert} from 'shared/supabase/utils' import {getUser} from 'shared/utils' -import UserAccountInformation from '../backend/utils/userInformation' +import UserAccountInformationForSeeding from '../backend/utils/userInformation' import {firebaseSignUp} from './firebaseUtils' /** @@ -16,7 +16,10 @@ import {firebaseSignUp} from './firebaseUtils' * @param userInfo - Class object containing information to create a user account generated by `fakerjs`. * @param profileType - Optional param used to signify how much information is used in the account generation. */ -export async function seedDbUser(userInfo: UserAccountInformation, profileType?: string) { +export async function seedDbUser( + userInfo: UserAccountInformationForSeeding, + profileType?: string, +): Promise { const pg = createSupabaseDirectClient() const userId = userInfo.user_id const deviceToken = randomString() @@ -83,14 +86,14 @@ export async function seedDbUser(userInfo: UserAccountInformation, profileType?: blockedByUserIds: [], } - await pg.tx(async (tx: any) => { + return pg.tx(async (tx: any) => { const preexistingUser = await getUser(userId, tx) - if (preexistingUser) return + if (preexistingUser) return false await insert(tx, 'users', { id: userId, name: userInfo.name, - username: cleanUsername(userInfo.name), + username: cleanUsername(userInfo.userName), data: {}, }) @@ -100,6 +103,7 @@ export async function seedDbUser(userInfo: UserAccountInformation, profileType?: }) await insert(tx, 'profiles', profileData) + return true }) } @@ -107,13 +111,17 @@ export async function seedUser( email?: string | undefined, password?: string | undefined, profileType?: string | undefined, + displayName?: string | undefined, + userName?: string | undefined, ) { - const userInfo = new UserAccountInformation() + const userInfo = new UserAccountInformationForSeeding() if (email) userInfo.email = email if (password) userInfo.password = password + if (displayName) userInfo.name = displayName + if (userName) userInfo.userName = userName userInfo.user_id = await firebaseSignUp(userInfo.email, userInfo.password) if (userInfo.user_id) { - await seedDbUser(userInfo, profileType ?? 'full') + const created = await seedDbUser(userInfo, profileType ?? 'full') + if (created) debug('User created in Firebase and Supabase:', userInfo.email) } - debug('User created in Firebase and Supabase:', userInfo.email) } diff --git a/tests/e2e/web/SPEC_CONFIG.ts b/tests/e2e/web/SPEC_CONFIG.ts index 5f12d89e..3b08779b 100644 --- a/tests/e2e/web/SPEC_CONFIG.ts +++ b/tests/e2e/web/SPEC_CONFIG.ts @@ -4,6 +4,7 @@ export const config = { BASE: 'http://localhost:9099/identitytoolkit.googleapis.com/v1', SIGNUP: '/accounts:signUp?key=fake-api-key', SIGN_IN_PASSWORD: '/accounts:signInWithPassword?key=fake-api-key', + ACCOUNT_LOOKUP: '/accounts:lookup?key=fake-api-key', DELETE: '/accounts:delete?key=fake-api-key', }, USERS: { diff --git a/tests/e2e/web/fixtures/base.ts b/tests/e2e/web/fixtures/base.ts index 23ec87fc..fcd40d17 100644 --- a/tests/e2e/web/fixtures/base.ts +++ b/tests/e2e/web/fixtures/base.ts @@ -4,10 +4,14 @@ import {AuthPage} from '../pages/AuthPage' import {ComatibilityPage} from '../pages/compatibilityPage' import {HomePage} from '../pages/homePage' import {OnboardingPage} from '../pages/onboardingPage' +import {OrganizationPage} from '../pages/organizationPage' import {ProfilePage} from '../pages/profilePage' +import {SettingsPage} from '../pages/settingsPage' import {SignUpPage} from '../pages/signUpPage' +import {SocialPage} from '../pages/socialPage' import {testAccounts, UserAccountInformation} from '../utils/accountInformation' import {deleteUser} from '../utils/deleteUser' +import {getAuthAccountInfo} from '../utils/networkUtils' export const test = base.extend<{ homePage: HomePage @@ -15,29 +19,48 @@ export const test = base.extend<{ signUpPage: SignUpPage profilePage: ProfilePage authPage: AuthPage + settingsPage: SettingsPage + socialPage: SocialPage + organizationPage: OrganizationPage compatabilityPage: ComatibilityPage cleanUpUsers: void onboardingAccount: UserAccountInformation fakerAccount: UserAccountInformation specAccount: UserAccountInformation + googleAccountOne: UserAccountInformation + googleAccountTwo: UserAccountInformation }>({ onboardingAccount: async ({}, use) => { - const account = testAccounts.account_all_info() // email captured here + const account = testAccounts.email_account_all_info() // email captured here await use(account) console.log('Cleaning up onboarding 1 account...') - await deleteUser(account.email, account.password) // same account, guaranteed + await deleteUser('Email/Password', account) // same account, guaranteed }, fakerAccount: async ({}, use) => { - const account = testAccounts.faker_account() // email captured here + const account = testAccounts.faker_account() await use(account) console.log('Cleaning up faker account...') - await deleteUser(account.email, account.password) // same account, guaranteed + await deleteUser('Email/Password', account) + }, + googleAccountOne: async ({page}, use) => { + const account = testAccounts.google_account_one() + const getAuthObject = await getAuthAccountInfo(page) + await use(account) + console.log('Cleaning up google account...') + await deleteUser('Google', undefined, getAuthObject()) + }, + googleAccountTwo: async ({page}, use) => { + const account = testAccounts.google_account_two() + const getAuthObject = await getAuthAccountInfo(page) + await use(account) + console.log('Cleaning up google account...') + await deleteUser('Google', undefined, getAuthObject()) }, specAccount: async ({}, use) => { const account = testAccounts.spec_account() await use(account) console.log('Cleaning up spec account...') - await deleteUser(account.email, account.password) + await deleteUser('Email/Password', account) }, onboardingPage: async ({page}, use) => { const onboardingPage = new OnboardingPage(page) @@ -63,6 +86,18 @@ export const test = base.extend<{ const compatibilityPage = new ComatibilityPage(page) await use(compatibilityPage) }, + settingsPage: async ({page}, use) => { + const settingsPage = new SettingsPage(page) + await use(settingsPage) + }, + socialPage: async ({page}, use) => { + const socialPage = new SocialPage(page) + await use(socialPage) + }, + organizationPage: async ({page}, use) => { + const organizationPage = new OrganizationPage(page) + await use(organizationPage) + }, }) export {expect} from '@playwright/test' diff --git a/tests/e2e/web/fixtures/signInFixture.ts b/tests/e2e/web/fixtures/signInFixture.ts index 42912fc7..47c5b727 100644 --- a/tests/e2e/web/fixtures/signInFixture.ts +++ b/tests/e2e/web/fixtures/signInFixture.ts @@ -1,33 +1,64 @@ -import {expect, Page, test as base} from '@playwright/test' - -import {seedUser} from '../../utils/seedDatabase' +import {test as base} from '@playwright/test' import {AuthPage} from '../pages/AuthPage' -import {config} from '../SPEC_CONFIG' +import {HomePage} from '../pages/homePage' +import {testAccounts, UserAccountInformation} from '../utils/accountInformation' +import { OnboardingPage } from '../pages/onboardingPage' +import { SignUpPage } from '../pages/signUpPage' +import { ProfilePage } from '../pages/profilePage' +import { SettingsPage } from '../pages/settingsPage' export const test = base.extend<{ - authenticatedPage: Page + homePage: HomePage + onboardingPage: OnboardingPage + signUpPage: SignUpPage + profilePage: ProfilePage + settingsPage: SettingsPage + authPage: AuthPage + dev_one_account: UserAccountInformation + fakerAccount: UserAccountInformation + googleAccountOne: UserAccountInformation + googleAccountTwo: UserAccountInformation }>({ - authenticatedPage: async ({page}, use) => { + homePage: async ({page}, use) => { + const homePage = new HomePage(page) + await use(homePage) + }, + onboardingPage: async ({page}, use) => { + const onboardingPage = new OnboardingPage(page) + await use(onboardingPage) + }, + signUpPage: async ({page}, use) => { + const signUpPage = new SignUpPage(page) + await use(signUpPage) + }, + profilePage: async ({page}, use) => { + const profilePage = new ProfilePage(page) + await use(profilePage) + }, + settingsPage: async ({page}, use) => { + const settingsPage = new SettingsPage(page) + await use(settingsPage) + }, + authPage: async ({page}, use) => { const authPage = new AuthPage(page) - - const email = config.USERS.DEV_1.EMAIL - const password = config.USERS.DEV_1.PASSWORD - - try { - await seedUser(email, password) - } catch (_e) { - console.log('User already exists for signinFixture', email) - } - - await page.goto('/signin') - await authPage.fillEmailField(email) - await authPage.fillPasswordField(password) - await authPage.clickSignInWithEmailButton() - - await page.waitForURL(/^(?!.*signin).*$/) - - expect(page.url()).not.toContain('/signin') - - await use(page) + await use(authPage) + }, + dev_one_account: async ({}, use) => { + const account = testAccounts.dev_one_account() + await use(account) + }, + fakerAccount: async ({}, use) => { + const account = testAccounts.faker_account() + await use(account) + }, + googleAccountOne: async ({}, use) => { + const account = testAccounts.google_account_one() + await use(account) + }, + googleAccountTwo: async ({}, use) => { + const account = testAccounts.google_account_two() + await use(account) }, }) + +export {expect} from '@playwright/test' diff --git a/tests/e2e/web/pages/AuthPage.ts b/tests/e2e/web/pages/AuthPage.ts index 36b33ece..7bccdf38 100644 --- a/tests/e2e/web/pages/AuthPage.ts +++ b/tests/e2e/web/pages/AuthPage.ts @@ -7,7 +7,7 @@ export class AuthPage { private readonly emailField: Locator private readonly passwordField: Locator private readonly signInWithEmailButton: Locator - private readonly signInWithGoogleButton: Locator + private readonly googleButton: Locator private readonly signUpWithEmailButton: Locator constructor(public readonly page: Page) { @@ -16,7 +16,7 @@ export class AuthPage { this.emailField = page.getByLabel('Email') this.passwordField = page.getByLabel('Password') this.signInWithEmailButton = page.getByRole('button', {name: 'Sign in with Email'}) - this.signInWithGoogleButton = page.getByRole('button', {name: 'Google'}) + this.googleButton = page.getByRole('button', {name: 'Google'}) this.signUpWithEmailButton = page.getByRole('button', {name: 'Sign up with Email'}) } @@ -35,9 +35,28 @@ export class AuthPage { await this.signInWithEmailButton.click() } - async clickSignInWithGoogleButton() { - await expect(this.signInWithGoogleButton).toBeVisible() - await this.signInWithGoogleButton.click() + async clickGoogleButton() { + await expect(this.googleButton).toBeVisible() + await this.googleButton.click() + } + + async getGooglePopupPage(): Promise { + const [popup] = await Promise.all([ + this.page.context().waitForEvent('page'), + this.clickGoogleButton(), + ]) + await popup.waitForLoadState() + return popup + } + + async signInToGoogleAccount(email: string, display_name?: string, username?: string) { + const popup = await this.getGooglePopupPage() + await popup.getByText('Add new account', {exact: true}).click() + await popup.getByLabel('Email').fill(email) + if (display_name) await popup.getByLabel('Display name').fill(display_name) + if (username) await popup.getByLabel('Screen name', {exact: true}).fill(username) + await popup.getByText('Sign in with Google.com', {exact: true}).click() + await popup.waitForEvent('close') } async clickSignUpWithEmailButton() { diff --git a/tests/e2e/web/pages/homePage.ts b/tests/e2e/web/pages/homePage.ts index 2e417813..1d42c5c4 100644 --- a/tests/e2e/web/pages/homePage.ts +++ b/tests/e2e/web/pages/homePage.ts @@ -2,78 +2,144 @@ import {expect, Locator, Page} from '@playwright/test' import {LocaleTuple} from 'common/constants' export class HomePage { + private readonly sidebar: Locator private readonly homePageLink: Locator - private readonly aboutLink: Locator - private readonly faqLink: Locator - private readonly voteLink: Locator - private readonly eventsLink: Locator - private readonly whatsNewLink: Locator - private readonly socialsLink: Locator - private readonly organizationLink: Locator - private readonly contactLink: Locator + private readonly profileLink: Locator private readonly signUpButton: Locator private readonly localePicker: Locator private readonly signInLink: Locator + private readonly signOutLink: Locator private readonly closeButton: Locator constructor(public readonly page: Page) { - this.homePageLink = page.getByText('Compass', {exact: true}) - this.aboutLink = page.getByTestId('sidebar-about') - this.faqLink = page.getByTestId('sidebar-faq') - this.voteLink = page.getByTestId('sidebar-vote') - this.eventsLink = page.getByTestId('sidebar-events') - this.whatsNewLink = page.getByTestId('sidebar-news') - this.socialsLink = page.getByTestId('sidebar-social') - this.organizationLink = page.getByTestId('sidebar-organization') - this.contactLink = page.getByTestId('sidebar-contact') + this.sidebar = page.getByTestId('sidebar') + this.homePageLink = page.locator('a[href="/home"]') + this.profileLink = page.getByTestId('sidebar-username') this.signUpButton = page.locator('button').filter({hasText: 'Sign up'}).first() this.localePicker = page.getByTestId('sidebar-locale-picker') - this.signInLink = page.getByTestId('sidebar-signin') + this.signInLink = page.locator('a[href="/signin"]').first() + this.signOutLink = page.getByText('Sign out', {exact: true}) this.closeButton = page.getByRole('button', {name: 'Close'}) } - async gotToHomePage() { - await this.page.goto('/') + get sidebarAbout() { + return this.sidebar.getByText('About') + } + + get sidebarFaq() { + return this.sidebar.getByText('FAQ') + } + + get sidebarVote() { + return this.sidebar.getByText('Vote') + } + + get sidebarEvents() { + return this.sidebar.getByText('Events') + } + + get sidebarWhatsNew() { + return this.sidebar.getByText("What's new") + } + + get sidebarSocials() { + return this.sidebar.getByText('Socials') + } + + get sidebarOrganization() { + return this.sidebar.getByText('Organization') + } + + get sidebarSettings() { + return this.sidebar.getByText('Settings') + } + + get sidebarPeople() { + return this.sidebar.getByText('People') + } + + get sidebarNotifs() { + return this.sidebar.getByText('Notifs') + } + + get sidebarMessages() { + return this.sidebar.getByText('Messages') + } + + get sidebarContact() { + return this.sidebar.getByText('Contact') + } + + async goToHomePage() { + await this.page.goto('/home') + } + + async goToRegisterPage() { + await this.page.goto('/register') + } + + async goToSigninPage() { + await this.page.goto('/signin') } async clickAboutLink() { - await expect(this.aboutLink).toBeVisible() - await this.aboutLink.click() + await expect(this.sidebarAbout).toBeVisible() + await this.sidebarAbout.click() } async clickFaqLink() { - await expect(this.faqLink).toBeVisible() - await this.faqLink.click() + await expect(this.sidebarFaq).toBeVisible() + await this.sidebarFaq.click() } async clickVoteLink() { - await expect(this.voteLink).toBeVisible() - await this.voteLink.click() + await expect(this.sidebarVote).toBeVisible() + await this.sidebarVote.click() } async clickEventsLink() { - await expect(this.eventsLink).toBeVisible() - await this.eventsLink.click() + await expect(this.sidebarEvents).toBeVisible() + await this.sidebarEvents.click() } async clickWhatsNewLink() { - await expect(this.whatsNewLink).toBeVisible() - await this.whatsNewLink.click() + await expect(this.sidebarWhatsNew).toBeVisible() + await this.sidebarWhatsNew.click() } async clickSocialsLink() { - await expect(this.socialsLink).toBeVisible() - await this.socialsLink.click() + await expect(this.sidebarSocials).toBeVisible() + await this.sidebarSocials.click() } async clickOrganizationLink() { - await expect(this.organizationLink).toBeVisible() - await this.organizationLink.click() + await expect(this.sidebarOrganization).toBeVisible() + await this.sidebarOrganization.click() } async clickContactLink() { - await expect(this.contactLink).toBeVisible() - await this.contactLink.click() + await expect(this.sidebarContact).toBeVisible() + await this.sidebarContact.click() + } + + async clickSettingsLink() { + await expect(this.sidebarSettings).toBeVisible() + await this.sidebarSettings.click() + } + + async clickPeopleLink() { + await expect(this.sidebarPeople).toBeVisible() + await this.sidebarPeople.click() + } + + async clickNotifsLink() { + await expect(this.sidebarNotifs).toBeVisible() + await this.sidebarNotifs.click() + } + + async clickMessagesLink() { + await expect(this.sidebarMessages).toBeVisible() + await this.sidebarMessages.click() } async clickSignUpButton() { @@ -88,7 +154,44 @@ export class HomePage { } async clickSignInLink() { + await expect(this.sidebar).toBeVisible() + await this.sidebar.getByText('Sign in').click() + } + + async verifyHomePageLinks() { + await expect(this.homePageLink).toBeVisible() + await expect(this.sidebarAbout).toBeVisible() + await expect(this.sidebarFaq).toBeVisible() + await expect(this.sidebarVote).toBeVisible() + await expect(this.sidebarEvents).toBeVisible() + await expect(this.sidebarWhatsNew).toBeVisible() + await expect(this.sidebarSocials).toBeVisible() + await expect(this.sidebarOrganization).toBeVisible() + await expect(this.sidebarContact).toBeVisible() + await expect(this.signUpButton).toBeVisible() await expect(this.signInLink).toBeVisible() - await this.signInLink.click() + await expect(this.localePicker).toBeVisible() + } + + async verifySignedInHomePage(displayName: string) { + await expect(this.homePageLink).toBeVisible() + await expect(this.profileLink).toBeVisible() + await expect(this.profileLink).toContainText(displayName) + await expect(this.sidebarPeople).toBeVisible() + await expect(this.sidebarNotifs).toBeVisible() + await expect(this.sidebarMessages).toBeVisible() + await expect(this.sidebarSettings).toBeVisible() + await expect(this.sidebarAbout).toBeVisible() + await expect(this.sidebarFaq).toBeVisible() + await expect(this.sidebarVote).toBeVisible() + await expect(this.sidebarEvents).toBeVisible() + await expect(this.sidebarWhatsNew).toBeVisible() + await expect(this.sidebarSocials).toBeVisible() + await expect(this.sidebarOrganization).toBeVisible() + await expect(this.sidebarContact).toBeVisible() + await expect(this.signOutLink).toBeVisible() + await expect(this.signUpButton).not.toBeVisible() + await expect(this.signInLink).not.toBeVisible() + await expect(this.localePicker).not.toBeVisible() } } diff --git a/tests/e2e/web/pages/organizationPage.ts b/tests/e2e/web/pages/organizationPage.ts new file mode 100644 index 00000000..9fac71fc --- /dev/null +++ b/tests/e2e/web/pages/organizationPage.ts @@ -0,0 +1,134 @@ +import {expect, Locator, Page} from '@playwright/test' + +export class OrganizationPage { + private readonly pageTitle: Locator + private readonly aboutUsHeading: Locator + private readonly proofAndTransparencyHeading: Locator + private readonly contactAndSupportHeading: Locator + private readonly trustAndLegalHeading: Locator + private readonly aboutCompassLink: Locator + private readonly constitutionLink: Locator + private readonly keyMetricsLink: Locator + private readonly pressLink: Locator + private readonly financialTransparencyLink: Locator + private readonly contactUsLink: Locator + private readonly helpAndSupportLink: Locator + private readonly securityLink: Locator + private readonly termsAndConditionsLink: Locator + private readonly privacyPolicyLink: Locator + + constructor(public readonly page: Page) { + this.pageTitle = page.getByRole('heading', {name: 'Organization'}) + this.aboutUsHeading = page.getByRole('heading', {name: 'About us'}) + this.proofAndTransparencyHeading = page.getByRole('heading', {name: 'Proof & transparency'}) + this.contactAndSupportHeading = page.getByRole('heading', {name: 'Contact & support'}) + this.trustAndLegalHeading = page.getByRole('heading', {name: 'Trust & legal'}) + this.aboutCompassLink = page.getByRole('link', {name: 'About Compass'}) + this.constitutionLink = page.getByRole('link', {name: 'Our constitution'}) + this.keyMetricsLink = page.getByRole('link', {name: 'Key metrics & growth'}) + this.pressLink = page.getByRole('link', {name: 'Press'}) + this.financialTransparencyLink = page.getByRole('link', {name: 'Financial transparency'}) + this.contactUsLink = page.getByRole('link', {name: 'Contact us'}) + this.helpAndSupportLink = page.getByRole('link', {name: 'Help & support center'}) + this.securityLink = page.getByRole('link', {name: 'Security'}) + this.termsAndConditionsLink = page.getByRole('link', {name: 'Terms and conditions'}) + this.privacyPolicyLink = page.getByRole('link', {name: 'Privacy policy'}) + } + + async goToOrganizationPage() { + await this.page.goto('/organization') + } + + async verifyOrganizationPage() { + await expect(this.page).toHaveURL(/\/organization$/) + await expect(this.pageTitle).toBeVisible() + await expect(this.aboutUsHeading).toBeVisible() + await expect(this.proofAndTransparencyHeading).toBeVisible() + await expect(this.contactAndSupportHeading).toBeVisible() + await expect(this.trustAndLegalHeading).toBeVisible() + + await this.verifyOrganizationLinks() + } + + async verifyOrganizationLinks() { + await expect(this.aboutCompassLink).toBeVisible() + await expect(this.aboutCompassLink).toHaveAttribute('href', '/about') + + await expect(this.constitutionLink).toBeVisible() + await expect(this.constitutionLink).toHaveAttribute('href', '/constitution') + + await expect(this.keyMetricsLink).toBeVisible() + await expect(this.keyMetricsLink).toHaveAttribute('href', '/stats') + + await expect(this.pressLink).toBeVisible() + await expect(this.pressLink).toHaveAttribute('href', '/press') + + await expect(this.financialTransparencyLink).toBeVisible() + await expect(this.financialTransparencyLink).toHaveAttribute('href', '/financials') + + await expect(this.contactUsLink).toBeVisible() + await expect(this.contactUsLink).toHaveAttribute('href', '/contact') + + await expect(this.helpAndSupportLink).toBeVisible() + await expect(this.helpAndSupportLink).toHaveAttribute('href', '/help') + + await expect(this.securityLink).toBeVisible() + await expect(this.securityLink).toHaveAttribute('href', '/security') + + await expect(this.termsAndConditionsLink).toBeVisible() + await expect(this.termsAndConditionsLink).toHaveAttribute('href', '/terms') + + await expect(this.privacyPolicyLink).toBeVisible() + await expect(this.privacyPolicyLink).toHaveAttribute('href', '/privacy') + } + + async clickAboutCompassLink() { + await expect(this.aboutCompassLink).toBeVisible() + await this.aboutCompassLink.click() + } + + async clickConstitutionLink() { + await expect(this.constitutionLink).toBeVisible() + await this.constitutionLink.click() + } + + async clickKeyMetricsLink() { + await expect(this.keyMetricsLink).toBeVisible() + await this.keyMetricsLink.click() + } + + async clickPressLink() { + await expect(this.pressLink).toBeVisible() + await this.pressLink.click() + } + + async clickFinancialTransparencyLink() { + await expect(this.financialTransparencyLink).toBeVisible() + await this.financialTransparencyLink.click() + } + + async clickContactUsLink() { + await expect(this.contactUsLink).toBeVisible() + await this.contactUsLink.click() + } + + async clickHelpAndSupportLink() { + await expect(this.helpAndSupportLink).toBeVisible() + await this.helpAndSupportLink.click() + } + + async clickSecurityLink() { + await expect(this.securityLink).toBeVisible() + await this.securityLink.click() + } + + async clickTermsAndConditionsLink() { + await expect(this.termsAndConditionsLink).toBeVisible() + await this.termsAndConditionsLink.click() + } + + async clickPrivacyPolicyLink() { + await expect(this.privacyPolicyLink).toBeVisible() + await this.privacyPolicyLink.click() + } +} diff --git a/tests/e2e/web/pages/settingsPage.ts b/tests/e2e/web/pages/settingsPage.ts new file mode 100644 index 00000000..6c730c8a --- /dev/null +++ b/tests/e2e/web/pages/settingsPage.ts @@ -0,0 +1,159 @@ +import {expect, Locator, Page} from '@playwright/test' +import {LocaleTuple} from 'common/constants' +import {FontsTuple} from 'web/components/font-picker' + +export class SettingsPage { + private readonly localePicker: Locator + private readonly measurementSystemToggle: Locator + private readonly themeToggle: Locator + private readonly fontPicker: Locator + private readonly downloadProfileJSONDataButton: Locator + private readonly manageHiddenProfilesButton: Locator + private readonly hiddenProfilesSection: Locator + private readonly directMessagingPreferenceToggle: Locator + private readonly privateInterestSignalsToggle: Locator + private readonly sendVerificationEmailButton: Locator + private readonly verifiedEmailLink: Locator + private readonly changeEmailButton: Locator + private readonly sendPasswordResetButton: Locator + private readonly deleteAccountButton: Locator + private readonly closeButton: Locator + private readonly cancelButton: Locator + private readonly deleteSurveyModal: Locator + private readonly deleteSurveyReasons: Locator + private readonly deleteSurveyDetails: Locator + + constructor(public readonly page: Page) { + this.localePicker = page.getByTestId('sidebar-locale-picker') + this.measurementSystemToggle = page.getByTestId('measurement-system-toggle') + this.themeToggle = page.getByTestId('settings-dark-light-toggle') + this.fontPicker = page.getByTestId('settings-font-picker') + this.downloadProfileJSONDataButton = page.getByRole('button', { + name: 'Download all my data (JSON)', + }) + this.manageHiddenProfilesButton = page.getByRole('button', {name: 'Manage hidden profiles'}) + this.hiddenProfilesSection = page.getByTestId('hidden-profiles') + this.directMessagingPreferenceToggle = page.getByTestId('settings-direct-message-toggle') + this.privateInterestSignalsToggle = page.getByTestId('settings-private-interest-signal-toggle') + this.sendVerificationEmailButton = page.getByRole('button', {name: 'Send verification email'}) + this.verifiedEmailLink = page.getByRole('button', {name: 'I verified my email'}) // Need method for this + this.changeEmailButton = page.getByRole('button', {name: 'Change email address'}) + this.sendPasswordResetButton = page.getByRole('button', {name: 'Send password reset email'}) + this.deleteAccountButton = page.getByRole('button', {name: 'Delete account'}) + this.closeButton = page.getByRole('button', {name: 'Close'}) + this.cancelButton = page.getByRole('button', {name: 'Cancel'}) + this.deleteSurveyModal = page.getByTestId('delete-survey-modal') + this.deleteSurveyReasons = page.getByTestId('delete-account-survey-reasons') + this.deleteSurveyDetails = page.getByRole('textbox') + } + + async setLocale(locale: LocaleTuple) { + if (!locale) return + await expect(this.localePicker).toBeVisible() + await this.localePicker.selectOption(locale[0]) + } + + async toggleMeasurementSystem() { + await expect(this.measurementSystemToggle).toBeVisible() + await this.measurementSystemToggle.click() + } + + async toggleDisplayTheme() { + await expect(this.themeToggle).toBeVisible() + await this.themeToggle.click() + } + + async setFont(font: FontsTuple) { + if (!font) return + await expect(this.fontPicker).toBeVisible() + await this.fontPicker.selectOption(font[0]) + } + + async clickdownloadProfileDataButton() { + await expect(this.downloadProfileJSONDataButton).toBeVisible() + await this.downloadProfileJSONDataButton.click() + } + + async clickManageHiddenProfilesButton() { + await expect(this.manageHiddenProfilesButton).toBeVisible() + await this.manageHiddenProfilesButton.click() + } + + async clickCancelButton() { + await expect(this.cancelButton).toBeVisible() + await this.cancelButton.click() + } + + async verifyHiddenProfiles(profiles: string[]) { + await expect(this.hiddenProfilesSection).toBeVisible() + for (let i = 0; i < profiles.length; i++) { + try { + await expect( + this.hiddenProfilesSection.getByRole('link', {name: `${profiles[i]}`}), + ).toBeVisible({timeout: 2000}) + } catch (error) { + throw new Error(`Profile ${profiles[i]} has not been hidden`) + } + } + } + + async unhideProfiles(profile: string) { + await expect(this.hiddenProfilesSection).toBeVisible() + const hiddenProfiles = await this.hiddenProfilesSection.count() + let matchIndex = -1 + for (let i = 0; i < hiddenProfiles; i++) { + const target = await this.hiddenProfilesSection.getByRole('link', {name: `${profile}`}) + if (target) { + matchIndex = i + } + } + await this.hiddenProfilesSection + .locator('div') + .nth(matchIndex) + .getByRole('button', {name: 'Unhide'}) + .click() + } + + async toggleDirectMessagingPreferences() { + await expect(this.directMessagingPreferenceToggle).toBeVisible() + await this.directMessagingPreferenceToggle.click() + } + + async togglePrivateInterestSignalsPreferences() { + await expect(this.privateInterestSignalsToggle).toBeVisible() + await this.privateInterestSignalsToggle.click() + } + + async clickSendVerificationEmailButton() { + await expect(this.sendVerificationEmailButton).toBeVisible() + await this.sendVerificationEmailButton.click() + } + + async clickChangeEmailAddressButton() { + await expect(this.changeEmailButton).toBeVisible() + await this.changeEmailButton.click() + } + + async clickSendPasswordResetEmailButton() { + await expect(this.sendPasswordResetButton).toBeVisible() + await this.sendPasswordResetButton.click() + } + + async clickDeleteAccountButton() { + await expect(this.deleteAccountButton).toBeVisible() + await this.deleteAccountButton.click() + } + + async clickCloseButton() { + await expect(this.closeButton).toBeVisible() + await this.closeButton.click() + } + + async fillDeleteAccountSurvey(reason: string) { + await expect(this.deleteSurveyModal).toBeVisible() + await expect(this.deleteSurveyReasons).toBeVisible() + await this.deleteSurveyReasons.locator('div').nth(1).click() + await expect(this.deleteSurveyDetails).toBeVisible() + await this.deleteSurveyDetails.fill(reason) + } +} diff --git a/tests/e2e/web/pages/socialPage.ts b/tests/e2e/web/pages/socialPage.ts new file mode 100644 index 00000000..0da9e612 --- /dev/null +++ b/tests/e2e/web/pages/socialPage.ts @@ -0,0 +1,113 @@ +import {expect, Locator, Page} from '@playwright/test' +import { + discordLink, + githubRepo, + instagramLink, + redditLink, + stoatLink, + supportEmail, + xLink, +} from 'common/constants' + +export class SocialPage { + private readonly pageTitle: Locator + private readonly communityHeading: Locator + private readonly followAndUpdatesHeading: Locator + private readonly developmentHeading: Locator + private readonly contactHeading: Locator + private readonly discordButton: Locator + private readonly redditButton: Locator + private readonly stoatButton: Locator + private readonly xButton: Locator + private readonly instagramButton: Locator + private readonly githubButton: Locator + private readonly emailButton: Locator + + constructor(public readonly page: Page) { + this.pageTitle = page.getByRole('heading', {name: 'Socials'}) + this.communityHeading = page.getByRole('heading', {name: 'Community'}) + this.followAndUpdatesHeading = page.getByRole('heading', {name: 'Follow & Updates'}) + this.developmentHeading = page.getByRole('heading', {name: 'Development'}) + this.contactHeading = page.getByRole('heading', {name: 'Contact'}) + this.discordButton = page.getByRole('link', {name: 'Discord'}) + this.redditButton = page.getByRole('link', {name: 'Reddit'}) + this.stoatButton = page.getByRole('link', {name: 'Revolt / Stoat'}) + this.xButton = page.getByRole('link', {name: 'X'}) + this.instagramButton = page.getByRole('link', {name: 'Instagram'}) + this.githubButton = page.getByRole('link', {name: 'GitHub'}) + this.emailButton = page.getByRole('link', {name: `Email ${supportEmail}`}) + } + + async goToSocialPage() { + await this.page.goto('/social') + } + + async verifySocialPage() { + await expect(this.page).toHaveURL(/\/social$/) + await expect(this.pageTitle).toBeVisible() + await expect(this.communityHeading).toBeVisible() + await expect(this.followAndUpdatesHeading).toBeVisible() + await expect(this.developmentHeading).toBeVisible() + await expect(this.contactHeading).toBeVisible() + + await this.verifySocialLinks() + } + + async verifySocialLinks() { + await expect(this.discordButton).toBeVisible() + await expect(this.discordButton).toHaveAttribute('href', discordLink) + + await expect(this.redditButton).toBeVisible() + await expect(this.redditButton).toHaveAttribute('href', redditLink) + + await expect(this.stoatButton).toBeVisible() + await expect(this.stoatButton).toHaveAttribute('href', stoatLink) + + await expect(this.xButton).toBeVisible() + await expect(this.xButton).toHaveAttribute('href', xLink) + + await expect(this.instagramButton).toBeVisible() + await expect(this.instagramButton).toHaveAttribute('href', instagramLink) + + await expect(this.githubButton).toBeVisible() + await expect(this.githubButton).toHaveAttribute('href', githubRepo) + + await expect(this.emailButton).toBeVisible() + await expect(this.emailButton).toHaveAttribute('href', `mailto:${supportEmail}`) + } + + async clickDiscordButton() { + await expect(this.discordButton).toBeVisible() + await this.discordButton.click() + } + + async clickRedditButton() { + await expect(this.redditButton).toBeVisible() + await this.redditButton.click() + } + + async clickStoatButton() { + await expect(this.stoatButton).toBeVisible() + await this.stoatButton.click() + } + + async clickXButton() { + await expect(this.xButton).toBeVisible() + await this.xButton.click() + } + + async clickInstagramButton() { + await expect(this.instagramButton).toBeVisible() + await this.instagramButton.click() + } + + async clickGitHubButton() { + await expect(this.githubButton).toBeVisible() + await this.githubButton.click() + } + + async clickEmailButton() { + await expect(this.emailButton).toBeVisible() + await this.emailButton.click() + } +} diff --git a/tests/e2e/web/specs/onboardingFlow.spec.ts b/tests/e2e/web/specs/onboardingFlow.spec.ts index a91a3c40..20a4c16a 100644 --- a/tests/e2e/web/specs/onboardingFlow.spec.ts +++ b/tests/e2e/web/specs/onboardingFlow.spec.ts @@ -1,9 +1,9 @@ import {userInformationFromDb} from '../../utils/databaseUtils' -import {progressToRequiredForm} from '../utils/testCleanupHelpers' import {expect, test} from '../fixtures/base' +import {registerWithEmail, skipOnboardingHeadToProfile} from '../utils/testCleanupHelpers' test.describe('when given valid input', () => { - test('should successfully complete the onboarding flow', async ({ + test('should successfully complete the onboarding flow with email', async ({ homePage, onboardingPage, signUpPage, @@ -12,13 +12,9 @@ test.describe('when given valid input', () => { onboardingAccount, }) => { console.log( - `Starting "should successfully complete the onboarding flow" with ${onboardingAccount.username}`, + `Starting "should successfully complete the onboarding flow with email" with ${onboardingAccount.username}`, ) - await homePage.gotToHomePage() - await homePage.clickSignUpButton() - await authPage.fillEmailField(onboardingAccount.email) - await authPage.fillPasswordField(onboardingAccount.password) - await authPage.clickSignUpWithEmailButton() + await registerWithEmail(homePage, authPage, onboardingAccount) await onboardingPage.clickContinueButton() //First continue await onboardingPage.clickContinueButton() //Second continue await onboardingPage.clickGetStartedButton() @@ -225,6 +221,38 @@ test.describe('when given valid input', () => { ) }) + test('should successfully complete the onboarding flow with google account', async ({ + homePage, + onboardingPage, + signUpPage, + authPage, + profilePage, + googleAccountOne, + headless, + }) => { + console.log( + `Starting "should successfully complete the onboarding flow with google account" with ${googleAccountOne.username}`, + ) + test.skip(headless, 'Google popup auth test requires headed mode') + await homePage.goToRegisterPage() + await authPage.fillPasswordField('') //The test only passes when this is added...something is weird here + await authPage.signInToGoogleAccount( + googleAccountOne.email, + googleAccountOne.display_name, + googleAccountOne.username, + ) + await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, googleAccountOne) + + //Verify displayed information is correct + await profilePage.verifyDisplayName(googleAccountOne.display_name) + + //Verify database info + const dbInfo = await userInformationFromDb(googleAccountOne) + + await expect(dbInfo.user.name).toContain(googleAccountOne.display_name) + await expect(dbInfo.user.username).toContain(googleAccountOne.username) + }) + test('should successfully skip the onboarding flow', async ({ homePage, onboardingPage, @@ -236,13 +264,8 @@ test.describe('when given valid input', () => { console.log( `Starting "should successfully skip the onboarding flow" with ${fakerAccount.username}`, ) - await progressToRequiredForm(homePage, authPage, fakerAccount, onboardingPage) - await signUpPage.fillDisplayName(fakerAccount.display_name) - await signUpPage.fillUsername(fakerAccount.username) - await signUpPage.clickNextButton() - await signUpPage.clickNextButton() //Skip optional information - await profilePage.clickCloseButton() - await onboardingPage.clickRefineProfileButton() + await registerWithEmail(homePage, authPage, fakerAccount) + await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount) //Verify displayed information is correct await profilePage.verifyDisplayName(fakerAccount.display_name) @@ -265,13 +288,8 @@ test.describe('when given valid input', () => { console.log( `Starting "should successfully enter optional information after completing flow" with ${fakerAccount.username}`, ) - await progressToRequiredForm(homePage, authPage, fakerAccount, onboardingPage) - await signUpPage.fillDisplayName(fakerAccount.display_name) - await signUpPage.fillUsername(fakerAccount.username) - await signUpPage.clickNextButton() - await signUpPage.clickNextButton() //Skip optional information - await profilePage.clickCloseButton() - await onboardingPage.clickRefineProfileButton() + await registerWithEmail(homePage, authPage, fakerAccount) + await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount) await profilePage.clickEditProfileButton() await signUpPage.chooseGender(fakerAccount.gender) await signUpPage.fillAge(fakerAccount.age) @@ -313,7 +331,8 @@ test.describe('when given valid input', () => { console.log( `Starting "should successfully use the start answering option" with ${fakerAccount.username}`, ) - await progressToRequiredForm(homePage, authPage, fakerAccount, onboardingPage) + await registerWithEmail(homePage, authPage, fakerAccount) + await onboardingPage.clickSkipOnboardingButton() await signUpPage.fillDisplayName(fakerAccount.display_name) await signUpPage.fillUsername(fakerAccount.username) await signUpPage.clickNextButton() @@ -338,22 +357,16 @@ test.describe('when given valid input', () => { }) test.describe('should successfully complete the onboarding flow after using the back button', () => { - test.beforeEach(async ({homePage, authPage, fakerAccount}) => { - console.log(`Before each with ${fakerAccount.username}`) - await homePage.gotToHomePage() - await homePage.clickSignUpButton() - await authPage.fillEmailField(fakerAccount.email) - await authPage.fillPasswordField(fakerAccount.password) - await authPage.clickSignUpWithEmailButton() - }) - test("the first time it's an option", async ({ + homePage, + authPage, onboardingPage, signUpPage, profilePage, fakerAccount, }) => { console.log(`Starting "the first time its an option" with ${fakerAccount.username}`) + await registerWithEmail(homePage, authPage, fakerAccount) await onboardingPage.clickContinueButton() await onboardingPage.clickBackButton() await onboardingPage.clickContinueButton() @@ -377,12 +390,15 @@ test.describe('when given valid input', () => { }) test("the second time it's an option", async ({ + homePage, + authPage, onboardingPage, signUpPage, profilePage, fakerAccount, }) => { console.log(`Starting "the second time its an option" with ${fakerAccount.username}`) + await registerWithEmail(homePage, authPage, fakerAccount) await onboardingPage.clickContinueButton() await onboardingPage.clickContinueButton() await onboardingPage.clickBackButton() @@ -407,6 +423,6 @@ test.describe('when given valid input', () => { }) }) -test.describe('when an error occurs', () => { - test('placeholder', async () => {}) -}) +// test.describe('when an error occurs', () => { +// test('placeholder', async ({}) => {}) +// }) diff --git a/tests/e2e/web/specs/postSignIn.spec.ts b/tests/e2e/web/specs/postSignIn.spec.ts deleted file mode 100644 index fb4c478b..00000000 --- a/tests/e2e/web/specs/postSignIn.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {expect} from '@playwright/test' - -import {test} from '../fixtures/signInFixture' - -test('should be logged in and see settings page', async ({authenticatedPage}) => { - await authenticatedPage.goto('/settings') - - await expect(authenticatedPage.getByRole('heading', {name: 'Theme'})).toBeVisible() -}) diff --git a/tests/e2e/web/specs/signIn.spec.ts b/tests/e2e/web/specs/signIn.spec.ts new file mode 100644 index 00000000..9261422c --- /dev/null +++ b/tests/e2e/web/specs/signIn.spec.ts @@ -0,0 +1,120 @@ +import { userInformationFromDb } from '../../utils/databaseUtils' +import {seedUser} from '../../utils/seedDatabase' +import {expect, test} from '../fixtures/signInFixture' +import {testAccounts} from '../utils/accountInformation' +import { + deleteProfileFromSettings, + registerWithEmail, + signinWithEmail, + skipOnboardingHeadToProfile +} from '../utils/testCleanupHelpers' + +//Seed the account +test.beforeAll(async () => { + const dev_1_Account = testAccounts.dev_one_account() + try { + await seedUser( + dev_1_Account.email, + dev_1_Account.password, + undefined, + dev_1_Account.display_name, + dev_1_Account.username, + ) + } catch (_e) { + console.log('User already exists for signinFixture', dev_1_Account.email) + } +}) + +test.describe('when given valid input', () => { + test('should be able to sign in to an available account', async ({ + homePage, + authPage, + dev_one_account, + }) => { + console.log( + `Starting "should be able to sign in to an available account" with ${dev_one_account.username}`, + ) + await signinWithEmail(homePage, authPage, dev_one_account) + await homePage.goToHomePage() + await homePage.verifySignedInHomePage(dev_one_account.display_name) + }) + + test('should successfully delete an account created via email and password', async ({ + homePage, + onboardingPage, + signUpPage, + authPage, + profilePage, + settingsPage, + fakerAccount, + }) => { + console.log( + `Starting "should successfully delete an account created via email and password" with ${fakerAccount.username}`, + ) + await registerWithEmail(homePage, authPage, fakerAccount) + await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount) + + //Verify displayed information is correct + await profilePage.verifyDisplayName(fakerAccount.display_name) + + //Verify database info + const dbInfo = await userInformationFromDb(fakerAccount) + + await expect(dbInfo.user.name).toContain(fakerAccount.display_name) + await expect(dbInfo.user.username).toContain(fakerAccount.username) + + await deleteProfileFromSettings(homePage, settingsPage) + }) + + test('should successfully delete an account created via google auth', async ({ + homePage, + onboardingPage, + signUpPage, + authPage, + profilePage, + settingsPage, + googleAccountTwo, + headless, + }) => { + console.log( + `Starting "should successfully delete an account created via google auth" with ${googleAccountTwo.username}`, + ) + test.skip(headless, 'Google popup auth test requires headed mode') + await homePage.goToRegisterPage() + await authPage.fillPasswordField('') //The test only passes when this is added...something is weird here + await authPage.signInToGoogleAccount( + googleAccountTwo.email, + googleAccountTwo.display_name, + googleAccountTwo.username, + ) + await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, googleAccountTwo) + + //Verify displayed information is correct + await profilePage.verifyDisplayName(googleAccountTwo.display_name) + + //Verify database info + const dbInfo = await userInformationFromDb(googleAccountTwo) + + await expect(dbInfo.user.name).toContain(googleAccountTwo.display_name) + await expect(dbInfo.user.username).toContain(googleAccountTwo.username) + + await deleteProfileFromSettings(homePage, settingsPage) + }) +}) + +test.describe('when given invalid input', () => { + test('should not be able to sign in to an available account', async ({ + homePage, + authPage, + dev_one_account, + page, + }) => { + console.log( + `Starting "should not be able to sign in to an available account" with ${dev_one_account.username}`, + ) + await signinWithEmail(homePage, authPage, dev_one_account.email, 'ThisPassword') + await expect( + page.getByText('Failed to sign in with your email and password', {exact: true}), + ).toBeVisible() + }) +}) diff --git a/tests/e2e/web/specs/signUp.spec.ts b/tests/e2e/web/specs/signUp.spec.ts index 948f546d..aac82b12 100644 --- a/tests/e2e/web/specs/signUp.spec.ts +++ b/tests/e2e/web/specs/signUp.spec.ts @@ -1,5 +1,5 @@ import {expect, test} from '../fixtures/base' -import {progressToRequiredForm} from '../utils/testCleanupHelpers' +import {registerWithEmail} from '../utils/testCleanupHelpers' test.describe('when given valid input', () => { test('placeholder', async () => {}) @@ -13,7 +13,11 @@ test.describe('when an error occurs', () => { onboardingPage, signUpPage, }) => { - await progressToRequiredForm(homePage, authPage, specAccount, onboardingPage) + console.log( + `Starting "should disable the button "Next" when the display name field is empty" with ${specAccount.username}`, + ) + await registerWithEmail(homePage, authPage, specAccount) + await onboardingPage.clickSkipOnboardingButton() await signUpPage.fillDisplayName('') await signUpPage.fillUsername(specAccount.username) await signUpPage.verifyDisplayNameError() @@ -27,7 +31,11 @@ test.describe('when an error occurs', () => { onboardingPage, signUpPage, }) => { - await progressToRequiredForm(homePage, authPage, specAccount, onboardingPage) + console.log( + `Starting "should disable the button "Next" when the username field is empty" with ${specAccount.username}`, + ) + await registerWithEmail(homePage, authPage, specAccount) + await onboardingPage.clickSkipOnboardingButton() await signUpPage.fillDisplayName(specAccount.display_name) await signUpPage.fillUsername('') await signUpPage.verifyUsernameError() diff --git a/tests/e2e/web/utils/accountInformation.ts b/tests/e2e/web/utils/accountInformation.ts index a8bb3fdb..bbb6b81f 100644 --- a/tests/e2e/web/utils/accountInformation.ts +++ b/tests/e2e/web/utils/accountInformation.ts @@ -102,7 +102,9 @@ type AccountConfig = { spec_account: () => UserAccountInformation dev_one_account: () => UserAccountInformation dev_two_account: () => UserAccountInformation - account_all_info: () => UserAccountInformation + google_account_one: () => UserAccountInformation + google_account_two: () => UserAccountInformation + email_account_all_info: () => UserAccountInformation } export const testAccounts: AccountConfig = { @@ -137,10 +139,10 @@ export const testAccounts: AccountConfig = { dev_one_account: () => { const id = crypto.randomUUID().slice(0, 6) return { - email: `dev_1_${id}@compass.com`, + email: `dev_1@compass.com`, password: 'dev_1Password', display_name: 'Dev1.Compass', - username: `Dev1.Connections_${id}`, + username: `Dev1.Connections`, } }, @@ -150,11 +152,31 @@ export const testAccounts: AccountConfig = { email: 'dev_2@compass.com', password: 'dev_2Password', display_name: 'Dev2.Compass', - username: `Dev2.Connections_${id}`, + username: `Dev2.Connections_`, } }, - account_all_info: () => { + google_account_one: () => { + const id = crypto.randomUUID().slice(0, 6) + return { + email: `g1_compass_${id}@gmail.com`, + password: 'G_oneCompassTest', + display_name: 'Google_one_Compass', + username: `G1_Connect_${id}`, + } + }, + + google_account_two: () => { + const id = crypto.randomUUID().slice(0, 6) + return { + email: `g2_compass_${id}@gmail.com`, + password: 'G_twoCompassTest', + display_name: 'Google_two_Compass', + username: `G2_Connect_${id}`, + } + }, + + email_account_all_info: () => { const id = crypto.randomUUID().slice(0, 6) return { // Use a non-real TLD like @test.compass to make it obvious these are test accounts and prevent accidental emails diff --git a/tests/e2e/web/utils/deleteUser.ts b/tests/e2e/web/utils/deleteUser.ts index 96968b22..d7376fe3 100644 --- a/tests/e2e/web/utils/deleteUser.ts +++ b/tests/e2e/web/utils/deleteUser.ts @@ -1,18 +1,31 @@ import {deleteFromDb} from '../../utils/databaseUtils' -import {deleteAccount, firebaseLogin} from '../../utils/firebaseUtils' +import {deleteAccount, firebaseLoginEmailPassword} from '../../utils/firebaseUtils' +import {UserAccountInformation} from './accountInformation' +import {AuthObject} from './networkUtils' -export async function deleteUser(email: string, password: string) { +type AuthType = 'Email/Password' | 'Google' +export async function deleteUser( + authType: AuthType, + account?: UserAccountInformation, + authInfo?: AuthObject, +) { try { - const loginInfo = await firebaseLogin(email, password) - await deleteAccount(loginInfo) - await deleteFromDb(loginInfo.data.localId) + let loginInfo + if (authType === 'Email/Password') { + loginInfo = await firebaseLoginEmailPassword(account?.email, account?.password) + await deleteAccount(loginInfo?.data.idToken) + await deleteFromDb(loginInfo?.data.localId) + } else if (authType === 'Google' && authInfo) { + await deleteAccount(authInfo.idToken) + await deleteFromDb(authInfo.localId) + } } catch (err: any) { // Skip deletion if user doesn't exist or other auth errors occur if ( err.response?.status === 400 || err.response?.data?.error?.message?.includes('EMAIL_NOT_FOUND') ) { - console.log(`Email not found, skipping user deletion for ${email}`) + console.log(`Email not found, skipping user deletion for ${account?.email}`) return } console.log(err) diff --git a/tests/e2e/web/utils/networkUtils.ts b/tests/e2e/web/utils/networkUtils.ts new file mode 100644 index 00000000..6c619675 --- /dev/null +++ b/tests/e2e/web/utils/networkUtils.ts @@ -0,0 +1,25 @@ +import {Page} from '@playwright/test' + +export type AuthObject = { + idToken: string + localId: string +} + +export async function getAuthAccountInfo(page: Page): Promise<() => AuthObject> { + let accountIdTokenAndLocalId: AuthObject | undefined + + await page.route('**/accounts:signInWithIdp**', async (route) => { + const response = await route.fetch() + const body = await response.json() + accountIdTokenAndLocalId = {idToken: body.idToken, localId: body.localId} + await route.fulfill({response}) + }) + + return () => { + if (!accountIdTokenAndLocalId) { + console.log('Sign-in was never intercepted — test may have been skipped') + return undefined as unknown as AuthObject + } + return accountIdTokenAndLocalId + } +} diff --git a/tests/e2e/web/utils/testCleanupHelpers.ts b/tests/e2e/web/utils/testCleanupHelpers.ts index 1e11d794..c022028d 100644 --- a/tests/e2e/web/utils/testCleanupHelpers.ts +++ b/tests/e2e/web/utils/testCleanupHelpers.ts @@ -1,18 +1,64 @@ import {AuthPage} from '../pages/AuthPage' import {HomePage} from '../pages/homePage' -import {OnboardingPage} from '../pages/onboardingPage' +import { OnboardingPage } from '../pages/onboardingPage' +import { ProfilePage } from '../pages/profilePage' +import { SettingsPage } from '../pages/settingsPage' +import { SignUpPage } from '../pages/signUpPage' import {UserAccountInformation} from '../utils/accountInformation' -export async function progressToRequiredForm( +export async function registerWithEmail( homePage: HomePage, authPage: AuthPage, account: UserAccountInformation, - onboardingPage: OnboardingPage, ) { - await homePage.gotToHomePage() - await homePage.clickSignUpButton() + await homePage.goToRegisterPage() await authPage.fillEmailField(account.email) await authPage.fillPasswordField(account.password) await authPage.clickSignUpWithEmailButton() - await onboardingPage.clickSkipOnboardingButton() } + +export async function signinWithEmail( + homePage: HomePage, + authPage: AuthPage, + accountOrEmail: UserAccountInformation | string, + password?: string, +) { + const email = typeof accountOrEmail === 'string' ? accountOrEmail : accountOrEmail.email + + const resolvedPassword = typeof accountOrEmail === 'string' ? password : accountOrEmail.password + + if (!email || !resolvedPassword) { + throw new Error('Provide either an `account` or `email` and `password`.') + } + + await homePage.goToSigninPage() + await authPage.fillEmailField(email) + await authPage.fillPasswordField(resolvedPassword) + await authPage.clickSignInWithEmailButton() +} + +export async function skipOnboardingHeadToProfile( + onboardingPage: OnboardingPage, + signUpPage: SignUpPage, + profilePage: ProfilePage, + account: UserAccountInformation, +) { + await onboardingPage.clickSkipOnboardingButton() + await signUpPage.fillDisplayName(account.display_name) + await signUpPage.fillUsername(account.username) + await signUpPage.clickNextButton() + await signUpPage.clickNextButton() + await profilePage.clickCloseButton() + await onboardingPage.clickRefineProfileButton() +} + +export async function deleteProfileFromSettings( + homePage: HomePage, + settingsPage: SettingsPage, +) { + await homePage.clickSettingsLink() + await settingsPage.clickDeleteAccountButton() + await settingsPage.fillDeleteAccountSurvey('Delete me') + await settingsPage.clickDeleteAccountButton() + await homePage.verifyHomePageLinks() +} \ No newline at end of file diff --git a/web/components/connection-preferences-settings.tsx b/web/components/connection-preferences-settings.tsx index 82784615..3da3a643 100644 --- a/web/components/connection-preferences-settings.tsx +++ b/web/components/connection-preferences-settings.tsx @@ -67,6 +67,7 @@ export function ConnectionPreferencesSettings() { ) => setFont(e.target.value as FontOption)} @@ -35,3 +36,8 @@ export function FontPicker(props: {className?: string} = {}) { ) } + +//Exported types for test files to use when referencing the keys of the choices objects +export type FontsTuple = { + [K in keyof typeof EN_TRANSLATIONS]: [K, (typeof EN_TRANSLATIONS)[K]] +}[keyof typeof EN_TRANSLATIONS] diff --git a/web/components/measurement-system-toggle.tsx b/web/components/measurement-system-toggle.tsx index 9d70b482..3bddc392 100644 --- a/web/components/measurement-system-toggle.tsx +++ b/web/components/measurement-system-toggle.tsx @@ -18,6 +18,7 @@ export default function MeasurementSystemToggle(props: {className?: string}) { setMeasurementSystem(enabled ? 'metric' : 'imperial')} className={clsx( diff --git a/web/components/nav/profile-summary.tsx b/web/components/nav/profile-summary.tsx index 3e3f7fed..e8dd8700 100644 --- a/web/components/nav/profile-summary.tsx +++ b/web/components/nav/profile-summary.tsx @@ -22,7 +22,7 @@ export function ProfileSummary(props: {user: User; className?: string}) {
-
+
{user.name}
diff --git a/web/components/nav/sidebar-item.tsx b/web/components/nav/sidebar-item.tsx index 23b78bc5..8aeb5b04 100644 --- a/web/components/nav/sidebar-item.tsx +++ b/web/components/nav/sidebar-item.tsx @@ -54,7 +54,6 @@ export function SidebarItem(props: {item: Item; currentPage?: string}) { return ( - + {t('delete_survey.title', 'Sorry to see you go')}
@@ -157,7 +157,7 @@ export function DeleteAccountSurveyModal() { {t('delete_survey.reason_label', 'Why are you deleting your account?')} -
+
{Object.entries(reasonsMap).map(([key, value]) => ( {hiddenProfiles && hiddenProfiles.length > 0 && ( - + {hiddenProfiles.map((u) => ( diff --git a/web/components/switch-setting.tsx b/web/components/switch-setting.tsx index 95063227..36c8ea2c 100644 --- a/web/components/switch-setting.tsx +++ b/web/components/switch-setting.tsx @@ -9,10 +9,11 @@ export const SwitchSetting = (props: { label?: 'Web' | 'Email' | 'Mobile' disabled: boolean colorMode?: ToggleColorMode + testId?: string }) => { - const {colorMode, checked, onChange, label, disabled} = props + const {colorMode, checked, onChange, label, disabled, testId} = props return ( - + ) return ( -