diff --git a/package.json b/package.json index 05da2345a..6f9f1933a 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "setRole": "ts-node -P tsconfig.script.json scripts/firebase-admin/setRole.ts" }, "engines": { - "node": ">=20", + "node": "^20", "yarn": "^1.22.19" }, "browserslist": { diff --git a/playwright.config.ts b/playwright.config.ts index f58a111fe..7cd13df76 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 1 : 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -46,15 +46,15 @@ export default defineConfig({ use: { ...devices["Desktop Firefox"] } - }, - - { - name: "webkit", - use: { - ...devices["Desktop Safari"] - } } + // { + // name: "webkit", + // use: { + // ...devices["Desktop Safari"] + // } + // } + /* Test against mobile viewports. */ // { // name: 'Mobile Chrome', diff --git a/tests/e2e/browse-bills.spec.ts b/tests/e2e/browse-bills.spec.ts index 84303aec2..a409e6075 100644 --- a/tests/e2e/browse-bills.spec.ts +++ b/tests/e2e/browse-bills.spec.ts @@ -2,8 +2,9 @@ import { test, expect } from "@playwright/test" import { BillPage } from "./page_objects/billPage" test.beforeEach(async ({ page }) => { - await page.goto("http://localhost:3000/bills") - await page.waitForSelector("li.ais-Hits-item a") + const billpage = new BillPage(page) + await billpage.goto() + await billpage.removePresetCourtfilter() }) test.describe("Search result test", () => { @@ -11,13 +12,14 @@ test.describe("Search result test", () => { const billpage = new BillPage(page) const searchTerm = billpage.searchWord - const resultCount = billpage.resultCount - const initialResultCount = await resultCount.textContent() await billpage.search(searchTerm) - const searchResultCount = await resultCount.textContent() - await expect(searchResultCount).not.toBe(initialResultCount) + await expect(billpage.queryFilter).toBeVisible() + + await expect(billpage.queryFilter).toContainText(searchTerm) + + await expect(billpage.firstBill).toBeVisible() }) test("should show search query", async ({ page }) => { @@ -30,7 +32,7 @@ test.describe("Search result test", () => { const queryFilter = await billpage.queryFilter - await expect(queryFilter).toContainText("query:") + await expect(queryFilter).toContainText("Query:") await expect(queryFilter).toContainText(searchTerm) }) @@ -50,7 +52,7 @@ test.describe("Search result test", () => { const searchTerm = "nonexistentsearchterm12345" const billpage = new BillPage(page) - billpage.search(searchTerm) + await billpage.search(searchTerm) const noResultsText = await page.getByText("Looks Pretty Empty Here") const noResultsImg = page.getByAltText("No Results") @@ -118,7 +120,7 @@ test.describe("Filter Bills test", () => { "%27" ) await expect(page).toHaveURL( - new RegExp(`court%5D%5B1%5D=${encodedFilterLabel}`) + new RegExp(`court%5D%5B0%5D=${encodedFilterLabel}`) ) }) diff --git a/tests/e2e/editProfile.spec.ts b/tests/e2e/editProfile.spec.ts new file mode 100644 index 000000000..9c53d134e --- /dev/null +++ b/tests/e2e/editProfile.spec.ts @@ -0,0 +1,283 @@ +import { test, expect, Page, Locator, Browser } from "@playwright/test" +import { EditProfilePage } from "./page_objects/editProfilePage" +import { userLogin, setupPage } from "./utils/login" +import { removeSpecialChar } from "./utils/removeSpecialChar" +import { gotoStable } from "./utils/goto" + +require("dotenv").config() + +/** + * @param USER_EMAIL + * @param USER_PASSWORD + * @returns + */ + +const USER_EMAIL = process.env.TEST_USER_USERNAME +const USER_PASSWORD = process.env.TEST_USER_PASSWORD + +test.describe.serial("Edit Page", () => { + test("Prevents User A from editing User B profile", async ({ browser }) => { + /* + Logs in user and stores session state + */ + const context = await browser.newContext() + const page = await context.newPage() + try { + let userAStateObject: { cookies: any[]; origins: any[] } //stores current user/userA sesssion state + + await userLogin(page, USER_EMAIL, USER_PASSWORD) + + const url = "http://localhost:3000/edit-profile/about-you" + await gotoStable(page, url) + + userAStateObject = await page.context().storageState() // save userA's session state + + /* + Prevents user A from manipulating url to access userB's profile + */ + const userBID = "d6ZFKTVH8i4hglyv42wz8QDaKKxw" //from test3@example.com test account on firebase + const attackUrl = `http://localhost:3000/profile?id=${userBID}` //manipulating url with userB's id + const attackPath = new RegExp(`.*\\/profile\\?id=${userBID}`) //manipulating path + + await gotoStable(page, attackUrl) + + await expect(page).not.toHaveURL(/.*\/edit-profile\/about-you/) //asserts that is doesn't navigate to userB's edit profile page + + await page.waitForURL(attackPath) + await expect(page).toHaveURL(attackPath) // asserts that the manipualted URL path is the view profile page for user B + + await expect(page.getByText("404")).toBeVisible() // asserts that the page shows error messages + await expect(page.getByText("This page could not be found")).toBeVisible() + } finally { + await context.close() + } + }) + + test.only("Assures user can add to blank profile page", async ({ + browser + }) => { + /* + Fills blank fields with same sample data and + confirms edits were saved. + */ + const { page, context } = await setupPage(browser) + + const editPage = new EditProfilePage(page) + + await userLogin(page, USER_EMAIL, USER_PASSWORD) + + const url = "http://localhost:3000/edit-profile/about-you" + await gotoStable(page, url) + + //sample input + const sampleName = "John Doe" + const sampleText = "I ❤️ politics" + const sampleTwitter = "jdoe" + const sampleLinkedIn = "https://www.linkedIn.com/in/jdoe" + const sampleRepresentative = "Alan Silvia" + // const sampleRepresentative = "Aaron L. Saunders" //NOTE: names with middle initials need a space after initial + const sampleSenator = "Adam Gomez" + // const sampleSenator = "Bruce E. Tarr" + + // clear and fill with sample data + await expect(editPage.editName).toHaveCount(1, { timeout: 100_000 }) + await expect(editPage.editName).toBeVisible({ timeout: 50_000 }) + await expect(editPage.editName).toBeEnabled() + await editPage.editName.waitFor({ state: "attached", timeout: 50_000 }) + await editPage.editName.click() + await editPage.editName.fill("") + await editPage.editName.fill(sampleName) + await expect(editPage.editName).toHaveValue(sampleName, { timeout: 50_000 }) + await editPage.editWriteAboutSelf.clear() + await editPage.editWriteAboutSelf.fill(sampleText) + await editPage.editTwitterUsername.clear() + await editPage.editTwitterUsername.fill(sampleTwitter) + await editPage.editLinkedInUrl.clear() + await editPage.editLinkedInUrl.fill(sampleLinkedIn) + await editPage.editRepresentative.pressSequentially(sampleRepresentative, { + delay: 50 + }) + await page.waitForTimeout(50) + await page.keyboard.press("Enter") + await editPage.editSenator.pressSequentially(sampleSenator, { delay: 50 }) + await page.waitForTimeout(50) + await page.keyboard.press("Enter") + + // save + await editPage.saveChangesButton.click() + await page.keyboard.press("Enter") //save-button activated -rerouting to profile page + + //assertion: Assure it saved + //name + await expect(page.getByText(sampleName)).toHaveText(sampleName, { + timeout: 50000 + }) + //(about) text + await expect(page.getByText(sampleText, { exact: true })).toHaveText( + sampleText + ) + //representative + const repRow = page.locator( + 'div:has(> .main-text:has-text("Representative"))' + ) + await expect(repRow.locator("p.sub-text")).toContainText( + sampleRepresentative + ) + + //senator + const senRow = page + .locator(".main-text", { hasText: "Senator" }) + .locator('xpath=following-sibling::p[contains(@class,"sub-text")]') + await removeSpecialChar(senRow, sampleSenator) + + //twitter + const sampleTwitterNewPage = "jdoe" + + const twitterLink = page.locator('a:has(img[alt="Twitter"])') + + await expect(twitterLink).toHaveAttribute( + "href", + new RegExp(sampleTwitterNewPage) + ) + + await context.close() + }) + + test("User can enable and disable notification settings", async ({ + browser + }) => { + const { page, context } = await setupPage(browser) + const editPage = new EditProfilePage(page) + + await userLogin(page, USER_EMAIL, USER_PASSWORD) + + const url = "http://localhost:3000/edit-profile/about-you" + await gotoStable(page, url) + + /** + * @param page + * @param saveButton The save button in notifications + */ + const saveButton = page.getByRole("button", { name: "Save", exact: true }) + + async function toggleEnabledButton(page: Page, saveButton: Locator) { + const enableButton = page.getByRole("button", { name: "Enable" }) + const disableButton = page.getByRole("button", { name: "Enabled" }) + const settingsButton = page.getByRole("button", { + name: "settings", + exact: true + }) + + await expect(settingsButton).toBeEnabled() + + await settingsButton.click() + + if (await enableButton.isVisible()) { + await enableButton.click() + + await disableButton.isVisible() + + await saveButton.click() + + //verify + await page + .getByRole("button", { name: "settings", exact: true }) + .click() + + await disableButton.isVisible() + } else if (await disableButton.isVisible()) { + await disableButton.click() + + await enableButton.isVisible() + + await saveButton.click() + + //verify + + await page + .getByRole("button", { name: "settings", exact: true }) + .click() + + expect(enableButton).toBeVisible({ timeout: 15000 }) + } else { + throw new Error("Unable to locate enable/d button") + } + } + try { + await toggleEnabledButton(page, saveButton) + } finally { + await context.close() + } + }) + + test("User can toggle private/public settings and save them", async ({ + browser + }) => { + const { page, context } = await setupPage(browser) + + await userLogin(page, USER_EMAIL, USER_PASSWORD) + + const url = "http://localhost:3000/edit-profile/about-you" + await gotoStable(page, url) + + const saveButton = page.getByRole("button", { name: "Save", exact: true }) + + /** + * @param page + * @param saveButton The save button in notifications + */ + async function togglePrivacyButton(page: Page, saveButton: Locator) { + const makePublic = page.getByRole("button", { name: "Make Public" }) + const makePrivate = page.getByRole("button", { name: "Make Private" }) + const settingsButton = page.getByRole("button", { + name: "settings", + exact: true + }) + + await expect(settingsButton).toBeEnabled({ timeout: 10000 }) + + await settingsButton.click() + + // ----default is private--- + + // test button toggling + if (await makePublic.isVisible({ timeout: 20000 })) { + await makePublic.click() + + await makePrivate.isVisible({ timeout: 20000 }) + } else if (await makePrivate.isVisible({ timeout: 20000 })) { + await makePrivate.click() + + await makePublic.isVisible({ timeout: 20000 }) + } else { + throw Error( + "Public/Private Button is not visable or toggling correctly" + ) + } + + // Verify public/private state gets saved + if (await makePrivate.isVisible({ timeout: 20000 })) { + await saveButton.click() + + await page + .getByRole("button", { name: "settings", exact: true }) + .click() + + await expect(makePrivate).toBeVisible({ timeout: 20000 }) + } else if (await makePublic.isVisible({ timeout: 20000 })) { + await saveButton.click() + + await page + .getByRole("button", { name: "settings", exact: true }) + .click() + + await expect(makePublic).toBeVisible({ timeout: 10000 }) + } + } + try { + await togglePrivacyButton(page, saveButton) + } finally { + await context.close() + } + }) +}) diff --git a/tests/e2e/page_objects/billPage.ts b/tests/e2e/page_objects/billPage.ts index b4f77131f..62adcf24f 100644 --- a/tests/e2e/page_objects/billPage.ts +++ b/tests/e2e/page_objects/billPage.ts @@ -12,6 +12,7 @@ export class BillPage { readonly currentCategorySelector: string readonly basicCategorySelector: string readonly billPageBackToList: Locator + readonly resultsCountText: Locator constructor(page: Page) { this.page = page @@ -26,23 +27,21 @@ export class BillPage { "li:nth-child(2) input.ais-RefinementList-checkbox" this.currentCategorySelector = ".ais-CurrentRefinements-item" this.basicCategorySelector = "div.ais-RefinementList.mb-4" + this.resultsCountText = page.getByText("Results").first() } async goto() { await this.page.goto("http://localhost:3000/bills") - await this.page.waitForSelector("li.ais-Hits-item a") + await this.resultCount.waitFor({ state: "visible", timeout: 30000 }) + // await this.page.waitForSelector("li.ais-Hits-item a",{timeout:90000}) } async search(query: string) { - const initialResult = await this.firstBill.textContent() + await this.searchBar.focus() await this.searchBar.fill(query) - await this.page.waitForFunction(initialResult => { - const searchResult = document.querySelector("li.ais-Hits-item a") - return ( - !searchResult || - (searchResult && searchResult.textContent != initialResult) - ) - }, initialResult) + const activeQueryFilter = this.page.getByText(`Query: ${query}`).first() + + await activeQueryFilter.waitFor({ state: "visible", timeout: 50000 }) } async sort(option: string) { @@ -152,4 +151,17 @@ export class BillPage { return filterLabel } + + async removePresetCourtfilter() { + const activeCourtCheckbox = this.page + .locator("div, span, label", { has: this.page.getByText(/Court/i) }) + .getByRole("checkbox", { checked: true }) + + await activeCourtCheckbox.click({ noWaitAfter: true, timeout: 0 }) + + await this.page + .getByText("Results") + .first() + .waitFor({ state: "visible", timeout: 60000 }) + } } diff --git a/tests/e2e/page_objects/editProfilePage.ts b/tests/e2e/page_objects/editProfilePage.ts new file mode 100644 index 000000000..17088fa80 --- /dev/null +++ b/tests/e2e/page_objects/editProfilePage.ts @@ -0,0 +1,45 @@ +import { expect, type Locator, type Page } from "@playwright/test" + +export class EditProfilePage { + readonly page: Page + readonly accessDeniedMessage: Locator + readonly errorHeading: Locator + readonly saveChangesButton: Locator + readonly editProfileButton: Locator + readonly editName: Locator + readonly editWriteAboutSelf: Locator + readonly editTwitterUsername: Locator + readonly editLinkedInUrl: Locator + readonly editSenator: Locator + readonly editRepresentative: Locator + readonly locateTestimony: Locator + + constructor(page: Page) { + this.page = page + this.accessDeniedMessage = page.getByText("This page could not be found") + this.errorHeading = page.getByText(/404 | 403/) + this.saveChangesButton = page.getByRole("button", { + name: "Save Personal Information" + }) + + this.editProfileButton = page.getByRole("button", { name: "Edit Profile" }) + this.editName = page.locator('input[name="fullName"]') + + this.editWriteAboutSelf = page.getByPlaceholder( + "Write something about yourself" + ) + this.editTwitterUsername = page.getByPlaceholder("Twitter Username") + this.editLinkedInUrl = page.getByPlaceholder("LinkedIn Url") + this.editSenator = page.locator('#react-select-3-input[role="combobox"]') + + this.editRepresentative = page.locator( + '#react-select-2-input[role="combobox"]' + ) + + this.locateTestimony = page.getByRole("tab", { name: "Testimonies" }) + } + + async goto() { + await this.page.goto("http://localhost:3000") + } +} diff --git a/tests/e2e/page_objects/testimony.ts b/tests/e2e/page_objects/testimony.ts index 803c4d7f7..02baca3f1 100644 --- a/tests/e2e/page_objects/testimony.ts +++ b/tests/e2e/page_objects/testimony.ts @@ -38,7 +38,21 @@ export class TestimonyPage { } async sort(option: string) { - await this.page.getByText("Sort by New -> Old").click() + // previoud code: await this.page.getByText("Sort by New -> Old").click() + await this.page + .getByText(/Sort by/i) + .first() + .click() await this.page.getByRole("option", { name: option }).click() } + + async removePresetCourtfilter() { + const activeCourtCheckbox = this.page + .locator("div, span, label", { has: this.page.getByText(/Court/i) }) + .getByRole("checkbox", { checked: true }) + + await activeCourtCheckbox.click({ noWaitAfter: true, timeout: 0 }) + + await this.resultsCountText.waitFor({ state: "visible", timeout: 60000 }) + } } diff --git a/tests/e2e/testimony.spec.ts b/tests/e2e/testimony.spec.ts index ab57f6012..94a1c56ea 100644 --- a/tests/e2e/testimony.spec.ts +++ b/tests/e2e/testimony.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test" import { TestimonyPage } from "./page_objects/testimony" +import { waitFor } from "@testing-library/dom" test.beforeEach(async ({ page }) => { await page.goto("http://localhost:3000/testimony") @@ -34,7 +35,7 @@ test.describe("Testimony Search", () => { await testimonyPage.search(queryText) const { queryFilterItem, resultsCountText } = testimonyPage - await expect(queryFilterItem).toContainText("query:") + await expect(queryFilterItem).toContainText("Query:") await expect(queryFilterItem).toContainText(queryText) await expect(resultsCountText).toBeVisible() }) @@ -102,33 +103,47 @@ test.describe("Testimony Filtering", () => { }) test("should filter by position: endorse", async ({ page }) => { - await page.getByRole("checkbox", { name: "endorse" }).check() const testimonyPage = new TestimonyPage(page) + testimonyPage.removePresetCourtfilter() + + const endorseCheckbox = page.getByRole("checkbox", { name: /endorse/i }) + await endorseCheckbox.check({ timeout: 30000 }) + await expect(testimonyPage.positionFilterItem).toContainText("endorse") await expect(page).toHaveURL(/.*position%5D%5B0%5D=endorse/) }) test("should filter by position: neutral", async ({ page }) => { - await page.getByRole("checkbox", { name: "neutral" }).check() const testimonyPage = new TestimonyPage(page) + testimonyPage.removePresetCourtfilter() + + const checkNeutral = page.getByRole("checkbox", { name: "neutral" }) + await checkNeutral.check({ timeout: 30000 }) + await page.getByRole("checkbox", { name: "neutral" }).check() + await expect(testimonyPage.positionFilterItem).toContainText("neutral") await expect(page).toHaveURL(/.*position%5D%5B0%5D=neutral/) }) test("should filter by bill", async ({ page }) => { + const testimonyPage = new TestimonyPage(page) + testimonyPage.removePresetCourtfilter() + const billCheckbox = page.getByLabel(/^[S|H]\d{1,4}$/).first() const billId = await billCheckbox.inputValue() expect(billId).toBeTruthy() if (billId) { await billCheckbox.check() - const testimonyPage = new TestimonyPage(page) await expect(testimonyPage.billFilterItem).toContainText(billId as string) await expect(page).toHaveURL(new RegExp(`.*billId%5D%5B0%5D=${billId}`)) } }) test("should filter by author", async ({ page }) => { + const testimonyPage = new TestimonyPage(page) + testimonyPage.removePresetCourtfilter() + const writtenByText = await page .getByText(/Written by/) .first() @@ -138,7 +153,6 @@ test.describe("Testimony Filtering", () => { if (writtenByText) { const authorName = writtenByText.slice(11) await page.getByRole("checkbox", { name: authorName }).check() - const testimonyPage = new TestimonyPage(page) await expect(testimonyPage.authorFilterItem).toContainText(authorName) await expect(page).toHaveURL( new RegExp( diff --git a/tests/e2e/utils/goto.ts b/tests/e2e/utils/goto.ts new file mode 100644 index 000000000..cbc07822d --- /dev/null +++ b/tests/e2e/utils/goto.ts @@ -0,0 +1,13 @@ +import { Locator, type Page } from "@playwright/test" + +export async function gotoStable(page: Page, url: string) { + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }) + } catch (e: any) { + // Firefox aborts when a redirect/navigation interrupts the request + const msg = String(e?.message ?? e) + if (!msg.includes("NS_BINDING_ABORTED")) throw e + } + + await page.waitForLoadState("domcontentloaded", { timeout: 60_000 }) +} diff --git a/tests/e2e/utils/login.ts b/tests/e2e/utils/login.ts new file mode 100644 index 000000000..f8d227f64 --- /dev/null +++ b/tests/e2e/utils/login.ts @@ -0,0 +1,69 @@ +import { expect, Page, Browser } from "@playwright/test" + +//login helper function +export async function userLogin( + page: Page, + USER_EMAIL: string | undefined, + USER_PASSWORD: string | undefined +) { + if (!USER_EMAIL || !USER_PASSWORD) { + throw new Error("Email or password are not defined.") + } + + await page.goto("http://localhost:3000", { + waitUntil: "commit", + timeout: 60_000 + }) + + const loginButton = page.getByRole("button", { name: "Log in / Sign up" }) + + const loggedInMarker = page.locator('a:has(img[alt="profileMenu"])') + + const state = await expect + .poll( + async () => { + if (await loggedInMarker.isVisible().catch(() => false)) + return "logged-in" + const count = await loginButton.count().catch(() => 0) + if (count > 0) return "needs-login" + return "loading" + }, + { timeout: 30_000 } + ) + .not.toBe("loading") + + // If already logged in, do nothing + if (await loggedInMarker.isVisible().catch(() => false)) return + + await loginButton.click() + + await page + .getByLabel("Sign Up or Sign In") + .getByRole("button", { name: "Sign In", exact: true }) + .click() + + const signInForm = page.getByRole("dialog", { name: "Sign In" }) + const emailInput = signInForm.locator('input[name="email"]') + const passwordInput = signInForm.locator('input[name="password"]') + const signInButton = signInForm.locator('button[type="submit"]') + + await emailInput.waitFor({ state: "visible", timeout: 5000 }) + await emailInput.fill(USER_EMAIL) + + await passwordInput.waitFor({ state: "visible", timeout: 5000 }) + await passwordInput.fill(USER_PASSWORD) + + await expect(signInButton).toBeEnabled({ timeout: 5000 }) + + await signInButton.click() +} + +//setup helper function +export async function setupPage( + browser: Browser, + state?: { cookies: any[]; origins: any[] } +) { + const context = await browser.newContext({ storageState: state }) + const page = await context.newPage() + return { page, context } +} diff --git a/tests/e2e/utils/removeSpecialChar.ts b/tests/e2e/utils/removeSpecialChar.ts new file mode 100644 index 000000000..f8912f8df --- /dev/null +++ b/tests/e2e/utils/removeSpecialChar.ts @@ -0,0 +1,17 @@ +import { expect, type Locator } from "@playwright/test" + +export async function removeSpecialChar(locator: Locator, sample: string) { + const expected = sample.normalize("NFD").replace(/[\u0300-\u036f]/g, "") + + const removing = await expect + .poll( + async () => { + const actual = (await locator.textContent()) ?? "" + return actual.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // strips accents from page text too + }, + { timeout: 30_000 } + ) + .toBe(expected) + + return removing +}