From 1f2eef5982f476a09a5de82f3bb2356ad8b49375 Mon Sep 17 00:00:00 2001 From: Olga Bulat Date: Mon, 1 May 2023 14:54:38 +0300 Subject: [PATCH] Improve Playwright navigation utilities (#1869) * Improve Playwright navigation utilities * Use setBreakpointCookie * Remove extra lines * Lint --- .../e2e/filters-sidebar-keyboard.spec.ts | 7 +- frontend/test/playwright/e2e/filters.spec.ts | 45 ++- .../playwright/e2e/header-internal.spec.ts | 41 ++- frontend/test/playwright/e2e/homepage.spec.ts | 10 +- .../test/playwright/e2e/load-more.spec.ts | 6 +- .../test/playwright/e2e/mobile-menu.spec.ts | 36 +- .../playwright/e2e/search-navigation.spec.ts | 9 +- .../e2e/search-query-client.spec.ts | 6 +- .../e2e/search-query-server.spec.ts | 21 +- .../test/playwright/e2e/search-types.spec.ts | 4 +- .../playwright/e2e/translation-banner.spec.ts | 6 +- frontend/test/playwright/utils/navigation.ts | 341 ++++++++---------- .../components/header.spec.ts | 18 +- .../visual-regression/pages/homepage.spec.ts | 2 +- .../pages/no-results.spec.ts | 13 +- .../pages/pages-single-result.spec.ts | 24 +- .../visual-regression/pages/pages.spec.ts | 23 +- 17 files changed, 281 insertions(+), 331 deletions(-) diff --git a/frontend/test/playwright/e2e/filters-sidebar-keyboard.spec.ts b/frontend/test/playwright/e2e/filters-sidebar-keyboard.spec.ts index 717381c8f9..ef0aaba7ad 100644 --- a/frontend/test/playwright/e2e/filters-sidebar-keyboard.spec.ts +++ b/frontend/test/playwright/e2e/filters-sidebar-keyboard.spec.ts @@ -2,8 +2,9 @@ import { test, expect, Page } from "@playwright/test" import { LanguageDirection, + languageDirections, pathWithDir, - setCookies, + setBreakpointCookie, t, } from "~~/test/playwright/utils/navigation" @@ -28,10 +29,10 @@ const firstFilterCheckbox = (page: Page, dir: LanguageDirection) => { test.describe.configure({ mode: "parallel" }) -for (const dir of ["ltr", "rtl"]) { +for (const dir of languageDirections) { test.describe(`search header keyboard accessibility test in ${dir}`, () => { test.beforeEach(async ({ page }) => { - await setCookies(page.context(), { uiBreakpoint: "lg" }) + await setBreakpointCookie(page, "lg") /** * To simplify finding the last focusable element in the filters sidebar, * we use the image search page. After the removal of the "searchBy" filter, diff --git a/frontend/test/playwright/e2e/filters.spec.ts b/frontend/test/playwright/e2e/filters.spec.ts index 100455938b..f16adc3456 100644 --- a/frontend/test/playwright/e2e/filters.spec.ts +++ b/frontend/test/playwright/e2e/filters.spec.ts @@ -2,11 +2,10 @@ import { test, expect, Page } from "@playwright/test" import { assertCheckboxStatus, - openFilters, - changeContentType, + changeSearchType, goToSearchTerm, - closeFilters, isPageDesktop, + filters, } from "~~/test/playwright/utils/navigation" import { mockProviderApis } from "~~/test/playwright/utils/route" @@ -52,7 +51,7 @@ breakpoints.describeMobileAndDesktop(() => { }) => { await goToSearchTerm(page, "cat", { searchType }) - await openFilters(page) + await filters.open(page) await assertCheckboxCount(page, "total", FILTER_COUNTS[searchType]) }) @@ -62,7 +61,7 @@ breakpoints.describeMobileAndDesktop(() => { await page.goto( "/search/?q=cat&license_type=commercial&license=cc0&searchBy=creator" ) - await openFilters(page) + await filters.open(page) // Creator filter was removed from the UI const expectedFilters = ["cc0", "commercial"] @@ -77,19 +76,19 @@ breakpoints.describeMobileAndDesktop(() => { await page.goto( "/search/?q=cat&license_type=commercial&license=cc0&searchBy=creator" ) - await openFilters(page) + await filters.open(page) // Creator filter was removed from the UI const expectedFilters = ["cc0", "commercial"] for (const checkbox of expectedFilters) { await assertCheckboxStatus(page, checkbox) } - await changeContentType(page, "Images") + await changeSearchType(page, IMAGE) await expect(page).toHaveURL( "/search/image?q=cat&license_type=commercial&license=cc0&searchBy=creator" ) - await openFilters(page) + await filters.open(page) for (const checkbox of expectedFilters) { await assertCheckboxStatus(page, checkbox) } @@ -101,16 +100,16 @@ breakpoints.describeMobileAndDesktop(() => { await page.goto( "/search/image?q=cat&license_type=commercial&license=cc0&searchBy=creator" ) - await openFilters(page) + await filters.open(page) // Creator filter was removed from the UI for (const checkbox of ["cc0", "commercial"]) { await assertCheckboxStatus(page, checkbox) } - await changeContentType(page, "All content") + await changeSearchType(page, ALL_MEDIA) - await openFilters(page) + await filters.open(page) await expect(page.locator('input[type="checkbox"]:checked')).toHaveCount(2) await expect(page).toHaveURL( @@ -122,22 +121,22 @@ breakpoints.describeMobileAndDesktop(() => { page, }) => { await page.goto("/search/audio?q=cat&license_type=commercial") - await openFilters(page) + await filters.open(page) // by-nc is special because we normally test for fuzzy match, and by-nc matches 3 labels. const byNc = page.locator('input[value="by-nc"]') await expect(byNc).toBeDisabled() for (const checkbox of ["by-nc-sa", "by-nc-nd"]) { - await assertCheckboxStatus(page, checkbox, "", "disabled") + await assertCheckboxStatus(page, checkbox, "disabled") } await assertCheckboxStatus(page, "commercial") await page.click('label:has-text("commercial")') - await assertCheckboxStatus(page, "commercial", "", "unchecked") + await assertCheckboxStatus(page, "commercial", "unchecked") await expect(byNc).not.toBeDisabled() for (const checkbox of ["commercial", "by-nc-sa", "by-nc-nd"]) { - await assertCheckboxStatus(page, checkbox, "", "unchecked") + await assertCheckboxStatus(page, checkbox, "unchecked") } }) @@ -152,18 +151,18 @@ breakpoints.describeMobileAndDesktop(() => { */ test("filters are updated when media type changes", async ({ page }) => { await page.goto("/search/image?q=cat&aspect_ratio=tall&license=cc0") - await openFilters(page) + await filters.open(page) await assertCheckboxStatus(page, "tall") await assertCheckboxStatus(page, "cc0") - await changeContentType(page, "Audio") - await openFilters(page) + await changeSearchType(page, AUDIO) + await filters.open(page) // Only CC0 checkbox is checked, and the filter button label is // '1 Filter' on `xl` or '1' on `lg` screens await assertCheckboxStatus(page, "cc0") - await closeFilters(page) + await filters.close(page) if (isPageDesktop(page)) { const filterButtonText = await page .locator('[aria-controls="filters"] span:visible') @@ -184,9 +183,9 @@ breakpoints.describeMobileAndDesktop(() => { page, }) => { await page.goto("/search/image?q=cat") - await openFilters(page) + await filters.open(page) - await assertCheckboxStatus(page, "cc0", "", "unchecked") + await assertCheckboxStatus(page, "cc0", "unchecked") const [response] = await Promise.all([ page.waitForResponse((response) => response.url().includes("cc0")), @@ -210,9 +209,9 @@ breakpoints.describeMobileAndDesktop(() => { await page.goto( `/search/${searchType}?q=birds&source=${source.toLowerCase()}` ) - await openFilters(page) + await filters.open(page) - await assertCheckboxStatus(page, source, "", "checked") + await assertCheckboxStatus(page, source, "checked") }) } }) diff --git a/frontend/test/playwright/e2e/header-internal.spec.ts b/frontend/test/playwright/e2e/header-internal.spec.ts index fb10099fd6..45c208ee7e 100644 --- a/frontend/test/playwright/e2e/header-internal.spec.ts +++ b/frontend/test/playwright/e2e/header-internal.spec.ts @@ -1,22 +1,30 @@ import { test, expect, Page } from "@playwright/test" import { - isMobileMenuOpen, + isDialogOpen, + LanguageDirection, scrollToBottom, - setCookies, + setBreakpointCookie, t, } from "~~/test/playwright/utils/navigation" import breakpoints from "~~/test/playwright/utils/breakpoints" -const modalCloseButton = `div[role="dialog"] >> [aria-label="${t( - "modal.close-pages-menu" -)}"]` const currentPageLink = 'div[role="dialog"] >> [aria-current="page"]' const currentPageLinkInPopover = '.popover-content >> [aria-current="page"]' -const menuButton = `[aria-label="${t("header.aria.menu")}"]` -const clickMenuButton = async (page: Page) => await page.click(menuButton) -const closeMenu = async (page: Page) => await page.click(modalCloseButton) +const getMenuButton = async (page: Page) => { + return page.getByRole("button", { name: t("header.aria.menu") }) +} + +const clickMenuButton = async (page: Page) => { + return (await getMenuButton(page)).click() +} + +const closeMenu = async (page: Page, dir: LanguageDirection = "ltr") => { + await page + .getByRole("button", { name: t("modal.close-pages-menu", dir) }) + .click() +} const isPagesPopoverOpen = async (page: Page) => page.locator(".popover-content").isVisible({ timeout: 100 }) @@ -25,20 +33,20 @@ test.describe.configure({ mode: "parallel" }) test.describe("Header internal", () => { breakpoints.describeXs(() => { - test.beforeEach(async ({ context }) => { - await setCookies(context, { uiBreakpoint: "xs" }) + test.beforeEach(async ({ page }) => { + await setBreakpointCookie(page, "xs") }) test("can open and close the modal on xs breakpoint", async ({ page }) => { await page.goto("/about") await clickMenuButton(page) - expect(await isMobileMenuOpen(page)).toBe(true) + expect(await isDialogOpen(page)).toBe(true) await expect(page.locator(currentPageLink)).toBeVisible() await expect(page.locator(currentPageLink)).toHaveText("About") await closeMenu(page) - expect(await isMobileMenuOpen(page)).toBe(false) - await expect(page.locator(menuButton)).toBeVisible() + expect(await isDialogOpen(page)).toBe(false) + await expect(await getMenuButton(page)).toBeVisible() }) test("the modal locks the scroll on xs breakpoint", async ({ page }) => { @@ -67,7 +75,7 @@ test.describe("Header internal", () => { await popup.close() // If we want the modal to stay open, we'll need to change this to `true`, // and implement the change - expect(await isMobileMenuOpen(page)).toBe(false) + expect(await isDialogOpen(page)).toBe(false) }) test("content page opened from home should be scrollable", async ({ @@ -95,10 +103,9 @@ test.describe("Header internal", () => { breakpoints.describeMd(() => { test("can open and close the popover on sm breakpoint", async ({ - context, page, }) => { - await setCookies(context, { breakpoint: "sm" }) + await setBreakpointCookie(page, "sm") await page.goto("/about") await clickMenuButton(page) expect(await isPagesPopoverOpen(page)).toBe(true) @@ -107,7 +114,7 @@ test.describe("Header internal", () => { await clickMenuButton(page) expect(await isPagesPopoverOpen(page)).toBe(false) - await expect(page.locator(menuButton)).toBeVisible() + await expect(await getMenuButton(page)).toBeVisible() }) }) }) diff --git a/frontend/test/playwright/e2e/homepage.spec.ts b/frontend/test/playwright/e2e/homepage.spec.ts index c74595c753..1d5c602831 100644 --- a/frontend/test/playwright/e2e/homepage.spec.ts +++ b/frontend/test/playwright/e2e/homepage.spec.ts @@ -1,13 +1,9 @@ import { expect, Page, test } from "@playwright/test" import { mockProviderApis } from "~~/test/playwright/utils/route" -import { - goToSearchTerm, - searchTypePath, - t, -} from "~~/test/playwright/utils/navigation" +import { goToSearchTerm, t } from "~~/test/playwright/utils/navigation" -import { supportedSearchTypes } from "~/constants/media" +import { searchPath, supportedSearchTypes } from "~/constants/media" test.describe.configure({ mode: "parallel" }) @@ -24,7 +20,7 @@ for (const searchType of supportedSearchTypes) { mode: "CSR", }) - const expectedUrl = `/search/${searchTypePath(searchType)}?q=cat` + const expectedUrl = `${searchPath(searchType)}?q=cat` await expect(page).toHaveURL(expectedUrl) }) } diff --git a/frontend/test/playwright/e2e/load-more.spec.ts b/frontend/test/playwright/e2e/load-more.spec.ts index 3301bfdad4..7e3b207c42 100644 --- a/frontend/test/playwright/e2e/load-more.spec.ts +++ b/frontend/test/playwright/e2e/load-more.spec.ts @@ -19,10 +19,8 @@ const openSingleMediaView = async ( ) => { const contentLinkSelector = mediaType === IMAGE ? "See all images" : "See all audio" - return await Promise.all([ - page.waitForNavigation(), - page.click(`text=${contentLinkSelector}`), - ]) + await page.click(`text=${contentLinkSelector}`) + await page.waitForURL(/search\/(audio|image)/) } /** * Cases, check both SSR and CSR: diff --git a/frontend/test/playwright/e2e/mobile-menu.spec.ts b/frontend/test/playwright/e2e/mobile-menu.spec.ts index 743114a2c8..60117cd402 100644 --- a/frontend/test/playwright/e2e/mobile-menu.spec.ts +++ b/frontend/test/playwright/e2e/mobile-menu.spec.ts @@ -1,12 +1,10 @@ import { test, expect } from "@playwright/test" import { - closeFilters, - closeMobileMenu, goToSearchTerm, - isMobileMenuOpen, - openContentTypes, - openFilters, + isDialogOpen, + searchTypes, + filters, } from "~~/test/playwright/utils/navigation" import breakpoints from "~~/test/playwright/utils/breakpoints" @@ -17,26 +15,26 @@ test.describe("mobile menu", () => { test("Can open filters menu on mobile at least twice", async ({ page }) => { await page.goto("/search/?q=cat") - await openFilters(page) - expect(await isMobileMenuOpen(page)).toBe(true) - await closeFilters(page) + await filters.open(page) + expect(await isDialogOpen(page)).toBe(true) + await filters.close(page) - await openFilters(page) - expect(await isMobileMenuOpen(page)).toBe(true) - await closeFilters(page) - expect(await isMobileMenuOpen(page)).toBe(false) + await filters.open(page) + expect(await isDialogOpen(page)).toBe(true) + await filters.close(page) + expect(await isDialogOpen(page)).toBe(false) }) test("Can open mobile menu at least twice", async ({ page }) => { await goToSearchTerm(page, "cat") - await openContentTypes(page) - expect(await isMobileMenuOpen(page)).toBe(true) - await closeMobileMenu(page) + await searchTypes.open(page) + expect(await isDialogOpen(page)).toBe(true) + await searchTypes.close(page) - await openContentTypes(page) - expect(await isMobileMenuOpen(page)).toBe(true) - await closeMobileMenu(page) - expect(await isMobileMenuOpen(page)).toBe(false) + await searchTypes.open(page) + expect(await isDialogOpen(page)).toBe(true) + await searchTypes.close(page) + expect(await isDialogOpen(page)).toBe(false) }) }) }) diff --git a/frontend/test/playwright/e2e/search-navigation.spec.ts b/frontend/test/playwright/e2e/search-navigation.spec.ts index 19f28c8b2b..dd6cda92b9 100644 --- a/frontend/test/playwright/e2e/search-navigation.spec.ts +++ b/frontend/test/playwright/e2e/search-navigation.spec.ts @@ -2,10 +2,10 @@ import { expect, test } from "@playwright/test" import { goToSearchTerm, - openFilters, + filters, searchFromHeader, - t, openFirstResult, + t, } from "~~/test/playwright/utils/navigation" import { mockProviderApis } from "~~/test/playwright/utils/route" import breakpoints from "~~/test/playwright/utils/breakpoints" @@ -23,19 +23,18 @@ test.describe("search history navigation", () => { }) => { await goToSearchTerm(page, "galah") // Open filter sidebar - await openFilters(page) + await filters.open(page) // Apply a filter await page.click("#modification") // There is a debounce when choosing a filter. // we need to wait for the page to reload before running the test - await page.waitForNavigation() + await page.waitForURL(/license_type=modification/) // Verify the filter is applied to the URL and the checkbox is checked // Note: Need to add that a search was actually executed with the new // filters and that the page results have been updated for the new filters // @todo(sarayourfriend): ^? - expect(page.url()).toContain("license_type=modification") expect(await page.isChecked("#modification")).toBe(true) // Navigate backwards and verify URL is updated and the filter is unapplied diff --git a/frontend/test/playwright/e2e/search-query-client.spec.ts b/frontend/test/playwright/e2e/search-query-client.spec.ts index cba2945b95..88d51495ed 100644 --- a/frontend/test/playwright/e2e/search-query-client.spec.ts +++ b/frontend/test/playwright/e2e/search-query-client.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test" import { - changeContentType, + changeSearchType, goToSearchTerm, searchFromHeader, } from "~~/test/playwright/utils/navigation" @@ -60,7 +60,7 @@ test.describe("search query on CSR", () => { query: "category=photograph", }) - await changeContentType(page, "Audio") + await changeSearchType(page, AUDIO) await expect(page).toHaveURL("/search/audio?q=cat") }) @@ -72,7 +72,7 @@ test.describe("search query on CSR", () => { query: "aspect_ratio=tall", }) - await changeContentType(page, "Audio") + await changeSearchType(page, AUDIO) await expect(page).toHaveURL("/search/audio?q=cat") }) diff --git a/frontend/test/playwright/e2e/search-query-server.spec.ts b/frontend/test/playwright/e2e/search-query-server.spec.ts index 210dbcfc03..cae7deadb0 100644 --- a/frontend/test/playwright/e2e/search-query-server.spec.ts +++ b/frontend/test/playwright/e2e/search-query-server.spec.ts @@ -3,14 +3,15 @@ import { test, expect } from "@playwright/test" import { assertCheckboxStatus, currentContentType, + filters, goToSearchTerm, - openFilters, + searchTypeNames, } from "~~/test/playwright/utils/navigation" import { mockProviderApis } from "~~/test/playwright/utils/route" import breakpoints from "~~/test/playwright/utils/breakpoints" -import { AUDIO, IMAGE } from "~/constants/media" +import { ALL_MEDIA, AUDIO, IMAGE } from "~/constants/media" /** * URL is correctly converted into search state: @@ -49,16 +50,17 @@ test.describe("search query on SSR", () => { await page.goto("/search/?q=cat") const contentType = await currentContentType(page) - expect(contentType?.trim()).toEqual("All content") + expect(contentType).toEqual(searchTypeNames.ltr[ALL_MEDIA]) }) test("url path /search/audio is used to select `audio` search tab", async ({ page, }) => { - await goToSearchTerm(page, "cat", { searchType: AUDIO }) + const searchType = AUDIO + await goToSearchTerm(page, "cat", { searchType }) const contentType = await currentContentType(page) - expect(contentType?.trim()).toEqual("Audio") + expect(contentType).toEqual(searchTypeNames.ltr[searchType]) }) test("url query to filter, all tab, one parameter per filter type", async ({ @@ -68,7 +70,7 @@ test.describe("search query on SSR", () => { query: "license=cc0&license_type=commercial&searchBy=creator", }) - await openFilters(page) + await filters.open(page) // Creator filter was removed from the UI for (const checkbox of ["cc0", "commercial"]) { await assertCheckboxStatus(page, checkbox) @@ -82,11 +84,10 @@ test.describe("search query on SSR", () => { searchType: IMAGE, query: "searchBy=creator&extension=jpg,png,gif,svg", }) - await openFilters(page) - const checkboxes = ["jpeg", "png", "gif", "svg"] + await filters.open(page) + const checkboxes = ["JPEG", "PNG", "GIF", "SVG"] for (const checkbox of checkboxes) { - const forValue = checkbox === "jpeg" ? "jpg" : checkbox - await assertCheckboxStatus(page, checkbox, forValue) + await assertCheckboxStatus(page, checkbox) } }) diff --git a/frontend/test/playwright/e2e/search-types.spec.ts b/frontend/test/playwright/e2e/search-types.spec.ts index 16310b0a60..c31be9de64 100644 --- a/frontend/test/playwright/e2e/search-types.spec.ts +++ b/frontend/test/playwright/e2e/search-types.spec.ts @@ -1,7 +1,7 @@ import { test, expect, Page } from "@playwright/test" import { - changeContentType, + changeSearchType, goToSearchTerm, } from "~~/test/playwright/utils/navigation" import { mockProviderApis } from "~~/test/playwright/utils/route" @@ -113,7 +113,7 @@ test.describe("search types", () => { const pageToOpen = searchType.id === "all" ? searchTypes[1] : searchTypes[0] await page.goto(pageToOpen.url) - await changeContentType(page, searchType.name) + await changeSearchType(page, searchType.id) await checkSearchResult(page, searchType) }) } diff --git a/frontend/test/playwright/e2e/translation-banner.spec.ts b/frontend/test/playwright/e2e/translation-banner.spec.ts index b64063efbe..7f849ca731 100644 --- a/frontend/test/playwright/e2e/translation-banner.spec.ts +++ b/frontend/test/playwright/e2e/translation-banner.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test" -import { setCookies } from "~~/test/playwright/utils/navigation" +import { dismissBannersUsingCookies } from "~~/test/playwright/utils/navigation" const russianSearchPath = "/ru/search?q=dog" @@ -29,9 +29,7 @@ test.describe("translation banner", () => { test("Banner is not shown if dismissed state is saved in a cookie", async ({ page, }) => { - await setCookies(page.context(), { - uiDismissedBanners: '["translation-ru"]', - }) + await dismissBannersUsingCookies(page) await page.goto(russianSearchPath) await expect( diff --git a/frontend/test/playwright/utils/navigation.ts b/frontend/test/playwright/utils/navigation.ts index 83ba47c7fa..e638945ed9 100644 --- a/frontend/test/playwright/utils/navigation.ts +++ b/frontend/test/playwright/utils/navigation.ts @@ -1,7 +1,7 @@ -import { BrowserContext, expect, Page } from "@playwright/test" - import rtlMessages from "~~/test/locales/ar.json" +import enMessages from "~/locales/en.json" + import { ALL_MEDIA, AUDIO, @@ -13,7 +13,7 @@ import { VIDEO, } from "~/constants/media" -import enMessages from "~/locales/en.json" +import type { BrowserContext, Locator, Page } from "@playwright/test" const messages: Record> = { ltr: enMessages, @@ -40,7 +40,7 @@ const getNestedProperty = ( /** * Simplified i18n t function that returns English messages for `ltr` and Arabic for `rtl`. - * It can also handle nested labels using dot notation ('header.title'). + * It can handle nested labels that use the dot notation ('header.title'). * @param path - The label to translate. * @param dir - The language direction. */ @@ -63,20 +63,13 @@ export const renderingContexts = [ export const renderModes = ["SSR", "CSR"] as const export type RenderMode = typeof renderModes[number] -export type LanguageDirection = "ltr" | "rtl" - -export const buttonSelectors = { - filter: 'button[aria-controls="filters"]', - contentSwitcher: 'button[aria-controls="content-switcher-modal"]', - mobileContentSettings: `button[aria-controls="content-settings-modal"]`, -} +export type LanguageDirection = typeof languageDirections[number] export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -export const searchTypePath = (searchType: SupportedSearchType) => - searchType === "all" ? "" : `${searchType}` +export type CheckboxStatus = "checked" | "unchecked" | "disabled" export const searchTypeNames = { ltr: { @@ -95,188 +88,162 @@ export const searchTypeNames = { }, } -const isButtonPressed = async ( +/** + * On mobile screen, open the "tab" in the content settings modal. + * Should be called after the modal is open. + */ +export const openContentSettingsTab = async ( page: Page, - buttonSelector: string -): Promise => { - const viewportSize = page.viewportSize() - if (!viewportSize) { - return false - } - const pageWidth = viewportSize.width - if (pageWidth > 640) { - return await getPressed(page, buttonSelector) - } else { - return await page.locator("button", { hasText: "Close" }).isVisible() - } -} + tab: "searchTypes" | "filters" = "searchTypes", + dir: LanguageDirection = "ltr" +) => { + const tabKey = tab === "searchTypes" ? "search-type.heading" : "filters.title" -const openMenu = async (page: Page, button: "filter" | "contentSwitcher") => { - const selector = buttonSelectors[button] - if (!(await isButtonPressed(page, selector))) { - await page.click(selector) - expect(await isButtonPressed(page, selector)).toEqual(true) - } + await page.getByRole("tab", { name: t(tabKey, dir) }).click() } -export const openFilters = async ( +/** + * On mobile screen, clicks on the "Close" button in the content settings modal. + */ +export const closeContentSettingsModal = async ( page: Page, dir: LanguageDirection = "ltr" ) => { - if (isPageDesktop(page)) { - await openMenu(page, "filter") - } else { - await openContentSettingsTab(page, "filters", dir) - } + return page + .getByRole("button", { name: t("modal.close-content-settings", dir) }) + .click() } -export const openContentTypes = async ( +/** + * Opens or closes the search settings: + * - given modal tab on mobile screen + * - filters sidebar or the search types popover on desktop screen + */ +export const setContentSwitcherState = async ( page: Page, + contentSwitcherKind: "filters" | "searchTypes", + state: "open" | "closed", dir: LanguageDirection = "ltr" ) => { - if (isPageDesktop(page)) { - await openMenu(page, "contentSwitcher") - } else { - await openContentSettingsTab(page, "contentTypes", dir) + const isDesktop = isPageDesktop(page) + + const buttonLocator = page.locator( + !isDesktop + ? "#content-settings-button" + : contentSwitcherKind === "filters" + ? "#filter-button" + : "#search-type-button" + ) + + const isPressed = await getSelectorPressed(buttonLocator) + const shouldBePressed = state === "open" + + if (isDesktop) { + if (isPressed === shouldBePressed) return null + return await buttonLocator.click() + } + + if (shouldBePressed) { + if (!isPressed) { + await buttonLocator.click() + } + return openContentSettingsTab(page, contentSwitcherKind, dir) + } else if (isPressed) { + await closeContentSettingsModal(page, dir) } } +export const filters = { + open: async (page: Page, dir: LanguageDirection = "ltr") => { + await setContentSwitcherState(page, "filters", "open", dir) + }, + close: async (page: Page, dir: LanguageDirection = "ltr") => { + await setContentSwitcherState(page, "filters", "closed", dir) + }, +} + +export const searchTypes = { + open: async (page: Page, dir: LanguageDirection = "ltr") => { + await setContentSwitcherState(page, "searchTypes", "open", dir) + }, + close: async (page: Page, dir: LanguageDirection = "ltr") => { + await setContentSwitcherState(page, "searchTypes", "closed", dir) + }, +} + export const isPageDesktop = (page: Page) => { const pageWidth = page.viewportSize()?.width if (!pageWidth) return false const desktopMinWidth = 1024 return pageWidth >= desktopMinWidth } + /** - * Returns `true` if the `selector`'s `aria-pressed` attribute is `true`. + * Returns true if the button with the given selector is pressed or expanded. */ -const getPressed = async (page: Page, selector: string) => { +const getSelectorPressed = async (selector: Locator) => { return ( - (await page.getAttribute(selector, "aria-pressed")) === "true" || - (await page.getAttribute(selector, "aria-expanded")) === "true" + (await selector.getAttribute("aria-pressed")) === "true" || + (await selector.getAttribute("aria-expanded")) === "true" ) } -/** - * Clicks the `selector` button if it is not already pressed. - */ -const ensureButtonPressed = async (page: Page, selector: string) => { - if (!(await getPressed(page, selector))) { - await page.click(selector) - expect(await getPressed(page, selector)).toEqual(true) - } +export const isDialogOpen = async (page: Page) => { + return page.getByRole("dialog").isVisible({ timeout: 100 }) } + /** - * Open the Content types tab in the mobile content settings modal. + * Asserts that the checkbox has the given status. + * + * @param page - Playwright page object + * @param label - the label of the checkbox, converted to a RegExp if string + * @param status - the status to assert */ -export const openContentSettingsTab = async ( +export const assertCheckboxStatus = async ( page: Page, - tab: "contentTypes" | "filters" = "contentTypes", - dir: LanguageDirection = "ltr" + label: string | RegExp, + status: CheckboxStatus = "checked" ) => { - const selector = "#content-settings-button" - - await ensureButtonPressed(page, selector) - - const tabLabel = t( - tab === "contentTypes" ? "search-type.heading" : "filters.title", - dir - ) - await page.locator(`button[role="tab"]:has-text("${tabLabel}")`).click() + const labelRegexp = typeof label === "string" ? new RegExp(label, "i") : label + await page.getByRole("checkbox", { + name: labelRegexp, + disabled: status === "disabled", + checked: status === "checked", + }) } -export const closeFilters = async (page: Page) => { - if (isPageDesktop(page)) { - const selector = buttonSelectors["filter"] - - if (await isButtonPressed(page, selector)) { - await page.click(selector) - expect(await isButtonPressed(page, selector)).toEqual(false) - } - } else { - await closeMobileMenu(page) - } -} +export const changeSearchType = async (page: Page, to: SupportedSearchType) => { + await searchTypes.open(page) -export const closeMobileMenu = async ( - page: Page, - dir: LanguageDirection = "ltr" -) => { - await page.click( - `button[aria-label="${t("modal.close-content-settings", dir)}"]` + const changedUrl = new RegExp( + to === ALL_MEDIA ? `/search/?` : `/search/${to}` ) -} - -export const isMobileMenuOpen = async (page: Page) => - page.locator('[role="dialog"]').isVisible({ timeout: 100 }) - -export const assertCheckboxStatus = async ( - page: Page, - label: string, - forValue = "", - status: "checked" | "unchecked" | "disabled" = "checked" -) => { - const selector = - forValue === "" - ? `label:has-text('${label}')` - : `label[for="${forValue}"]:has-text('${label}')` - const checkbox = page.locator(selector) - switch (status) { - case "checked": { - await expect(checkbox).not.toBeDisabled() - await expect(checkbox).toBeChecked() - break - } - case "unchecked": { - await expect(checkbox).not.toBeDisabled() - await expect(checkbox).not.toBeChecked() - break - } - case "disabled": { - await expect(checkbox).toBeDisabled() - } - } -} + await page.getByRole("radio", { name: searchTypeNames.ltr[to] }).click() + await page.waitForURL(changedUrl) -export const changeContentType = async ( - page: Page, - to: "Audio" | "Images" | "All content" -) => { - if (isPageDesktop(page)) { - await page.click( - `button[aria-controls="content-switcher-popover"], button[aria-controls="content-switcher-modal"]` - ) - // Ensure that the asynchronous navigation is finished before next steps - await Promise.all([ - page.waitForNavigation(), - page.locator(`#content-switcher-popover a:has-text("${to}")`).click(), - ]) - } else { - await openContentTypes(page) - await page.locator(`a[role="radio"]:has-text("${to}")`).click() - await closeMobileMenu(page) - } + await searchTypes.close(page) } /** - * For desktop, returns the content of the Content switcher button. - * For mobile, returns the selected content type from the modal. - * @param page - Playwright page object. + * Returns the name of the currently selected search type. + * Opens the content switcher and selects the text content of the checked + * radio item. */ export const currentContentType = async (page: Page) => { - if (isPageDesktop(page)) { - const contentSwitcherButton = await page.locator( - `button[aria-controls="content-switcher-popover"]` - ) - return (await contentSwitcherButton.textContent())?.trim() - } else { - await openContentTypes(page) - const currentContentType = await page - .locator('a[aria-current="page"]') - .textContent() - await closeMobileMenu(page) - return currentContentType - } + await searchTypes.open(page) + const currentContentType = + (await page.getByRole("radio", { checked: true }).textContent())?.trim() ?? + "" + await searchTypes.close(page) + + return currentContentType +} + +export const dismissTranslationBannersUsingCookies = async (page: Page) => { + const uiDismissedBanners = ["ru", "en", "ar", "es"].map( + (lang) => `translation-${lang}` + ) + await setCookies(page.context(), { uiDismissedBanners }) } /** @@ -284,14 +251,7 @@ export const currentContentType = async (page: Page) => { * so the page should finish rendering before calling `dismissTranslationBanner`. */ export const dismissTranslationBanner = async (page: Page) => { - await setCookies(page.context(), { - uiDismissedBanners: [ - "translation-ru", - "translation-en", - "translation-ar", - "translation-es", - ], - }) + await dismissTranslationBannersUsingCookies(page) const bannerCloseButton = page.locator( '[data-testid="banner-translation"] button' ) @@ -305,12 +265,18 @@ export const selectHomepageSearchType = async ( searchType: SupportedSearchType, dir: LanguageDirection = "ltr" ) => { - await page.getByRole("button", { name: t("search-type.all", dir) }).click() + await page + .getByRole("button", { name: searchTypeNames[dir][ALL_MEDIA] }) + .click() await page .getByRole("radio", { name: searchTypeNames[dir][searchType] }) .click() } +export const dismissBannersUsingCookies = async (page: Page) => { + await dismissTranslationBanner(page) +} + export const goToSearchTerm = async ( page: Page, term: string, @@ -326,14 +292,7 @@ export const goToSearchTerm = async ( const mode = options.mode ?? "SSR" const query = options.query ? `&${options.query}` : "" - await setCookies(page.context(), { - uiDismissedBanners: [ - "translation-ru", - "translation-en", - "translation-ar", - "translation-es", - ], - }) + await dismissBannersUsingCookies(page) if (mode === "SSR") { const path = `${searchPath(searchType)}?q=${term}${query}` await page.goto(pathWithDir(path, dir)) @@ -348,11 +307,8 @@ export const goToSearchTerm = async ( await searchInput.type(term) // Click search button // Wait for navigation - await Promise.all([ - page.waitForNavigation(), - await page.getByRole("button", { name: t("search.search", dir) }).click(), - ]) - await page.waitForLoadState("load") + await page.getByRole("button", { name: t("search.search", dir) }).click() + await page.waitForURL(/search/, { waitUntil: "load" }) } await scrollDownAndUp(page) } @@ -365,7 +321,8 @@ export const searchFromHeader = async (page: Page, term: string) => { // Double-click on the search bar to remove previous value await page.dblclick("id=search-bar") await page.fill("id=search-bar", term) - await Promise.all([page.waitForNavigation(), page.keyboard.press("Enter")]) + await page.keyboard.press("Enter") + await page.waitForURL(/search/) } /** @@ -376,29 +333,19 @@ export const searchFromHeader = async (page: Page, term: string) => { */ export const openFirstResult = async (page: Page, mediaType: MediaType) => { const firstResult = page.locator(`a[href*="/${mediaType}/"]`).first() - const firstResultHref = await firstResult.getAttribute("href") - if (!firstResultHref) { - throw new Error(`Could not find a link to a ${mediaType} in the page`) - } + const firstResultHref = await getLocatorHref(firstResult) await firstResult.click({ position: { x: 32, y: 32 } }) await scrollDownAndUp(page) await page.waitForURL(firstResultHref) await page.mouse.move(0, 0) } -/** - * Click on the first related result: a link that contains - * // in its URL in the 'aside' element for related media. - * We cannot use the 'startsWith' `^` matcher because localized routes - * start with the locale prefix (e.g. /ar/image/). - * Scroll down and up to load all lazy-loaded content. - */ -export const openFirstRelatedResult = async ( - page: Page, - mediaType: MediaType -) => { - await page.locator(`aside a[href*="/${mediaType}/"]`).first().click() - await scrollDownAndUp(page) +export const getLocatorHref = async (locator: Locator) => { + const href = await locator.getAttribute("href") + if (!href) { + throw new Error("Could not find href attribute") + } + return href } export const scrollToBottom = async (page: Page) => { @@ -449,3 +396,11 @@ export const setCookies = async ( })) ) } + +export const closeFiltersUsingCookies = async (page: Page) => { + await setCookies(page.context(), { uiIsFilterDismissed: true }) +} + +export const setBreakpointCookie = async (page: Page, breakpoint: string) => { + await setCookies(page.context(), { uiBreakpoint: breakpoint }) +} diff --git a/frontend/test/playwright/visual-regression/components/header.spec.ts b/frontend/test/playwright/visual-regression/components/header.spec.ts index d2def3cee5..5d5906d6ed 100644 --- a/frontend/test/playwright/visual-regression/components/header.spec.ts +++ b/frontend/test/playwright/visual-regression/components/header.spec.ts @@ -5,11 +5,11 @@ import breakpoints, { } from "~~/test/playwright/utils/breakpoints" import { hideInputCursors } from "~~/test/playwright/utils/page" import { - closeFilters, + filters, goToSearchTerm, languageDirections, scrollToBottom, - setCookies, + setBreakpointCookie, sleep, } from "~~/test/playwright/utils/navigation" @@ -20,10 +20,8 @@ const headerSelector = ".main-header" for (const dir of languageDirections) { test.describe(`header-${dir}`, () => { breakpoints.describeEvery(({ breakpoint, expectSnapshot }) => { - test.beforeEach(async ({ context, page }) => { - if (!isMobileBreakpoint(breakpoint)) { - await setCookies(context, { uiIsDesktopLayout: true }) - } + test.beforeEach(async ({ page }) => { + await setBreakpointCookie(page, breakpoint) await goToSearchTerm(page, "birds", { dir }) }) @@ -39,7 +37,7 @@ for (const dir of languageDirections) { test("resting", async ({ page }) => { // By default, filters are open on desktop. We need to close them. if (!isMobileBreakpoint(breakpoint)) { - await closeFilters(page) + await filters.close(page) } // Make sure the header is not hovered on await page.mouse.move(0, 150) @@ -48,7 +46,7 @@ for (const dir of languageDirections) { test("scrolled", async ({ page }) => { if (!isMobileBreakpoint(breakpoint)) { - await closeFilters(page) + await filters.close(page) } await scrollToBottom(page) await page.mouse.move(0, 150) @@ -58,7 +56,7 @@ for (const dir of languageDirections) { test("searchbar hovered", async ({ page }) => { if (!isMobileBreakpoint(breakpoint)) { - await closeFilters(page) + await filters.close(page) } await page.hover("input") await hideInputCursors(page) @@ -70,7 +68,7 @@ for (const dir of languageDirections) { test("searchbar active", async ({ page }) => { if (!isMobileBreakpoint(breakpoint)) { - await closeFilters(page) + await filters.close(page) } await hideInputCursors(page) await page.click("input") diff --git a/frontend/test/playwright/visual-regression/pages/homepage.spec.ts b/frontend/test/playwright/visual-regression/pages/homepage.spec.ts index ce8195e5cc..29f772ce1a 100644 --- a/frontend/test/playwright/visual-regression/pages/homepage.spec.ts +++ b/frontend/test/playwright/visual-regression/pages/homepage.spec.ts @@ -4,8 +4,8 @@ import breakpoints from "~~/test/playwright/utils/breakpoints" import { hideInputCursors } from "~~/test/playwright/utils/page" import { dismissTranslationBanner, - pathWithDir, languageDirections, + pathWithDir, } from "~~/test/playwright/utils/navigation" test.describe.configure({ mode: "parallel" }) diff --git a/frontend/test/playwright/visual-regression/pages/no-results.spec.ts b/frontend/test/playwright/visual-regression/pages/no-results.spec.ts index f9389c3ae9..fa070e15a3 100644 --- a/frontend/test/playwright/visual-regression/pages/no-results.spec.ts +++ b/frontend/test/playwright/visual-regression/pages/no-results.spec.ts @@ -1,9 +1,11 @@ import { test } from "@playwright/test" import { + closeFiltersUsingCookies, + dismissBannersUsingCookies, goToSearchTerm, languageDirections, - setCookies, + setBreakpointCookie, } from "~~/test/playwright/utils/navigation" import breakpoints from "~~/test/playwright/utils/breakpoints" @@ -18,11 +20,10 @@ for (const searchType of supportedSearchTypes) { test(`No results ${searchType} ${dir} page snapshots`, async ({ page, }) => { - await setCookies(page.context(), { - uiBreakpoint: breakpoint as string, - uiIsFilterDismissed: true, - uiDismissedBanners: ["translation-ar"], - }) + await dismissBannersUsingCookies(page) + await closeFiltersUsingCookies(page) + await setBreakpointCookie(page, breakpoint) + await goToSearchTerm(page, "querywithnoresults", { dir, searchType }) await expectSnapshot(`no-results-${searchType}-${dir}`, page, { diff --git a/frontend/test/playwright/visual-regression/pages/pages-single-result.spec.ts b/frontend/test/playwright/visual-regression/pages/pages-single-result.spec.ts index 38dab580b0..b46ee2684f 100644 --- a/frontend/test/playwright/visual-regression/pages/pages-single-result.spec.ts +++ b/frontend/test/playwright/visual-regression/pages/pages-single-result.spec.ts @@ -2,11 +2,13 @@ import { test } from "@playwright/test" import breakpoints from "~~/test/playwright/utils/breakpoints" import { + closeFiltersUsingCookies, + dismissBannersUsingCookies, goToSearchTerm, languageDirections, openFirstResult, pathWithDir, - setCookies, + setBreakpointCookie, } from "~~/test/playwright/utils/navigation" import { supportedMediaTypes } from "~/constants/media" @@ -19,12 +21,11 @@ for (const mediaType of supportedMediaTypes) { breakpoints.describeEvery( { maxDiffPixelRatio: 0.02 }, ({ breakpoint, expectSnapshot }) => { - test.beforeEach(async ({ context, page }) => { - await setCookies(context, { - uiBreakpoint: breakpoint, - uiIsFilterDismissed: true, - uiDismissedBanners: ["translation-ar"], - }) + test.beforeEach(async ({ page }) => { + await closeFiltersUsingCookies(page) + await dismissBannersUsingCookies(page) + await setBreakpointCookie(page, breakpoint) + await goToSearchTerm(page, "birds", { dir }) }) @@ -47,11 +48,10 @@ for (const mediaType of supportedMediaTypes) { for (const dir of languageDirections) { breakpoints.describeMobileAndDesktop(({ breakpoint, expectSnapshot }) => { test(`${dir} full-page report snapshots`, async ({ page }) => { - await setCookies(page.context(), { - uiBreakpoint: breakpoint, - uiIsFilterDismissed: true, - uiDismissedBanners: ["translation-ar"], - }) + await dismissBannersUsingCookies(page) + await closeFiltersUsingCookies(page) + await setBreakpointCookie(page, breakpoint) + const IMAGE_ID = "da5cb478-c093-4d62-b721-cda18797e3fb" const path = pathWithDir(`/image/${IMAGE_ID}/report`, dir) diff --git a/frontend/test/playwright/visual-regression/pages/pages.spec.ts b/frontend/test/playwright/visual-regression/pages/pages.spec.ts index b78c37466a..a31c696eb0 100644 --- a/frontend/test/playwright/visual-regression/pages/pages.spec.ts +++ b/frontend/test/playwright/visual-regression/pages/pages.spec.ts @@ -3,9 +3,11 @@ import { expect, Page, test } from "@playwright/test" import breakpoints from "~~/test/playwright/utils/breakpoints" import { removeHiddenOverflow } from "~~/test/playwright/utils/page" import { - pathWithDir, + closeFiltersUsingCookies, + dismissBannersUsingCookies, languageDirections, - setCookies, + pathWithDir, + setBreakpointCookie, } from "~~/test/playwright/utils/navigation" test.describe.configure({ mode: "parallel" }) @@ -23,12 +25,11 @@ for (const contentPage of contentPages) { test.describe.configure({ retries: 2 }) breakpoints.describeEvery(({ breakpoint, expectSnapshot }) => { - test.beforeEach(async ({ context, page }) => { - await setCookies(context, { - uiBreakpoint: breakpoint as string, - uiIsFilterDismissed: true, - uiDismissedBanners: ["translation-ar"], - }) + test.beforeEach(async ({ page }) => { + await dismissBannersUsingCookies(page) + await closeFiltersUsingCookies(page) + await setBreakpointCookie(page, breakpoint) + await page.goto(pathWithDir(contentPage, dir)) }) @@ -60,10 +61,8 @@ test.describe("Layout color is set correctly", () => { await page.getByPlaceholder("البحث عن محتوى").fill("cat") - await Promise.all([ - page.waitForNavigation(), - page.getByRole("button", { name: "يبحث" }).click(), - ]) + await page.getByRole("button", { name: "يبحث" }).click() + await page.waitForURL(/ar\/search/) await cleanImageResults(page)