From d5e10b65ecac7e7fc4ca15c55ccbcd3955c18c3d Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 14:39:43 +0100 Subject: [PATCH 1/5] test(e2e): reduce number of workers to 4 This attempts to prevent tests from failing because of timeouts --- frontend/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 524ae13d..204f499a 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -20,7 +20,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 : 8, + workers: process.env.CI ? 1 : 4, // 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. From 71923f511542c25862d7e2f8bdc21541738dc3c8 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 14:46:43 +0100 Subject: [PATCH 2/5] fix(accessibility): increase size of ComboboxInput icon Highlighted by Playwright accessibility checks --- frontend/app/components/ui/Combobox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 5f637967a2f3ea0b923534a44ea8bc94aac794ce Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 14:49:00 +0100 Subject: [PATCH 3/5] test(e2e): move common helpers out and fix "surprise me" button check --- frontend/e2e/accessibility.spec.ts | 13 +++--- frontend/e2e/auth.spec.ts | 20 +++++---- frontend/e2e/favorites.spec.ts | 4 +- frontend/e2e/fixtures.ts | 71 +++++++++++++++++++++++++++++- frontend/e2e/settings.spec.ts | 6 +-- frontend/e2e/theme.spec.ts | 5 +-- 6 files changed, 96 insertions(+), 23 deletions(-) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 615b0dcc..fa122ad0 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -7,6 +7,10 @@ async function expectNoViolations(page: Page, loaded: Locator) { await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1); 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 +32,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..d03ccf31 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -43,4 +43,73 @@ 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) { + if (await openMobileMenu(page)) { + await page.getByRole("menuitem", { name: "Log out" }).click(); + } else { + await page + .getByRole("banner") + .getByRole("button", { name: "Log out" }) + .click(); + } +} + +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(); From f8c01fd88a42404ff4aa6a3910143400e98bf812 Mon Sep 17 00:00:00 2001 From: Johannes Horn Date: Thu, 12 Nov 2026 15:24:08 +0100 Subject: [PATCH 4/5] test(e2e): wait for animations before Axe checks and for reload after logout --- frontend/e2e/accessibility.spec.ts | 13 +++++++++++++ frontend/e2e/fixtures.ts | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index fa122ad0..9dbc64cb 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -6,6 +6,19 @@ 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, diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index d03ccf31..19e5174c 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -82,6 +82,8 @@ async function goToProfile(page: Page, name: string) { } 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 { @@ -90,6 +92,11 @@ async function logOut(page: Page) { .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") { From 2fbe0597f28e06af04f0b23dd5e33399b6133c71 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 12 Nov 2026 21:56:33 +0100 Subject: [PATCH 5/5] fix: only use one E2E test worker How many workers are able to run without timeouts depends on the system, so the safe approach is to use only one. This should be documented in a separate commit --- frontend/playwright.config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 204f499a..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 : 4, + // 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.