Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/app/components/ui/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function ComboboxInput({
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
size="icon-sm"
variant="ghost"
render={<ComboboxTrigger aria-label="Show options" />}
data-slot="input-group-button"
Expand Down
26 changes: 21 additions & 5 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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());
});

Expand Down
20 changes: 12 additions & 8 deletions frontend/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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 }) => {
Expand All @@ -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 ({
Expand Down
4 changes: 2 additions & 2 deletions frontend/e2e/favorites.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();

Expand Down
78 changes: 77 additions & 1 deletion frontend/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
6 changes: 2 additions & 4 deletions frontend/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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 }) => {
Expand Down
5 changes: 2 additions & 3 deletions frontend/e2e/theme.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
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("./");
await expect(
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();
Expand Down
6 changes: 4 additions & 2 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down