diff --git a/.gitignore b/.gitignore index 3bf0c560..c4f70fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,10 @@ build/ db-data/ book-data.csv + +# Playwright +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ +playwright/.auth/ diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index 704c9073..7ced6ff5 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -126,23 +126,25 @@ for await (const book of parser as AsyncIterable) { booksToInsert = []; genresToInsert = []; inserted += BATCH_SIZE; + + if (inserted % 5000 === 0) { + console.clear(); + console.log( + `Inserted ${inserted} (${Math.round((inserted / BOOK_COUNT) * 100)}%) of ${BOOK_COUNT} books...`, + ); + } } } catch (error) { console.error(`Error inserting book with ISBN ${book.isbn}:`, error); console.log(`Book data: ${JSON.stringify(book, null, 2)}`); process.exit(1); } - - if (inserted % 5000 === 0) { - console.clear(); - console.log( - `Inserted ${inserted} (${Math.round((inserted / BOOK_COUNT) * 100)}%) of ${BOOK_COUNT} books...`, - ); - } } // Insert last batch if there are any remaining books to insert -await insertBatch(booksToInsert, genresToInsert); +if (booksToInsert.length > 0) { + await insertBatch(booksToInsert, genresToInsert); +} console.clear(); console.log("Seeding complete!"); diff --git a/docs/choices-and-reasoning.md b/docs/choices-and-reasoning.md index c1be4b5e..879ea02c 100644 --- a/docs/choices-and-reasoning.md +++ b/docs/choices-and-reasoning.md @@ -68,6 +68,25 @@ When several genres are selected, we show books that belong to at least one of t Base UI provides accessible components (keyboard navigation, focus handling and ARIA attributes), and shadcn/ui gives us styled versions whose code lives in our repository, so we can adjust them to our needs. We use Tailwind CSS for styling, which replaced our CSS module files. +## Playwright E2E testing + +We use [Playwright](https://playwright.dev/) to run end-to-end (E2E) tests. + +We run tests on the following browsers: + +- Chromium +- Firefox (Gecko) +- Safari (WebKit) +- Mobile Chrome +- Mobile Safari + +These cover almost all of the browser engines that are in use, i.e. Chromium, Gecko and WebKit. +We have skipped Microsoft Edge and (branded) Google Chrome because we already test Chromium, which is the browser engine these use. +Mobile Chrome and Mobile Safari are used to test the app on mobile screens. + +We do not use CI for E2E tests. Our VM only has 1 CPU core available and the tests take a very long time. They also slow down our app, which is running on the same server. +Instead, we use CI for other tasks, such as linting and formatting. + ## Changes/not implemented yet from our specification - **Filters:** We filter on genres, language, book format and number of pages. The specification also mentioned author, publisher, place and person, which we have not implemented yet. diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 04f50e91..304064d5 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -2,3 +2,6 @@ pnpm-lock.yaml build schema.graphql *.generated.* +.react-router +playwright-report +test-results diff --git a/frontend/README.md b/frontend/README.md index 96a99aa7..b2e3df97 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -54,6 +54,8 @@ If you are adding new Shadcn components to the project using the CLI, please mak ## Testing +### Vitest + Tests are written with [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/), and run in a simulated browser DOM ([jsdom](https://github.com/jsdom/jsdom)). @@ -98,6 +100,25 @@ intended change without catching real bugs. The page tests (home and selectedBook) were removed when the frontend moved from Open Library to our GraphQL API. Several component tests have not been updated after recent changes and currently fail, for example `FavoriteButton`, which now uses Apollo instead of `localStorage`. We will fix them and rewrite the page tests with Apollo's MockedProvider in #33. +### Playwright (end-to-end tests) + +Before running tests for the first time, make sure to install the browsers we will use to test: + +```sh +cd frontend +pnpm exec playwright install +``` + +To run end-to-end (E2E) tests, the database must be running in the background. For further details, see the [backend README](../backend/README.md). +When it's ready, run the following command: + +```sh +cd frontend +pnpm e2e +``` + +The tests will take a while to complete, because we test against multiple browsers. + ### Browsers and devices The application is checked manually in the browsers and screen sizes below. diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts new file mode 100644 index 00000000..6a79763e --- /dev/null +++ b/frontend/e2e/accessibility.spec.ts @@ -0,0 +1,91 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "./fixtures"; +import type { Locator, Page } from "@playwright/test"; + +async function expectNoViolations(page: Page, loaded: Locator) { + await expect(loaded).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .withTags([ + "wcag2a", + "wcag2aa", + "wcag21a", + "wcag21aa", + "wcag22aa", + "best-practice", + ]) + .analyze(); + + expect(results.violations).toEqual([]); +} + +for (const colorScheme of ["light", "dark"] as const) { + test.describe(`${colorScheme} mode`, () => { + // The theme follows the system setting until the user picks one. + test.use({ colorScheme }); + + test("home page", async ({ page }) => { + await page.goto("./"); + await expectNoViolations( + page, + page + .getByRole("region", { name: "Top rated" }) + .getByRole("link", { name: "View details for" }) + .first(), + ); + }); + + test("books page with filters open", async ({ page }) => { + await page.goto("books"); + await page.getByRole("button", { name: "Filter books" }).click(); + await expectNoViolations( + page, + page.getByRole("heading", { name: "Filter Books" }), + ); + }); + + test("book page", async ({ page }) => { + await page.goto("books?q=potter"); + await page + .getByRole("link", { name: "View details for" }) + .first() + .click(); + await expectNoViolations( + page, + page.getByRole("heading", { name: "Description" }), + ); + }); + + test("login page", async ({ page }) => { + await page.goto("login"); + await expectNoViolations( + page, + page.getByRole("heading", { name: "Log in" }), + ); + }); + + test("signup page", async ({ page }) => { + await page.goto("signup"); + await expectNoViolations( + page, + page.getByRole("heading", { name: "Sign up" }), + ); + }); + + test("profile page", async ({ page, user }) => { + await page.goto("profile"); + await expectNoViolations( + page, + page.getByRole("heading", { level: 1, name: user.name }), + ); + }); + + test("settings page", async ({ page, user: _user }) => { + await page.goto("settings"); + await expectNoViolations( + page, + page.getByRole("heading", { level: 1, name: "Settings" }), + ); + }); + }); +} diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 00000000..ccea5bac --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,67 @@ +import { randomUUID } from "node:crypto"; +import { expect, logIn, test } from "./fixtures"; + +test("a new user can sign up", async ({ page }) => { + await page.goto("signup"); + + await page.getByLabel("Name").fill("Someone"); + await page.getByLabel("Email").fill(`e2e-${randomUUID()}@example.com`); + await page.getByLabel("Password", { exact: true }).fill("password123"); + await page.getByLabel("Confirm password").fill("password123"); + await page.getByRole("button", { name: "Sign up", exact: true }).click(); + + await expect(page).toHaveURL("./"); + await expect( + page.getByRole("banner").getByRole("link", { name: "Someone" }), + ).toBeVisible(); +}); + +test("signing up with a used email shows an error", async ({ page, user }) => { + await page.context().clearCookies(); + await page.goto("signup"); + + await page.getByLabel("Name").fill(user.name); + await page.getByLabel("Email").fill(user.email); + await page.getByLabel("Password", { exact: true }).fill(user.password); + await page.getByLabel("Confirm password").fill(user.password); + await page.getByRole("button", { name: "Sign up", exact: true }).click(); + + await expect( + page.getByText("An account with this email already exists."), + ).toBeVisible(); +}); + +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 logIn(page, user.email, user.password); + + await expect(page).toHaveURL("./"); + await expect(header.getByRole("link", { name: user.name })).toBeVisible(); +}); + +test("logging in with a wrong password shows an error", async ({ + page, + user, +}) => { + await page.context().clearCookies(); + + await logIn(page, user.email, "wrong-password"); + + await expect(page.getByText("Wrong email or password.")).toBeVisible(); + await expect(page).toHaveURL("login"); +}); + +test("profile and settings send logged out users to log in", async ({ + page, +}) => { + await page.goto("profile"); + await expect(page).toHaveURL("login"); + + await page.goto("settings"); + await expect(page).toHaveURL("login"); +}); diff --git a/frontend/e2e/books.spec.ts b/frontend/e2e/books.spec.ts new file mode 100644 index 00000000..aeff6c96 --- /dev/null +++ b/frontend/e2e/books.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from "@playwright/test"; + +const bookLinks = (page: Page) => + page.getByRole("link", { name: "View details for" }); + +test("searching updates the URL and the results", async ({ page }) => { + await page.goto("books"); + + await page + .getByRole("searchbox", { name: "Search for books" }) + .fill("potter"); + + await expect(page).toHaveURL("books?q=potter"); + await expect(bookLinks(page).first()).toContainText("potter", { + ignoreCase: true, + }); +}); + +test("filtering by genre can be reset", async ({ page }) => { + await page.goto("books"); + + await page.getByRole("button", { name: "Filter books" }).click(); + await page.getByRole("combobox", { name: "Genres" }).click(); + await page.getByRole("option").first().click(); + // Closes the genre list, which covers the rest of the filters. + await page.keyboard.press("Escape"); + + await expect(page).toHaveURL((url) => url.searchParams.has("genres")); + await expect(page.getByLabel("1 filter active")).toBeVisible(); + + await page.getByRole("button", { name: "Reset" }).click(); + + await expect(page).toHaveURL("books"); + await expect(page.getByLabel("1 filter active")).toBeHidden(); +}); + +test("sorting by rating orders the books", async ({ page }) => { + await page.goto("books"); + + await page.getByRole("combobox", { name: "Sort by" }).click(); + await page.getByRole("option", { name: "Rating: high to low" }).click(); + + await expect(page).toHaveURL("books?sort=rating_desc"); + + // Retries because the old, unsorted list can still be shown right after the URL changes. + await expect(async () => { + const ratings = (await bookLinks(page).allInnerTexts()).map((text) => + Number(/(\d\.\d{2})\s*\(/.exec(text)?.[1]), + ); + expect(ratings).toHaveLength(12); + expect(ratings).toEqual([...ratings].sort((a, b) => b - a)); + }).toPass(); +}); + +test("pagination moves to the next page", async ({ page }) => { + await page.goto("books"); + const firstBook = await bookLinks(page).first().getAttribute("aria-label"); + + await page.getByRole("link", { name: "Go to next page" }).click(); + + await expect(page).toHaveURL("books?page=2"); + await expect( + page + .getByRole("navigation", { name: "pagination" }) + .getByRole("link", { name: "2", exact: true }), + ).toHaveAttribute("aria-current", "page"); + await expect(bookLinks(page).first()).not.toHaveAccessibleName( + firstBook ?? "", + ); +}); + +test("a book can be opened, browsed and left again", async ({ page }) => { + await page.goto("books?q=potter"); + + await bookLinks(page).first().click(); + + await expect(page).toHaveURL(/\/book\/\d+\?q=potter$/); + await expect( + page.getByRole("heading", { name: "Description" }), + ).toBeVisible(); + const firstUrl = page.url(); + + await page.getByRole("link", { name: "Go to next book" }).click(); + await expect(page).not.toHaveURL(firstUrl); + await expect(page).toHaveURL(/\/book\/\d+\?q=potter$/); + + await page.getByRole("button", { name: "Go back to search results" }).click(); + await expect(page).toHaveURL("books?q=potter"); +}); + +test("a search without matches suggests other books", async ({ page }) => { + await page.goto("books?q=xqzvwkjhqzx"); + + await expect( + page.getByRole("status").filter({ hasText: "No books found" }), + ).toBeVisible(); + await expect(bookLinks(page).first()).toBeVisible(); +}); + +test("an unknown book shows a not found message", async ({ page }) => { + await page.goto("book/999999999"); + + await expect( + page.getByRole("heading", { name: "Book Not Found" }), + ).toBeVisible(); +}); diff --git a/frontend/e2e/favorites.spec.ts b/frontend/e2e/favorites.spec.ts new file mode 100644 index 00000000..5dbf6f27 --- /dev/null +++ b/frontend/e2e/favorites.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "./fixtures"; + +test("logged out users are asked to log in to save favorites", async ({ + page, +}) => { + await page.goto("books"); + + await page.getByRole("button", { name: "to favorites" }).first().click(); + + const dialog = page.getByRole("alertdialog", { + name: "Log in to save favorites", + }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("link", { name: "Log in" }).click(); + await expect(page).toHaveURL("login"); +}); + +test("a favorite shows on the profile and can be removed", async ({ + page, + user, +}) => { + await page.goto("books?q=potter"); + const bookLink = page.getByRole("link", { name: "View details for" }).first(); + const title = (await bookLink.getAttribute("aria-label"))?.replace( + "View details for ", + "", + ); + expect(title).toBeTruthy(); + + const addButton = page.getByRole("button", { + name: `Add ${title} to favorites`, + }); + await addButton.click(); + await expect( + page.getByRole("button", { name: `Remove ${title} from favorites` }), + ).toHaveAttribute("aria-pressed", "true"); + + await page.getByRole("banner").getByRole("link", { name: user.name }).click(); + const favorites = page.getByRole("region", { name: "Favorite books" }); + await favorites + .getByRole("link", { name: `View details for ${title}` }) + .click(); + + await expect( + page.getByRole("heading", { name: "Description" }), + ).toBeVisible(); + await page + .getByRole("button", { name: `Remove ${title} from favorites` }) + .click(); + await expect( + page.getByRole("button", { name: `Add ${title} to favorites` }), + ).toBeVisible(); + + await page.goto("profile"); + await expect(page.getByText("You have no favorite books yet.")).toBeVisible(); +}); + +test("favorites give recommendations on the home page", async ({ + page, + user: _user, +}) => { + await page.goto("books?q=potter"); + await page.getByRole("button", { name: "to favorites" }).first().click(); + await expect( + page.getByRole("button", { name: "from favorites" }).first(), + ).toBeVisible(); + + await page.goto("./"); + + await expect( + page.getByRole("heading", { name: "Because you like" }), + ).toBeVisible(); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts new file mode 100644 index 00000000..0e14ff64 --- /dev/null +++ b/frontend/e2e/fixtures.ts @@ -0,0 +1,39 @@ +import { randomUUID } from "node:crypto"; +import { test as base, expect, type Page } from "@playwright/test"; + +type User = { name: string; email: string; password: string }; + +// Every test gets its own user, so tests running in parallel never change each other's data. +const test = base.extend<{ user: User }>({ + user: async ({ page }, use) => { + const user = { + name: "Test User", + email: `e2e-${randomUUID()}@example.com`, + password: "password123", + }; + + // Signing up through page.request stores the session cookie in the browser, so the user is logged in. + const response = await page.request.post( + "http://localhost:3001/api/auth/sign-up/email", + { + data: user, + // The backend only accepts auth requests from the frontend's origin. + headers: { Origin: "http://localhost:5173" }, + }, + ); + expect(response.ok()).toBe(true); + + await use(user); + }, +}); + +async function logIn(page: Page, email: string, password: string) { + await page.goto("login"); + // Scoped to main because the header also has a "Log in" link. + const form = page.getByRole("main"); + await form.getByLabel("Email").fill(email); + await form.getByLabel("Password").fill(password); + await form.getByRole("button", { name: "Log in", exact: true }).click(); +} + +export { test, logIn, expect }; diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts new file mode 100644 index 00000000..804eb05c --- /dev/null +++ b/frontend/e2e/home.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("./"); +}); + +test("searching from the hero shows matching books", async ({ page }) => { + await page + .getByRole("searchbox", { name: "Search for books" }) + .fill("potter"); + await page.getByRole("button", { name: "Search" }).click(); + + await expect(page).toHaveURL("books?q=potter"); + await expect( + page.getByRole("link", { name: "View details for" }).first(), + ).toContainText("potter", { ignoreCase: true }); +}); + +test("a genre link shows books in that genre", async ({ page }) => { + await page + .getByRole("region", { name: "Popular genres" }) + .getByRole("link") + .first() + .click(); + + await expect(page).toHaveURL((url) => url.searchParams.has("genres")); + await expect(page.getByLabel("1 filter active")).toBeVisible(); + await expect( + page.getByRole("link", { name: "View details for" }).first(), + ).toBeVisible(); +}); + +test("see all opens the top rated books", async ({ page }) => { + await page + .getByRole("region", { name: "Top rated" }) + .getByRole("link", { name: "See all" }) + .click(); + + await expect(page).toHaveURL("books?sort=rating_desc&minRatings=10000"); + await expect(page.getByRole("combobox", { name: "Sort by" })).toContainText( + "Rating: high to low", + ); +}); + +test("surprise me opens a book", async ({ page }) => { + await page.getByRole("button", { name: "Surprise me" }).click(); + + await expect(page).toHaveURL(/\/book\/\d+$/); + await expect( + page.getByRole("heading", { name: "Description" }), + ).toBeVisible(); +}); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 00000000..66fe171b --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -0,0 +1,48 @@ +import { expect, logIn, test } from "./fixtures"; + +test("profile changes show on the profile page", async ({ page, user }) => { + await page.goto("settings"); + + await page.getByLabel("Name").fill("Renamed Someone"); + await page.getByLabel("Bio").fill("I usually read fiction."); + await page.getByRole("button", { name: "Save profile" }).click(); + await expect(page.getByText("Your profile has been updated.")).toBeVisible(); + + await page.getByRole("link", { name: "Back to profile" }).click(); + + await expect( + page.getByRole("heading", { level: 1, name: "Renamed Someone" }), + ).toBeVisible(); + await expect(page.getByText("I usually read fiction.")).toBeVisible(); + await expect(page.getByText(user.email)).toBeVisible(); +}); + +test("a changed password works for the next log in", async ({ page, user }) => { + await page.goto("settings"); + + await page.getByLabel("Current password").fill(user.password); + await page.getByLabel("New password", { exact: true }).fill("new-password"); + await page.getByLabel("Confirm new password").fill("new-password"); + await page.getByRole("button", { name: "Change password" }).click(); + await expect(page.getByText("Your password has been changed.")).toBeVisible(); + + await page.context().clearCookies(); + await logIn(page, user.email, "new-password"); + + await expect( + page.getByRole("banner").getByRole("link", { name: user.name }), + ).toBeVisible(); +}); + +test("a wrong current password is rejected", async ({ page, user }) => { + await page.goto("settings"); + + await page.getByLabel("Current password").fill(`${user.password}-wrong`); + await page.getByLabel("New password", { exact: true }).fill("new-password"); + await page.getByLabel("Confirm new password").fill("new-password"); + await page.getByRole("button", { name: "Change password" }).click(); + + await expect( + page.getByText("The current password is incorrect."), + ).toBeVisible(); +}); diff --git a/frontend/e2e/theme.spec.ts b/frontend/e2e/theme.spec.ts new file mode 100644 index 00000000..e8729a87 --- /dev/null +++ b/frontend/e2e/theme.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test("dark mode is kept after a reload", async ({ page }) => { + await page.goto("./"); + + await page.getByRole("button", { name: "Toggle theme" }).click(); + await page.getByRole("menuitem", { name: "Dark" }).click(); + await expect(page.locator("html")).toHaveClass("dark"); + + await page.reload(); + + await expect(page.locator("html")).toHaveClass("dark"); +}); diff --git a/frontend/package.json b/frontend/package.json index b00cd691..c1f0a026 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,10 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "e2e": "playwright test", + "e2e:watch": "playwright test --watch", + "e2e:ui": "playwright test --ui" }, "dependencies": { "@apollo/client": "^4.3.5", @@ -43,11 +46,13 @@ "zod": "^4.6.7" }, "devDependencies": { + "@axe-core/playwright": "^4.14.0", "@babel/core": "^7.29.7", "@eslint-react/eslint-plugin": "^5.28.8", "@eslint/js": "^10.0.1", "@graphql-codegen/cli": "^7.4.6", "@graphql-codegen/typescript-operations": "^6.1.10", + "@playwright/test": "^1.65.0", "@react-router/dev": "^8.5.0", "@rolldown/plugin-babel": "^0.2.4", "@testing-library/dom": "^10.4.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..524ae13d --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,81 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./e2e", + // Run tests in files in parallel + fullyParallel: true, + // Fail the build on CI if you accidentally left test.only in the source code. + 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, + // 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. + use: { + // Base URL to use in actions like `await page.goto('')`. + baseURL: "http://localhost:5173/project2/", + + // Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer + trace: "on-first-retry", + + // Skips the scroll reveals and count-up animations on the home page. + contextOptions: { reducedMotion: "reduce" }, + }, + + // Configure projects for major browsers + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + + // Test against mobile viewports. + { + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, + }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 12"] }, + }, + ], + + // The database must already be running (`pnpm db:start` in backend/) + webServer: [ + { + command: "pnpm dev", + cwd: "../backend", + url: "http://localhost:3001/api/auth/ok", + reuseExistingServer: !process.env.CI, + }, + { + // Tests the production build. Port 5173 is the origin the backend allows. + command: "pnpm build && pnpm preview --port 5173 --strictPort", + url: "http://localhost:5173/project2/", + reuseExistingServer: !process.env.CI, + }, + ], +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 06230716..6a1cf808 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: specifier: ^4.6.7 version: 4.6.7 devDependencies: + '@axe-core/playwright': + specifier: ^4.14.0 + version: 4.14.0(playwright-core@1.65.0) '@babel/core': specifier: ^7.29.7 version: 7.29.7 @@ -90,6 +93,9 @@ importers: '@graphql-codegen/typescript-operations': specifier: ^6.1.10 version: 6.2.0(graphql@17.0.2) + '@playwright/test': + specifier: ^1.65.0 + version: 1.65.0 '@react-router/dev': specifier: ^8.5.0 version: 8.5.1(@react-router/serve@8.5.1(react-router@8.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(typescript@6.0.3))(react-router@8.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(typescript@6.0.3)(vite@8.3.5(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)) @@ -194,6 +200,12 @@ packages: resolution: {integrity: sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@axe-core/playwright@4.14.0': + resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1084,6 +1096,11 @@ packages: '@oxc-project/types@0.156.0': resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + '@playwright/test@1.65.0': + resolution: {integrity: sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==} + engines: {node: '>=20'} + hasBin: true + '@react-router/dev@8.5.1': resolution: {integrity: sha512-1NQ/lP8m7Kd9G4cGKPoEMZe0qcWOnRkf8QxpAcnX/3V5wJp+/mDvM9QUqwUxI+RywytNNGtc4uuDFhwt0xMLbg==} engines: {node: '>=22.22.0'} @@ -1697,6 +1714,10 @@ packages: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + axe-core@4.14.0: + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} + engines: {node: '>=4'} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -3206,6 +3227,16 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + playwright-core@1.65.0: + resolution: {integrity: sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.65.0: + resolution: {integrity: sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==} + engines: {node: '>=20'} + hasBin: true + postcss-selector-parser@7.1.7: resolution: {integrity: sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==} engines: {node: '>=4'} @@ -3961,6 +3992,11 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.3 + '@axe-core/playwright@4.14.0(playwright-core@1.65.0)': + dependencies: + axe-core: 4.14.0 + playwright-core: 1.65.0 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -5034,6 +5070,10 @@ snapshots: '@oxc-project/types@0.156.0': {} + '@playwright/test@1.65.0': + dependencies: + playwright: 1.65.0 + '@react-router/dev@8.5.1(@react-router/serve@8.5.1(react-router@8.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(typescript@6.0.3))(react-router@8.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(typescript@6.0.3)(vite@8.3.5(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7 @@ -5616,6 +5656,8 @@ snapshots: auto-bind@5.0.1: {} + axe-core@4.14.0: {} + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.7 @@ -7031,6 +7073,12 @@ snapshots: dependencies: find-up: 3.0.0 + playwright-core@1.65.0: {} + + playwright@1.65.0: + dependencies: + playwright-core: 1.65.0 + postcss-selector-parser@7.1.7: dependencies: cssesc: 3.0.0 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e8ac7f05..bff26222 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,5 @@ import { reactRouter } from "@react-router/dev/vite"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ @@ -19,6 +19,7 @@ export default defineConfig({ }, // https://vitest.dev/config/ test: { + exclude: [...configDefaults.exclude, "./e2e/**"], // jsdom simulates a browser DOM so React components can render in Node. environment: "jsdom", // Runs before every test file: adds jest-dom matchers and cleans up between tests.