diff --git a/frontend/app/components/ui/Combobox.tsx b/frontend/app/components/ui/Combobox.tsx index 534a2525..e71d5174 100644 --- a/frontend/app/components/ui/Combobox.tsx +++ b/frontend/app/components/ui/Combobox.tsx @@ -69,7 +69,7 @@ function ComboboxInput({ {showTrigger && ( } data-slot="input-group-button" diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 615b0dcc..9dbc64cb 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -6,7 +6,24 @@ async function expectNoViolations(page: Page, loaded: Locator) { await expect(loaded).toBeVisible(); await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1); + // Wait for fade-ins (e.g. a popover opening or a button becoming enabled) to finish, + // otherwise Axe can measure half-transparent colors. Skips endless animations like spinners + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter( + (animation) => animation.effect?.getTiming().iterations !== Infinity, + ) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const results = await new AxeBuilder({ page }) + // Open dialogs and menus add hidden elements that keep keyboard focus inside them. + // Axe reports these as errors, but they are needed so keyboard navigation works correctly, + // so we exclude them. + .exclude("[data-base-ui-focus-guard]") .withTags([ "wcag2a", "wcag2aa", @@ -28,11 +45,10 @@ for (const colorScheme of ["light", "dark"] as const) { test("home page", async ({ page }) => { await page.goto("./"); - // Wait until "Surprise Me" button is ready - await expect(page.getByRole("button", { name: "Surprise me" })).toHaveCSS( - "opacity", - "1", - ); + // Wait until the "Surprise me" button is ready + await expect( + page.getByRole("button", { name: "Surprise me" }), + ).toBeEnabled(); await expectNoViolations(page, bookCards(page).first()); }); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index ccea5bac..d944a126 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -1,5 +1,12 @@ import { randomUUID } from "node:crypto"; -import { expect, logIn, test } from "./fixtures"; +import { + expect, + expectLoggedInAs, + expectLoggedOut, + logIn, + logOut, + test, +} from "./fixtures"; test("a new user can sign up", async ({ page }) => { await page.goto("signup"); @@ -11,9 +18,7 @@ test("a new user can sign up", async ({ page }) => { await page.getByRole("button", { name: "Sign up", exact: true }).click(); await expect(page).toHaveURL("./"); - await expect( - page.getByRole("banner").getByRole("link", { name: "Someone" }), - ).toBeVisible(); + await expectLoggedInAs(page, "Someone"); }); test("signing up with a used email shows an error", async ({ page, user }) => { @@ -33,15 +38,14 @@ test("signing up with a used email shows an error", async ({ page, user }) => { test("a user can log out and log in again", async ({ page, user }) => { await page.goto("./"); - const header = page.getByRole("banner"); - await header.getByRole("button", { name: "Log out" }).click(); - await expect(header.getByRole("link", { name: "Log in" })).toBeVisible(); + await logOut(page); + await expectLoggedOut(page); await logIn(page, user.email, user.password); await expect(page).toHaveURL("./"); - await expect(header.getByRole("link", { name: user.name })).toBeVisible(); + await expectLoggedInAs(page, user.name); }); test("logging in with a wrong password shows an error", async ({ diff --git a/frontend/e2e/favorites.spec.ts b/frontend/e2e/favorites.spec.ts index 955813b1..6194b333 100644 --- a/frontend/e2e/favorites.spec.ts +++ b/frontend/e2e/favorites.spec.ts @@ -1,4 +1,4 @@ -import { bookCards, expect, test } from "./fixtures"; +import { bookCards, expect, goToProfile, test } from "./fixtures"; test("logged out users are asked to log in to save favorites", async ({ page, @@ -37,7 +37,7 @@ test("a favorite shows on the profile and can be removed", async ({ page.getByRole("button", { name: `Favorite ${title}`, pressed: true }), ).toBeVisible(); - await page.getByRole("banner").getByRole("link", { name: user.name }).click(); + await goToProfile(page, user.name); const favorites = page.getByRole("region", { name: "Favorite books" }); await favorites.getByRole("link", { name: title }).click(); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 5ab8f846..19e5174c 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -43,4 +43,80 @@ async function logIn(page: Page, email: string, password: string) { await form.getByRole("button", { name: "Log in", exact: true }).click(); } -export { test, bookCards, logIn, expect }; +// Below Tailwind's md breakpoint the header shows a menu button instead of its links and buttons. +// Opens that menu on narrow screens and returns true, or returns false on wide screens. +async function openMobileMenu(page: Page) { + if (page.viewportSize()!.width >= 768) return false; + await page.getByRole("button", { name: "Open menu" }).click(); + return true; +} + +async function expectLoggedInAs(page: Page, name: string) { + if (await openMobileMenu(page)) { + await expect(page.getByText(`Signed in as ${name}`)).toBeVisible(); + await page.keyboard.press("Escape"); + } else { + await expect( + page.getByRole("banner").getByRole("link", { name }), + ).toBeVisible(); + } +} + +async function expectLoggedOut(page: Page) { + if (await openMobileMenu(page)) { + await expect(page.getByRole("menuitem", { name: "Log in" })).toBeVisible(); + await page.keyboard.press("Escape"); + } else { + await expect( + page.getByRole("banner").getByRole("link", { name: "Log in" }), + ).toBeVisible(); + } +} + +async function goToProfile(page: Page, name: string) { + if (await openMobileMenu(page)) { + await page.getByRole("menuitem", { name: "Profile" }).click(); + } else { + await page.getByRole("banner").getByRole("link", { name }).click(); + } +} + +async function logOut(page: Page) { + // Logging out reloads the page + const reloaded = page.waitForEvent("load"); + if (await openMobileMenu(page)) { + await page.getByRole("menuitem", { name: "Log out" }).click(); + } else { + await page + .getByRole("banner") + .getByRole("button", { name: "Log out" }) + .click(); + } + await reloaded; + // The reloaded page shows a loading screen (MainLoading) until the code for its route has + // loaded. Wait for the route, because navigating away while it is still loading makes + // React Router reload the page again, which cancels the navigation in Firefox and WebKit + await expect(page.getByRole("main")).not.toContainText("Loading..."); +} + +async function pickTheme(page: Page, theme: "Light" | "Dark" | "System") { + if (await openMobileMenu(page)) { + await page.getByRole("menuitem", { name: "Theme" }).click(); + } else { + await page.getByRole("button", { name: "Toggle theme" }).click(); + } + await page.getByRole("menuitem", { name: theme }).click(); +} + +export { + test, + bookCards, + logIn, + openMobileMenu, + expectLoggedInAs, + expectLoggedOut, + goToProfile, + logOut, + pickTheme, + expect, +}; diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index 66fe171b..05e37b47 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -1,4 +1,4 @@ -import { expect, logIn, test } from "./fixtures"; +import { expect, expectLoggedInAs, logIn, test } from "./fixtures"; test("profile changes show on the profile page", async ({ page, user }) => { await page.goto("settings"); @@ -29,9 +29,7 @@ test("a changed password works for the next log in", async ({ page, user }) => { await page.context().clearCookies(); await logIn(page, user.email, "new-password"); - await expect( - page.getByRole("banner").getByRole("link", { name: user.name }), - ).toBeVisible(); + await expectLoggedInAs(page, user.name); }); test("a wrong current password is rejected", async ({ page, user }) => { diff --git a/frontend/e2e/theme.spec.ts b/frontend/e2e/theme.spec.ts index 1e4b0646..e03fe001 100644 --- a/frontend/e2e/theme.spec.ts +++ b/frontend/e2e/theme.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, pickTheme, test } from "./fixtures"; test("dark mode is kept after a reload", async ({ page }) => { await page.goto("./"); @@ -6,8 +6,7 @@ test("dark mode is kept after a reload", async ({ page }) => { page.getByRole("heading", { name: "Discover books worth reading" }), ).toBeVisible(); - await page.getByRole("button", { name: "Toggle theme" }).click(); - await page.getByRole("menuitem", { name: "Dark" }).click(); + await pickTheme(page, "Dark"); await expect(page.locator("html")).toHaveClass("dark"); await page.reload(); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 524ae13d..b02f4523 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -19,8 +19,10 @@ export default defineConfig({ forbidOnly: !!process.env.CI, // Retry on CI only retries: process.env.CI ? 2 : 0, - // Opt out of parallel tests on CI - workers: process.env.CI ? 1 : 8, + // We only use one worker everywhere to avoid timeouts and other failures + // that are not caused by real bugs. How many workers you may use differs based on + // your system, so feel free to change this number. + workers: 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.