diff --git a/.eslintrc.js b/.eslintrc.js index 625d2154f08..3453d34a83c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -215,6 +215,15 @@ module.exports = { ], }, }, + { + files: ["frontend/test/{playwright,storybook}/**"], + plugins: ["playwright"], + extends: ["plugin:playwright/recommended"], + rules: { + // Enable once https://github.com/playwright-community/eslint-plugin-playwright/issues/154 is resolved + "playwright/expect-expect": ["off"], + }, + }, { files: [ "automations/js/src/**", diff --git a/frontend/package.json b/frontend/package.json index 6f1bc2e81cd..8b3eda450d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test:unit": "jest", "test:unit:watch": "pnpm test:unit --collectCoverage=false --watch", "test:playwright": "./bin/playwright.sh", - "test:playwright:local": "pnpm exec playwright test -c test/playwright", + "test:playwright:local": "playwright test -c test/playwright", "test:playwright:debug": "PWDEBUG=1 pnpm test:playwright:local", "test:playwright:recreate-tapes": "rimraf test/tapes && pnpm test:playwright:update-tapes", "test:playwright:update-tapes": "UPDATE_TAPES=true pnpm test:playwright", diff --git a/frontend/test/playwright/e2e/all-results-keyboard.spec.ts b/frontend/test/playwright/e2e/all-results-keyboard.spec.ts index ab1eb3855ae..950402e1932 100644 --- a/frontend/test/playwright/e2e/all-results-keyboard.spec.ts +++ b/frontend/test/playwright/e2e/all-results-keyboard.spec.ts @@ -51,12 +51,12 @@ test.describe("all results grid keyboard accessibility test", () => { test("should open image results as links", async ({ page }) => { await walkToType("image", page) await page.keyboard.press("Enter") - await page.waitForURL( - new RegExp( - `/image/[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\\?q=birds$`, - "i" - ) + const urlRegex = new RegExp( + `/image/[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\\?q=birds$`, + "i" ) + await page.waitForURL(urlRegex) + expect(page.url()).toMatch(urlRegex) }) test("should open audio results as links", async ({ page }) => { @@ -123,6 +123,6 @@ test.describe("all results grid keyboard accessibility test", () => { await audio.getActive(nextFocusedResult) await expect(playButton).toBeVisible() - await expect(pauseButton).not.toBeVisible() + await expect(pauseButton).toBeHidden() }) }) diff --git a/frontend/test/playwright/e2e/audio-detail.spec.ts b/frontend/test/playwright/e2e/audio-detail.spec.ts index df732e32454..667df76c91e 100644 --- a/frontend/test/playwright/e2e/audio-detail.spec.ts +++ b/frontend/test/playwright/e2e/audio-detail.spec.ts @@ -68,10 +68,10 @@ test("sends a custom event on seek", async ({ page, context }) => { test("shows the 404 error page when no valid id", async ({ page }) => { await page.goto("audio/foo") - await showsErrorPage(page) + await expect(showsErrorPage(page)).resolves.toBeUndefined() }) test("shows the 404 error page when no id", async ({ page }) => { await page.goto("audio/") - await showsErrorPage(page) + await expect(showsErrorPage(page)).resolves.toBeUndefined() }) diff --git a/frontend/test/playwright/e2e/external-sources.spec.ts b/frontend/test/playwright/e2e/external-sources.spec.ts index 2c0bc184ab7..9fcd7013fd4 100644 --- a/frontend/test/playwright/e2e/external-sources.spec.ts +++ b/frontend/test/playwright/e2e/external-sources.spec.ts @@ -25,10 +25,6 @@ test("sends correct analytics events", async ({ page, context }) => { (event) => event.n === "SELECT_EXTERNAL_SOURCE" ) - if (!viewEvent || !selectEvent) { - throw new Error("Analytics events were not triggered properly.") - } - expectEventPayloadToMatch(viewEvent, { searchType: "all", query: "cat", diff --git a/frontend/test/playwright/e2e/filters-sidebar-keyboard.spec.ts b/frontend/test/playwright/e2e/filters-sidebar-keyboard.spec.ts index e991ae55431..7220a837650 100644 --- a/frontend/test/playwright/e2e/filters-sidebar-keyboard.spec.ts +++ b/frontend/test/playwright/e2e/filters-sidebar-keyboard.spec.ts @@ -46,9 +46,10 @@ for (const dir of languageDirections) { await walkToFilterButton(page) // Check that the filters sidebar is open - expect( - await page.locator("#filter-button").getAttribute("aria-expanded") - ).toBe("true") + await expect(page.locator("#filter-button")).toHaveAttribute( + "aria-expanded", + "true" + ) await page.keyboard.press(keycodes.Tab) diff --git a/frontend/test/playwright/e2e/filters.spec.ts b/frontend/test/playwright/e2e/filters.spec.ts index f16adc34566..7219482b4c1 100644 --- a/frontend/test/playwright/e2e/filters.spec.ts +++ b/frontend/test/playwright/e2e/filters.spec.ts @@ -1,7 +1,6 @@ import { test, expect, Page } from "@playwright/test" import { - assertCheckboxStatus, changeSearchType, goToSearchTerm, isPageDesktop, @@ -12,6 +11,8 @@ import { mockProviderApis } from "~~/test/playwright/utils/route" import breakpoints from "~~/test/playwright/utils/breakpoints" +import enMessages from "~/locales/en.json" + import { supportedSearchTypes, ALL_MEDIA, @@ -53,7 +54,9 @@ breakpoints.describeMobileAndDesktop(() => { await filters.open(page) - await assertCheckboxCount(page, "total", FILTER_COUNTS[searchType]) + await expect( + assertCheckboxCount(page, "total", FILTER_COUNTS[searchType]) + ).resolves.toBeUndefined() }) } @@ -63,10 +66,10 @@ breakpoints.describeMobileAndDesktop(() => { ) await filters.open(page) // Creator filter was removed from the UI - const expectedFilters = ["cc0", "commercial"] + const expectedFilters = ["Zero", "Use commercially"] for (const checkbox of expectedFilters) { - await assertCheckboxStatus(page, checkbox) + await expect(page.getByRole("checkbox", { name: checkbox })).toBeChecked() } }) @@ -78,10 +81,10 @@ breakpoints.describeMobileAndDesktop(() => { ) await filters.open(page) // Creator filter was removed from the UI - const expectedFilters = ["cc0", "commercial"] + const expectedFilters = ["Zero", "Use commercially"] for (const checkbox of expectedFilters) { - await assertCheckboxStatus(page, checkbox) + await expect(page.getByRole("checkbox", { name: checkbox })).toBeChecked() } await changeSearchType(page, IMAGE) @@ -90,7 +93,7 @@ breakpoints.describeMobileAndDesktop(() => { ) await filters.open(page) for (const checkbox of expectedFilters) { - await assertCheckboxStatus(page, checkbox) + await expect(page.getByRole("checkbox", { name: checkbox })).toBeChecked() } }) @@ -103,8 +106,8 @@ breakpoints.describeMobileAndDesktop(() => { await filters.open(page) // Creator filter was removed from the UI - for (const checkbox of ["cc0", "commercial"]) { - await assertCheckboxStatus(page, checkbox) + for (const checkbox of ["Zero", "Use commercially"]) { + await expect(page.getByRole("checkbox", { name: checkbox })).toBeChecked() } await changeSearchType(page, ALL_MEDIA) @@ -120,23 +123,64 @@ breakpoints.describeMobileAndDesktop(() => { test("selecting some filters can disable dependent filters", async ({ page, }) => { - await page.goto("/search/audio?q=cat&license_type=commercial") + // Ignore the "+" licenses which are not presented on the page + // `exact: true` is required in locators later in this test to prevent "Attribution" from matching + // all CC licenses with the BY element (all of them :P) + const allLicenses = Object.values(enMessages.licenseReadableNames).filter( + (l) => !l.includes("Plus") + ) + const nonCommercialLicenses = allLicenses.filter((l) => + l.includes("NonCommercial") + ) + const commercialLicenses = allLicenses.filter( + (l) => !nonCommercialLicenses.includes(l) + ) + + await page.goto("/search/audio?q=cat") 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 expect( + page.getByRole("checkbox", { name: "Use commercially" }) + ).not.toBeChecked() + + // Use commercially is not enabled yet, so commercial licenses are still available + // Therefore, all active licenses should have enabled checkboxes + for (const checkbox of allLicenses) { + const element = page.getByRole("checkbox", { + name: checkbox, + exact: true, + }) + await expect(element).toBeVisible() + await expect(element).toBeEnabled() } - await assertCheckboxStatus(page, "commercial") - await page.click('label:has-text("commercial")') + // Enable the commercial use filter + await page.locator('label:has-text("Use commercially")').click() + + await expect( + page.getByRole("checkbox", { name: "Use commercially" }) + ).toBeChecked() + + // Because we checked "Use commercially", licenses that disallow commercial + // use will be disabled and the rest will still be enabled. + // Additionally, none of the checkboxes will be checked because we've only + // manipulated the commercial filter, not any specific license filters + for (const checkbox of nonCommercialLicenses) { + await expect( + page.getByRole("checkbox", { name: checkbox, exact: true }) + ).not.toBeChecked() + await expect( + page.getByRole("checkbox", { name: checkbox, exact: true }) + ).toBeDisabled() + } - 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") + for (const checkbox of commercialLicenses) { + await expect( + page.getByRole("checkbox", { name: checkbox, exact: true }) + ).not.toBeChecked() + await expect( + page.getByRole("checkbox", { name: checkbox, exact: true }) + ).toBeEnabled() } }) @@ -153,16 +197,18 @@ breakpoints.describeMobileAndDesktop(() => { await page.goto("/search/image?q=cat&aspect_ratio=tall&license=cc0") await filters.open(page) - await assertCheckboxStatus(page, "tall") - await assertCheckboxStatus(page, "cc0") + await expect(page.getByRole("checkbox", { name: "Tall" })).toBeChecked() + await expect(page.getByRole("checkbox", { name: "Zero" })).toBeChecked() 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 expect(page.getByRole("checkbox", { name: "Zero" })).toBeChecked() + await filters.close(page) + // eslint-disable-next-line playwright/no-conditional-in-test if (isPageDesktop(page)) { const filterButtonText = await page .locator('[aria-controls="filters"] span:visible') @@ -170,6 +216,7 @@ breakpoints.describeMobileAndDesktop(() => { expect(filterButtonText).toContain("Filters") } else { const filtersAriaLabel = + // eslint-disable-next-line playwright/no-conditional-in-test (await page .locator('[aria-controls="content-settings-modal"]') .getAttribute("aria-label")) ?? "" @@ -185,18 +232,20 @@ breakpoints.describeMobileAndDesktop(() => { await page.goto("/search/image?q=cat") await filters.open(page) - await assertCheckboxStatus(page, "cc0", "unchecked") + await expect(page.getByRole("checkbox", { name: "Zero" })).not.toBeChecked() - const [response] = await Promise.all([ - page.waitForResponse((response) => response.url().includes("cc0")), - page.click('label:has-text("CC0")'), - ]) + // Alternative way with a predicate. Note no await. + const responsePromise = page.waitForResponse( + (response) => + response.url().includes("/images/") && response.status() === 200 + ) + await page.getByLabel("Zero").click() + const response = await responsePromise - await assertCheckboxStatus(page, "cc0") + await expect(page.getByRole("checkbox", { name: "Zero" })).toBeChecked() // Remove the host url and path because when proxied, the 'http://localhost:49153' is used instead of the // real API url - const queryString = response.url().split("/images/")[1] - expect(queryString).toEqual("?q=cat&license=cc0") + expect(response.url()).toContain("?q=cat&license=cc0") }) for (const [searchType, source] of [ @@ -211,7 +260,7 @@ breakpoints.describeMobileAndDesktop(() => { ) await filters.open(page) - await assertCheckboxStatus(page, source, "checked") + await expect(page.getByRole("checkbox", { name: source })).toBeChecked() }) } }) diff --git a/frontend/test/playwright/e2e/global-audio.spec.ts b/frontend/test/playwright/e2e/global-audio.spec.ts index 01a0e7bed99..3dfb0d8da1c 100644 --- a/frontend/test/playwright/e2e/global-audio.spec.ts +++ b/frontend/test/playwright/e2e/global-audio.spec.ts @@ -39,7 +39,7 @@ test.describe("Global Audio", () => { .getByRole("button", { name: t("audioTrack.close") }) .click() // and confirm the player is not visible - await expect(page.locator(".global-audio")).not.toBeVisible() + await expect(page.locator(".global-audio")).toBeHidden() }) test("player does not reproduce an audio different that the current audio in the details page", async ({ diff --git a/frontend/test/playwright/e2e/homepage.spec.ts b/frontend/test/playwright/e2e/homepage.spec.ts index 8c22c465da1..16f92811fa9 100644 --- a/frontend/test/playwright/e2e/homepage.spec.ts +++ b/frontend/test/playwright/e2e/homepage.spec.ts @@ -30,7 +30,7 @@ const searchTypePopover = "[aria-labelledby='search-type-button'] > div" const popoverIsVisible = async (page: Page) => await expect(page.locator(searchTypePopover)).toBeVisible() const popoverIsNotVisible = async (page: Page) => - await expect(page.locator(searchTypePopover)).not.toBeVisible() + await expect(page.locator(searchTypePopover)).toBeHidden() const clickPopoverButton = async (page: Page) => await page.getByRole("button", { name: t("searchType.all") }).click() diff --git a/frontend/test/playwright/e2e/image-detail.spec.ts b/frontend/test/playwright/e2e/image-detail.spec.ts index 7b3de2828ab..2a6005c9181 100644 --- a/frontend/test/playwright/e2e/image-detail.spec.ts +++ b/frontend/test/playwright/e2e/image-detail.spec.ts @@ -38,9 +38,7 @@ test("shows the main image with its title as alt text", async ({ page }) => { test("does not show back to search results breadcrumb", async ({ page }) => { await goToCustomImagePage(page) - await expect( - page.locator(`text="${t("singleResult.back")}"`) - ).not.toBeVisible({ + await expect(page.locator(`text="${t("singleResult.back")}"`)).toBeHidden({ timeout: 300, }) }) diff --git a/frontend/test/playwright/e2e/load-more.spec.ts b/frontend/test/playwright/e2e/load-more.spec.ts index 88834e32ab8..5abc3861ecc 100644 --- a/frontend/test/playwright/e2e/load-more.spec.ts +++ b/frontend/test/playwright/e2e/load-more.spec.ts @@ -128,7 +128,7 @@ test.describe("Load more button", () => { mode, searchType: AUDIO, }) - await expect(page.locator(loadMoreButton)).not.toBeVisible() + await expect(page.locator(loadMoreButton)).toBeHidden() }) /** @@ -142,19 +142,18 @@ test.describe("Load more button", () => { const analyticsEvents = collectAnalyticsEvents(context) await goToSearchTerm(page, "cat") + await page.locator(loadMoreButton).scrollIntoViewIfNeeded() await expect(page.locator(loadMoreButton)).toBeVisible() const reachResultEndEvent = analyticsEvents.find( (event) => event.n === "REACH_RESULT_END" ) - if (reachResultEndEvent) { - expectEventPayloadToMatch(reachResultEndEvent, { - query: "cat", - searchType: "all", - resultPage: 1, - }) - } + expectEventPayloadToMatch(reachResultEndEvent, { + query: "cat", + searchType: "all", + resultPage: 1, + }) }) }) } @@ -167,18 +166,12 @@ test.describe("Load more button", () => { const analyticsEvents = collectAnalyticsEvents(context) await goToSearchTerm(page, "cat") - await expect(page.locator(loadMoreButton)).toBeVisible() - await page.click(loadMoreButton) const loadMoreEvent = analyticsEvents.find( (event) => event.n === "LOAD_MORE_RESULTS" ) - if (!loadMoreEvent) { - throw new Error("Load more event did not send.") - } - expectEventPayloadToMatch(loadMoreEvent, { query: "cat", searchType: "all", @@ -202,10 +195,6 @@ test.describe("Load more button", () => { (event) => event.n === "LOAD_MORE_RESULTS" ) - if (!loadMoreEvents) { - throw new Error("Load more event did not send.") - } - expect(loadMoreEvents.length).toBe(2) loadMoreEvents.every((event, index) => expectEventPayloadToMatch(event, { diff --git a/frontend/test/playwright/e2e/migration-banner.spec.ts b/frontend/test/playwright/e2e/migration-banner.spec.ts index 95f8c27190b..b12fbe11ad0 100644 --- a/frontend/test/playwright/e2e/migration-banner.spec.ts +++ b/frontend/test/playwright/e2e/migration-banner.spec.ts @@ -58,9 +58,9 @@ test.describe("migration banner", () => { await migrationNotice .locator(`[aria-label="${t("migrationNotice.close")}"]:visible`) .click({ timeout: 500 }) - await expect(migrationNotice).not.toBeVisible({ timeout: 500 }) + await expect(migrationNotice).toBeHidden({ timeout: 500 }) await page.reload() - await expect(migrationNotice).not.toBeVisible({ timeout: 500 }) + await expect(migrationNotice).toBeHidden({ timeout: 500 }) }) }) diff --git a/frontend/test/playwright/e2e/report-media.spec.ts b/frontend/test/playwright/e2e/report-media.spec.ts index eb2393a7c65..7f379882a96 100644 --- a/frontend/test/playwright/e2e/report-media.spec.ts +++ b/frontend/test/playwright/e2e/report-media.spec.ts @@ -55,7 +55,7 @@ const submitDmcaReport = async (page: Page, context: BrowserContext) => { await page.click('text="Open form"'), // Opens a new tab ]) await newPage.waitForLoadState() - return expect(await newPage.url()).toContain("https://docs.google.com/forms") + return expect(newPage.url()).toContain("https://docs.google.com/forms") } // todo: Test a mature report with the optional description field diff --git a/frontend/test/playwright/e2e/search-navigation.spec.ts b/frontend/test/playwright/e2e/search-navigation.spec.ts index fe5f047eea6..c38eb3398c8 100644 --- a/frontend/test/playwright/e2e/search-navigation.spec.ts +++ b/frontend/test/playwright/e2e/search-navigation.spec.ts @@ -32,8 +32,11 @@ test.describe("search history navigation", () => { // Open filter sidebar await filters.open(page) + const modifyLocator = page.getByRole("checkbox", { + name: "Modify or adapt", + }) // Apply a filter - await page.click("#modification") + await modifyLocator.click() // There is a debounce when choosing a filter. // we need to wait for the page to reload before running the test await page.waitForURL(/license_type=modification/) @@ -42,14 +45,14 @@ test.describe("search history navigation", () => { // 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(await page.isChecked("#modification")).toBe(true) + await expect(modifyLocator).toBeChecked() // Navigate backwards and verify URL is updated and the filter is unapplied await page.goBack() // Ditto here about the note above, need to verify a new search actually happened with new results expect(page.url()).not.toContain("license_type=modification") - expect(await page.isChecked("#modification")).toBe(false) + await expect(modifyLocator).not.toBeChecked() }) test("should update search results when back button updates search type", async ({ @@ -62,7 +65,7 @@ test.describe("search history navigation", () => { expect(page.url()).toContain("/search/image") // There are no content links on single media type search pages - await expect(await getContentLink(page, IMAGE)).not.toBeVisible() + await expect(await getContentLink(page, IMAGE)).toBeHidden() await page.goBack({ waitUntil: "networkidle" }) @@ -76,13 +79,12 @@ test.describe("search history navigation", () => { await goToSearchTerm(page, "galah") await searchFromHeader(page, "cat") - expect(await page.locator('input[name="q"]').inputValue()).toBe("cat") + await expect(page.locator('input[name="q"]')).toHaveValue("cat") await page.goBack() await expect(await getContentLink(page, IMAGE)).toBeVisible() - - expect(await page.locator('input[name="q"]').inputValue()).toBe("galah") + await expect(page.locator('input[name="q"]')).toHaveValue("galah") }) test("navigates to the image detail page correctly", async ({ page }) => { diff --git a/frontend/test/playwright/e2e/search-query-server.spec.ts b/frontend/test/playwright/e2e/search-query-server.spec.ts index cae7deadb02..4d9dcb65b3d 100644 --- a/frontend/test/playwright/e2e/search-query-server.spec.ts +++ b/frontend/test/playwright/e2e/search-query-server.spec.ts @@ -1,7 +1,6 @@ import { test, expect } from "@playwright/test" import { - assertCheckboxStatus, currentContentType, filters, goToSearchTerm, @@ -72,8 +71,10 @@ test.describe("search query on SSR", () => { await filters.open(page) // Creator filter was removed from the UI - for (const checkbox of ["cc0", "commercial"]) { - await assertCheckboxStatus(page, checkbox) + for (const checkbox of ["Zero", "Use commercially"]) { + await expect( + page.getByRole("checkbox", { name: checkbox }) + ).toBeChecked() } }) @@ -87,10 +88,15 @@ test.describe("search query on SSR", () => { await filters.open(page) const checkboxes = ["JPEG", "PNG", "GIF", "SVG"] for (const checkbox of checkboxes) { - await assertCheckboxStatus(page, checkbox) + // exact: true required to prevent `SVG` matching a provider with SVG in the name + await expect( + page.getByRole("checkbox", { name: checkbox, exact: true }) + ).toBeChecked() } }) + // https://github.com/WordPress/openverse/issues/2572 + // eslint-disable-next-line playwright/no-skipped-test test.skip("url mature query is set, and can be unchecked using the Safer Browsing popup", async ({ page, }) => { @@ -101,7 +107,7 @@ test.describe("search query on SSR", () => { await page.click('button:has-text("Safer Browsing")') - const matureCheckbox = await page.locator("text=Show Mature Content") + const matureCheckbox = page.locator("text=Show Mature Content") await expect(matureCheckbox).toBeChecked() await page.click("text=Show Mature Content") diff --git a/frontend/test/playwright/e2e/search-types.spec.ts b/frontend/test/playwright/e2e/search-types.spec.ts index b7ce7907efc..bec1c4864b6 100644 --- a/frontend/test/playwright/e2e/search-types.spec.ts +++ b/frontend/test/playwright/e2e/search-types.spec.ts @@ -111,6 +111,7 @@ test.describe("search types", () => { test(`Can open ${searchType.name} page client-side`, async ({ page }) => { // Audio is loading a lot of files, so we do not use it for the first SSR page const pageToOpen = + // eslint-disable-next-line playwright/no-conditional-in-test searchType.id === "all" ? searchTypes[1] : searchTypes[0] await page.goto(pageToOpen.url) await changeSearchType(page, searchType.id) @@ -126,7 +127,7 @@ test.describe("search types", () => { page, }) => { await page.goto("/search/?q=birds") - const contentLink = await page.locator( + const contentLink = page.locator( `a:not([role="radio"])[href*="/search/${searchTypeName}"][href$="q=birds"]` ) await expect(contentLink).toContainText(searchType.results) diff --git a/frontend/test/playwright/e2e/translation-banner.spec.ts b/frontend/test/playwright/e2e/translation-banner.spec.ts index 7f849ca7313..bff4e5744a7 100644 --- a/frontend/test/playwright/e2e/translation-banner.spec.ts +++ b/frontend/test/playwright/e2e/translation-banner.spec.ts @@ -32,9 +32,9 @@ test.describe("translation banner", () => { await dismissBannersUsingCookies(page) await page.goto(russianSearchPath) - await expect( - page.locator('[data-testid="banner-translation"]') - ).not.toBeVisible({ timeout: 500 }) + await expect(page.locator('[data-testid="banner-translation"]')).toBeHidden( + { timeout: 500 } + ) }) test("Can close the translation banner", async ({ page }) => { @@ -44,12 +44,12 @@ test.describe("translation banner", () => { }) const banner = page.locator('.span:has-text("Help us get to 100 percent")') - await expect(banner).not.toBeVisible({ timeout: 500 }) + await expect(banner).toBeHidden({ timeout: 500 }) // Test that the banner does not re-appear when navigating to the 'About us' page await page.locator('a[href="/ru/about"]').click() - await expect(banner).not.toBeVisible({ timeout: 500 }) + await expect(banner).toBeHidden({ timeout: 500 }) await page.goto(russianSearchPath) - await expect(banner).not.toBeVisible({ timeout: 500 }) + await expect(banner).toBeHidden({ timeout: 500 }) }) }) diff --git a/frontend/test/playwright/utils/analytics.ts b/frontend/test/playwright/utils/analytics.ts index 6692da99475..32580990a73 100644 --- a/frontend/test/playwright/utils/analytics.ts +++ b/frontend/test/playwright/utils/analytics.ts @@ -17,12 +17,12 @@ export function expectEventPayloadToMatch( event: EventResponse | undefined, expectedPayload: Events[T] ): void { - if (!event) { - throw new Error( - `Event is null; expected payload of ${JSON.stringify(expectedPayload)}` - ) - } - expect(event.p).toMatchObject(expectedPayload) + expect( + event, + `Event not captured; expected payload of ${JSON.stringify(expectedPayload)}` + ).toBeDefined() + // Safe to cast as previous line ensures it is defined + expect((event as EventResponse).p).toMatchObject(expectedPayload) } export const collectAnalyticsEvents = (context: BrowserContext) => { @@ -35,7 +35,7 @@ export const collectAnalyticsEvents = (context: BrowserContext) => { if (parsedData.p) { event.p = JSON.parse(parsedData.p) } - sentAnalyticsEvents.push({ ...parsedData }) + sentAnalyticsEvents.push({ ...event }) } route.abort() }) diff --git a/frontend/test/playwright/utils/audio.ts b/frontend/test/playwright/utils/audio.ts index 3117b99dbbc..7efa8cdd35b 100644 --- a/frontend/test/playwright/utils/audio.ts +++ b/frontend/test/playwright/utils/audio.ts @@ -67,7 +67,7 @@ const getInactive = async (context: Locator) => (await getAllInactive(context, { filterVisible: true }))[0] const getNthAudioRow = async (page: Page, num: number) => { - const nthAudioRow = await page.getByRole("application").nth(num) + const nthAudioRow = page.getByRole("application").nth(num) await expect(nthAudioRow.getByRole("article")).toHaveAttribute( "status", "paused" diff --git a/frontend/test/playwright/utils/navigation.ts b/frontend/test/playwright/utils/navigation.ts index 823eaadf329..f81eb7b8e1b 100644 --- a/frontend/test/playwright/utils/navigation.ts +++ b/frontend/test/playwright/utils/navigation.ts @@ -192,26 +192,6 @@ export const isDialogOpen = async (page: Page) => { return page.getByRole("dialog").isVisible({ timeout: 100 }) } -/** - * 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 assertCheckboxStatus = async ( - page: Page, - label: string | RegExp, - status: CheckboxStatus = "checked" -) => { - const labelRegexp = typeof label === "string" ? new RegExp(label, "i") : label - await page.getByRole("checkbox", { - name: labelRegexp, - disabled: status === "disabled", - checked: status === "checked", - }) -} - export const changeSearchType = async (page: Page, to: SupportedSearchType) => { await searchTypes.open(page) @@ -352,6 +332,9 @@ export const openFirstResult = async (page: Page, mediaType: MediaType) => { const firstResultHref = await getLocatorHref(firstResult) await firstResult.click({ position: { x: 32, y: 32 } }) await scrollDownAndUp(page) + // Wait for all pending requests to finish, at which point we know + // that all lazy-loaded content is available + // eslint-disable-next-line playwright/no-networkidle await page.waitForURL(firstResultHref, { waitUntil: "networkidle" }) await page.mouse.move(0, 0) } diff --git a/frontend/test/playwright/utils/page.ts b/frontend/test/playwright/utils/page.ts index 6222afd2212..535a62b729b 100644 --- a/frontend/test/playwright/utils/page.ts +++ b/frontend/test/playwright/utils/page.ts @@ -1,3 +1,7 @@ +// Disable no-element-handle for this utility +// It uses them appropriately to do meta-test setup +/* eslint playwright/no-element-handle: ["off"] */ + import type { Page } from "@playwright/test" export const removeHiddenOverflow = async (page: Page) => { diff --git a/frontend/test/playwright/visual-regression/components/header.spec.ts b/frontend/test/playwright/visual-regression/components/header.spec.ts index 5d5906d6ed0..ffbec5f758e 100644 --- a/frontend/test/playwright/visual-regression/components/header.spec.ts +++ b/frontend/test/playwright/visual-regression/components/header.spec.ts @@ -34,48 +34,48 @@ 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 filters.close(page) - } - // Make sure the header is not hovered on - await page.mouse.move(0, 150) - await expectSnapshot(`resting-${dir}`, page.locator(headerSelector)) - }) + test.describe("starting with closed filters", () => { + test.beforeEach(async ({ page }) => { + // By default, filters are open on desktop. We need to close them. + if (!isMobileBreakpoint(breakpoint)) { + await filters.close(page) + } + }) - test("scrolled", async ({ page }) => { - if (!isMobileBreakpoint(breakpoint)) { - await filters.close(page) - } - await scrollToBottom(page) - await page.mouse.move(0, 150) - await sleep(200) - await expectSnapshot(`scrolled-${dir}`, page.locator(headerSelector)) - }) + test("resting", async ({ page }) => { + // Make sure the header is not hovered on + await page.mouse.move(0, 150) + await expectSnapshot(`resting-${dir}`, page.locator(headerSelector)) + }) - test("searchbar hovered", async ({ page }) => { - if (!isMobileBreakpoint(breakpoint)) { - await filters.close(page) - } - await page.hover("input") - await hideInputCursors(page) - await expectSnapshot( - `searchbar-hovered-${dir}`, - page.locator(headerSelector) - ) - }) + test("scrolled", async ({ page }) => { + await scrollToBottom(page) + await page.mouse.move(0, 150) + await sleep(200) + await expectSnapshot(`scrolled-${dir}`, page.locator(headerSelector)) + }) + + test("searchbar hovered", async ({ page }) => { + await page.hover("input") + await hideInputCursors(page) + await expectSnapshot( + `searchbar-hovered-${dir}`, + page.locator(headerSelector) + ) + }) - test("searchbar active", async ({ page }) => { - if (!isMobileBreakpoint(breakpoint)) { - await filters.close(page) - } - await hideInputCursors(page) - await page.click("input") - const locator = isMobileBreakpoint(breakpoint) - ? page - : page.locator(headerSelector) - await expectSnapshot(`searchbar-active-${dir}`, locator) + test("searchbar active", async ({ page }) => { + await hideInputCursors(page) + await page.click("input") + // Search takes up the entire view on mobile + // But on desktop, to reduce the snapshot size, we can scope the + // locator just to the header + // eslint-disable-next-line playwright/no-conditional-in-test + const locator = isMobileBreakpoint(breakpoint) + ? page + : page.locator(headerSelector) + await expectSnapshot(`searchbar-active-${dir}`, locator) + }) }) }) }) diff --git a/frontend/test/playwright/visual-regression/pages/homepage.spec.ts b/frontend/test/playwright/visual-regression/pages/homepage.spec.ts index d14949fd0bf..dc781907ae5 100644 --- a/frontend/test/playwright/visual-regression/pages/homepage.spec.ts +++ b/frontend/test/playwright/visual-regression/pages/homepage.spec.ts @@ -19,7 +19,9 @@ const cleanImageCarousel = async (page: Page) => { await page.addStyleTag({ content: ".home-cell > img { filter: brightness(0%); }", }) - await page.waitForTimeout(1000) // wait for animation to finish + // wait for animation to finish + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000) } for (const dir of languageDirections) { 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 2b9fe5a91d0..9b32033dd7c 100644 --- a/frontend/test/playwright/visual-regression/pages/no-results.spec.ts +++ b/frontend/test/playwright/visual-regression/pages/no-results.spec.ts @@ -42,9 +42,9 @@ for (const searchType of supportedSearchTypes) { return childHeight + headerElHeight }) - const viewportWidth = (await page.viewportSize()?.width) ?? 0 + const viewportWidth = page.viewportSize()?.width await page.setViewportSize({ - width: viewportWidth, + width: viewportWidth ?? 0, height: viewportHeight + 1, }) diff --git a/frontend/test/playwright/visual-regression/pages/pages.spec.ts b/frontend/test/playwright/visual-regression/pages/pages.spec.ts index 83bc487828b..a78fcad97b2 100644 --- a/frontend/test/playwright/visual-regression/pages/pages.spec.ts +++ b/frontend/test/playwright/visual-regression/pages/pages.spec.ts @@ -50,6 +50,7 @@ const cleanImageResults = async (page: Page) => { await page.addStyleTag({ content: ".results-grid img { filter: brightness(0%); }", }) + // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(500) } diff --git a/frontend/test/storybook/visual-regression/v-checkbox.spec.ts b/frontend/test/storybook/visual-regression/v-checkbox.spec.ts index 929676c0372..4b38d253844 100644 --- a/frontend/test/storybook/visual-regression/v-checkbox.spec.ts +++ b/frontend/test/storybook/visual-regression/v-checkbox.spec.ts @@ -82,6 +82,7 @@ test.describe("v-checkbox", () => { await checkbox.click() // `force: true` is required because the `input`'s pointer events are actually intercepted by the visual SVG. // We still want to check that it works though as that does mimic the user behavior of checking directly on the checkbox. + // eslint-disable-next-line playwright/no-force-option await checkbox.click({ force: true }) expect( await page.locator(".screenshot-area").screenshot() diff --git a/frontend/test/storybook/visual-regression/v-footer.spec.ts b/frontend/test/storybook/visual-regression/v-footer.spec.ts index 5cc38b864d4..afa4b49e584 100644 --- a/frontend/test/storybook/visual-regression/v-footer.spec.ts +++ b/frontend/test/storybook/visual-regression/v-footer.spec.ts @@ -21,11 +21,14 @@ test.describe("VFooter", () => { for (const dir of languageDirections) { for (const footerKind of footerKinds) { breakpoints.describeEvery(({ expectSnapshot }) => { - test(`footer-${footerKind}-${dir}`, async ({ page }) => { + test.beforeEach(async ({ page }) => { await page.goto(pageUrl(dir, footerKind)) if (dir === "rtl") { await page.locator("#language").selectOption({ value: "ar" }) } + }) + + test(`footer-${footerKind}-${dir}`, async ({ page }) => { await expectSnapshot( `footer-${footerKind}-${dir}`, page.locator("footer") diff --git a/frontend/test/storybook/visual-regression/v-media-reuse.spec.ts b/frontend/test/storybook/visual-regression/v-media-reuse.spec.ts index eb9dd7f80f1..7b6d66f89cd 100644 --- a/frontend/test/storybook/visual-regression/v-media-reuse.spec.ts +++ b/frontend/test/storybook/visual-regression/v-media-reuse.spec.ts @@ -19,14 +19,16 @@ test.describe("media-reuse", () => { for (const tab of tabs) { for (const dir of languageDirections) { breakpoints.describeEvery(({ expectSnapshot }) => { - test(`Should render a ${dir} media reuse section with "${tab.name}" tab open`, async ({ - page, - }) => { + test.beforeEach(async ({ page }) => { await page.goto(pageUrl(dir)) if (dir === "rtl") { await page.locator("#language").selectOption({ value: "ar" }) } + }) + test(`Should render a ${dir} media reuse section with "${tab.name}" tab open`, async ({ + page, + }) => { await page.locator(`#tab-${tab.id}`).click() // Make sure the tab is not focused and doesn't have a pink ring const reuseTitle = t("mediaDetails.reuse.title", dir) diff --git a/package.json b/package.json index 76e09a1430f..97b4dddcb3c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "eslint-plugin-import": "2.27.5", "eslint-plugin-jest": "27.2.1", "eslint-plugin-prettier": "4.2.1", + "eslint-plugin-playwright": "0.15.3", "eslint-plugin-tsdoc": "0.2.17", "eslint-plugin-unicorn": "42.0.0", "eslint-plugin-vue": "9.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad16e9a9945..f76f951f5a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,7 @@ importers: eslint-plugin-eslint-comments: 3.2.0 eslint-plugin-import: 2.27.5 eslint-plugin-jest: 27.2.1 + eslint-plugin-playwright: 0.15.3 eslint-plugin-prettier: 4.2.1 eslint-plugin-tsdoc: 0.2.17 eslint-plugin-unicorn: 42.0.0 @@ -38,6 +39,7 @@ importers: eslint-plugin-eslint-comments: 3.2.0_eslint@8.41.0 eslint-plugin-import: 2.27.5_m7z7hem2alvzntqboke5kjqj54 eslint-plugin-jest: 27.2.1_4exdxrpny6rv2wyed4ea7t42sm + eslint-plugin-playwright: 0.15.3_3ihmqpierzyltv7ppmnktkstfa eslint-plugin-prettier: 4.2.1_kuab6g4o5cytp2fsnrjqsidrgq eslint-plugin-tsdoc: 0.2.17 eslint-plugin-unicorn: 42.0.0_eslint@8.41.0 @@ -10434,6 +10436,21 @@ packages: natural-compare: 1.4.0 dev: true + /eslint-plugin-playwright/0.15.3_3ihmqpierzyltv7ppmnktkstfa: + resolution: {integrity: sha512-LQMW5y0DLK5Fnpya7JR1oAYL2/7Y9wDiYw6VZqlKqcRGSgjbVKNqxraphk7ra1U3Bb5EK444xMgUlQPbMg2M1g==} + peerDependencies: + eslint: '>=7' + eslint-plugin-jest: '>=25' + peerDependenciesMeta: + eslint: + optional: true + eslint-plugin-jest: + optional: true + dependencies: + eslint: 8.41.0 + eslint-plugin-jest: 27.2.1_4exdxrpny6rv2wyed4ea7t42sm + dev: true + /eslint-plugin-prettier/4.2.1_kuab6g4o5cytp2fsnrjqsidrgq: resolution: {integrity: sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==} engines: {node: '>=12.0.0'} @@ -17959,6 +17976,7 @@ packages: /signal-exit/3.0.6: resolution: {integrity: sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==} + dev: true /signal-exit/3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}