From ad39d41b3a6e01ff82a618952ea7e25e4c25ebb4 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 20:50:29 +0100 Subject: [PATCH 01/27] build: add Playwright --- frontend/package.json | 6 +++++- frontend/pnpm-lock.yaml | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index b00cd691..8cc3d615 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", @@ -48,6 +51,7 @@ "@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/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 06230716..fe3caf73 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -90,6 +90,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)) @@ -1084,6 +1087,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'} @@ -3206,6 +3214,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'} @@ -5034,6 +5052,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 @@ -7031,6 +7053,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 From 45318c38344913f75f4c94ace4fcd35958df2051 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 20:50:37 +0100 Subject: [PATCH 02/27] feat: add basic Playwright setup --- .github/workflows/playwright.yml | 30 ++++++++++++ .gitignore | 7 +++ frontend/e2e/example.spec.ts | 18 ++++++++ frontend/playwright.config.ts | 79 ++++++++++++++++++++++++++++++++ frontend/vite.config.ts | 3 +- 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 frontend/e2e/example.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..42ecbe5c --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,30 @@ +name: Playwright Tests +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm install -g pnpm && pnpm install + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + - name: Run Playwright tests + run: pnpm exec playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 30 +defaults: + run: + working-directory: frontend 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/frontend/e2e/example.spec.ts b/frontend/e2e/example.spec.ts new file mode 100644 index 00000000..54a906a4 --- /dev/null +++ b/frontend/e2e/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..744dc9ad --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,79 @@ +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 : undefined, + /* 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", + }, + + /* 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'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "pnpm dev", + url: "http://localhost:5173/project2/", + reuseExistingServer: !process.env.CI, + }, +}); 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. From 3b190f6bfa97c4f784d8c4fc9db7485d3091d745 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 20:55:55 +0100 Subject: [PATCH 03/27] ci: use self-hosted NTNU runners --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 42ecbe5c..0e5f7094 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -7,7 +7,7 @@ on: jobs: test: timeout-minutes: 60 - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 From b3078f413202a04e177259363d82eda2b9db2fb9 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 20:57:11 +0100 Subject: [PATCH 04/27] ci: specify runner name correctly --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0e5f7094..23659e6c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -7,7 +7,7 @@ on: jobs: test: timeout-minutes: 60 - runs-on: self-hosted + runs-on: [self-hosted] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 From fd234102fa61817ae227b25372eaafcc24055729 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 21:06:19 +0100 Subject: [PATCH 05/27] ci: temporarily remove CI tests until we get access to a runner We need to request to get a GitHub runner added first --- .github/workflows/playwright.yml | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 .github/workflows/playwright.yml diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index 23659e6c..00000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [main] - pull_request: - branches: [main] -jobs: - test: - timeout-minutes: 60 - runs-on: [self-hosted] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm install -g pnpm && pnpm install - - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps - - name: Run Playwright tests - run: pnpm exec playwright test - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: frontend/playwright-report/ - retention-days: 30 -defaults: - run: - working-directory: frontend From 5a4e4f018b081dcb67fe146c8f4c2a7ba7214746 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 21:33:26 +0100 Subject: [PATCH 06/27] test: start backend in E2E tests too --- frontend/playwright.config.ts | 78 ++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 33 deletions(-) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 744dc9ad..51cf3c7d 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -13,26 +13,29 @@ import { defineConfig, devices } from "@playwright/test"; */ export default defineConfig({ testDir: "./e2e", - /* Run tests in files in parallel */ + // Run tests in files in parallel fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ + // Fail the build on CI if you accidentally left test.only in the source code. forbidOnly: !!process.env.CI, - /* Retry on CI only */ + // Retry on CI only retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ + // Opt out of parallel tests on CI. workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + // 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. */ + // 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('')`. */ + // 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 */ + // 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 */ + // Configure projects for major browsers projects: [ { name: "chromium", @@ -49,31 +52,40 @@ export default defineConfig({ use: { ...devices["Desktop Safari"] }, }, - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, + // Test against mobile viewports. + { + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, + }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 12"] }, + }, - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, + // Test against branded browsers. + { + name: "Microsoft Edge", + use: { ...devices["Desktop Edge"], channel: "msedge" }, + }, + { + name: "Google Chrome", + use: { ...devices["Desktop Chrome"], channel: "chrome" }, + }, ], - /* Run your local dev server before starting the tests */ - webServer: { - command: "pnpm dev", - url: "http://localhost:5173/project2/", - reuseExistingServer: !process.env.CI, - }, + // 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, + }, + ], }); From 93704fc86a28c4d6560fc611073294094288030a Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 21:33:46 +0100 Subject: [PATCH 07/27] test: check usual user flows for books and homepage using E2E tests --- frontend/e2e/books.spec.ts | 106 +++++++++++++++++++++++++++++++++++ frontend/e2e/example.spec.ts | 18 ------ frontend/e2e/home.spec.ts | 52 +++++++++++++++++ 3 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 frontend/e2e/books.spec.ts delete mode 100644 frontend/e2e/example.spec.ts create mode 100644 frontend/e2e/home.spec.ts 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/example.spec.ts b/frontend/e2e/example.spec.ts deleted file mode 100644 index 54a906a4..00000000 --- a/frontend/e2e/example.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('has title', async ({ page }) => { - await page.goto('https://playwright.dev/'); - - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); -}); - -test('get started link', async ({ page }) => { - await page.goto('https://playwright.dev/'); - - // Click the get started link. - await page.getByRole('link', { name: 'Get started' }).click(); - - // Expects page to have a heading with the name of Installation. - await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); -}); 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(); +}); From 038e9cee5369c35b2bf804e3e286656f912f72e6 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 21:47:34 +0100 Subject: [PATCH 08/27] test: check usual auth user flows --- frontend/e2e/auth.spec.ts | 67 +++++++++++++++++++++++++++++++++++++++ frontend/e2e/fixtures.ts | 39 +++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 frontend/e2e/auth.spec.ts create mode 100644 frontend/e2e/fixtures.ts 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/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 }; From 8d4f6de455c2f35e8c9b84e9b09b40a6a1114fad Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 21:48:18 +0100 Subject: [PATCH 09/27] test: check usage of favorites --- frontend/e2e/favorites.spec.ts | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 frontend/e2e/favorites.spec.ts 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(); +}); From 66de27013eefda576dbed394b8983a5eb78776c6 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 21:49:44 +0100 Subject: [PATCH 10/27] test: check usage of settings --- frontend/e2e/settings.spec.ts | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/e2e/settings.spec.ts 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(); +}); From 15a2f95c25972751270a2137750e1ce6debbd9d0 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 21:49:57 +0100 Subject: [PATCH 11/27] test: check dark mode is kept after reload --- frontend/e2e/theme.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 frontend/e2e/theme.spec.ts 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"); +}); From 584346538ee24674a54d310aeea02659990ce221 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 22:08:08 +0100 Subject: [PATCH 12/27] test: add automatic accessibility checks --- frontend/e2e/accessibility.spec.ts | 91 ++++++++++++++++++++++++++++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 20 +++++++ 3 files changed, 112 insertions(+) create mode 100644 frontend/e2e/accessibility.spec.ts 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/package.json b/frontend/package.json index 8cc3d615..c1f0a026 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fe3caf73..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 @@ -197,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'} @@ -1705,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==} @@ -3979,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 @@ -5638,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 From ba317caaad6df86706aacf12761969949a63be49 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 22:36:09 +0100 Subject: [PATCH 13/27] chore: ignore Playwright when running Prettier --- frontend/.prettierignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 04f50e91..e3ef9264 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -2,3 +2,5 @@ pnpm-lock.yaml build schema.graphql *.generated.* +playwright-report +test-results From dc94e5cc1f16bcdbc72e362d0704492c00129e5b Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 22:36:44 +0100 Subject: [PATCH 14/27] chore: ignore React Router types when running Prettier --- frontend/.prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/.prettierignore b/frontend/.prettierignore index e3ef9264..304064d5 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -2,5 +2,6 @@ pnpm-lock.yaml build schema.graphql *.generated.* +.react-router playwright-report test-results From be0a098f22321c5ccabf57e7a4eb40dbb0cf7599 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sat, 7 Nov 2026 23:05:51 +0100 Subject: [PATCH 15/27] test: set max number of workers --- frontend/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 51cf3c7d..d96cc200 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -19,8 +19,8 @@ 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 : undefined, + // 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. From 77295c6bc5c059917e6883c515888256e1a6cb38 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 16:30:54 +0100 Subject: [PATCH 16/27] ci: add Playwright tests back We have now gotten access to add a self-hosted GitHub runner --- .github/workflows/playwright.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/playwright.yml diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..0e5f7094 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,30 @@ +name: Playwright Tests +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + test: + timeout-minutes: 60 + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm install -g pnpm && pnpm install + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + - name: Run Playwright tests + run: pnpm exec playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 30 +defaults: + run: + working-directory: frontend From 2fdec81081d692cb27d68ecbb95b56a7a41bb72a Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 16:34:39 +0100 Subject: [PATCH 17/27] ci: install system deps once manually --- .github/workflows/playwright.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0e5f7094..da33858d 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -16,7 +16,8 @@ jobs: - name: Install dependencies run: npm install -g pnpm && pnpm install - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps + # System dependencies installed once manually so we do not need sudo in CI + run: pnpm exec playwright install - name: Run Playwright tests run: pnpm exec playwright test - uses: actions/upload-artifact@v4 From a12d62cc6e9ebe085ca9dd99c24c08766fbc2caf Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 16:45:14 +0100 Subject: [PATCH 18/27] ci: add environment variables for custom ports --- .github/workflows/playwright.yml | 4 ++++ backend/.env.example | 1 + backend/src/index.ts | 2 +- frontend/playwright.config.ts | 27 +++++++++++++++++++++------ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index da33858d..e9dc5c00 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -8,6 +8,10 @@ jobs: test: timeout-minutes: 60 runs-on: self-hosted + # We use different ports than the defaults because they are busy on our VM + env: + FRONTEND_PORT: 5174 + BACKEND_PORT: 3002 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/backend/.env.example b/backend/.env.example index 9600c134..ac988751 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,5 @@ DATABASE_URL=postgresql://postgres@localhost:5433/readr +PORT=3001 BETTER_AUTH_URL=http://localhost:3001 FRONTEND_URL=http://localhost:5173 diff --git a/backend/src/index.ts b/backend/src/index.ts index 0dae8283..0ce6ef5f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,7 +17,7 @@ export type ApolloContext = { }; }; -const PORT = 3001; +const PORT = Number(process.env.PORT ?? 3001); // Server setup mostly based on Apollo Server documentation: // https://www.apollographql.com/docs/apollo-server/getting-started diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index d96cc200..52a24064 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -8,6 +8,12 @@ import { defineConfig, devices } from "@playwright/test"; // import path from 'path'; // dotenv.config({ path: path.resolve(__dirname, '.env') }); +// CI sets the FRONTEND_PORT and BACKEND_PORT environment variables +const backendPort = process.env.BACKEND_PORT ?? "3001"; +const backendUrl = `http://localhost:${backendPort}`; +const frontendPort = process.env.FRONTEND_PORT ?? "5173"; +const frontendUrl = `http://localhost:${frontendPort}`; + /** * See https://playwright.dev/docs/test-configuration. */ @@ -26,7 +32,7 @@ export default defineConfig({ // 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/", + baseURL: `${frontendUrl}/project2/`, // Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer trace: "on-first-retry", @@ -73,19 +79,28 @@ export default defineConfig({ }, ], - // The database must already be running (`pnpm db:start` in backend/). + // The database must already be running (`pnpm db:start` in backend/) webServer: [ { command: "pnpm dev", cwd: "../backend", - url: "http://localhost:3001/api/auth/ok", + url: `${backendUrl}/api/auth/ok`, reuseExistingServer: !process.env.CI, + env: { + PORT: backendPort, + BETTER_AUTH_URL: backendUrl, + FRONTEND_URL: frontendUrl, + }, }, { - // 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/", + // Tests the production build + command: `pnpm build && pnpm preview --port ${frontendPort} --strictPort`, + url: `${frontendUrl}/project2/`, reuseExistingServer: !process.env.CI, + env: { + VITE_API_URL: backendUrl, + VITE_APP_URL: frontendUrl, + }, }, ], }); From 75163ea75cfc3f8424f6f499bfabbb28c4545aff Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 17:05:05 +0100 Subject: [PATCH 19/27] ci: set up database and seeding scripts --- .github/workflows/playwright.yml | 28 ++++++++++++++++++++++++++-- backend/src/db/seed/index.ts | 3 ++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e9dc5c00..0150b91d 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -12,13 +12,31 @@ jobs: env: FRONTEND_PORT: 5174 BACKEND_PORT: 3002 + DATABASE_URL: postgresql://postgres@127.0.0.1:5434/readr + BETTER_AUTH_SECRET: e2e-test-secret-do-not-use-in-production + SEED_BOOK_LIMIT: 5000 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - - name: Install dependencies - run: npm install -g pnpm && pnpm install + - name: Install pnpm + run: npm install -g pnpm + - name: Install frontend dependencies + run: pnpm install + - name: Install backend dependencies + working-directory: backend + run: pnpm install + - name: Set up test database + working-directory: backend + # We have the seed data available at ~/ci-data/book-data.csv on the self-hosted runner + run: | + initdb -D db-data -U postgres -E UTF8 --auth=trust + pg_ctl -D db-data -l db-data/db-data.log -w -o "-p 5434 -c listen_addresses=127.0.0.1" start + createdb -h 127.0.0.1 -p 5434 -U postgres readr + cp ~/ci-data/book-data.csv src/db/seed/ + pnpm db:migrate + pnpm db:seed - name: Install Playwright Browsers # System dependencies installed once manually so we do not need sudo in CI run: pnpm exec playwright install @@ -30,6 +48,12 @@ jobs: name: playwright-report path: frontend/playwright-report/ retention-days: 30 + - name: Stop and delete test database + if: always() + working-directory: backend + run: | + pg_ctl -D db-data stop || true + rm -rf db-data defaults: run: working-directory: frontend diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index 704c9073..bd883cee 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -46,7 +46,8 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const parser = createReadStream(resolve(__dirname, "book-data.csv")).pipe( - parse({ columns: true }), + // Limit set for runs in CI because we do not need the whole database for testing + parse({ columns: true, to: process.env.SEED_BOOK_LIMIT ?? null }), ); let inserted = 0; From 283c195bb781c7f69ed5fccb333fa16192d3eb07 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 17:08:04 +0100 Subject: [PATCH 20/27] fix: insert last batch of books only if needed --- backend/src/db/seed/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index bd883cee..38607de9 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -143,7 +143,9 @@ for await (const book of parser as AsyncIterable) { } // 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!"); From 9c67ac620e90222ece45f1d0d8d3cdedf0ba9bca Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 17:13:42 +0100 Subject: [PATCH 21/27] fix: check progress only after each seeding batch --- backend/src/db/seed/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index 38607de9..37a76131 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -127,19 +127,19 @@ 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 From bcb824a71b09e397bfcbec48ffa587a1c244221d Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 17:19:55 +0100 Subject: [PATCH 22/27] ci: use correct backend URL --- frontend/e2e/config.ts | 7 +++++++ frontend/e2e/fixtures.ts | 5 +++-- frontend/playwright.config.ts | 12 ++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 frontend/e2e/config.ts diff --git a/frontend/e2e/config.ts b/frontend/e2e/config.ts new file mode 100644 index 00000000..7f1dd269 --- /dev/null +++ b/frontend/e2e/config.ts @@ -0,0 +1,7 @@ +// CI sets the FRONTEND_PORT and BACKEND_PORT environment variables +const backendPort = process.env.BACKEND_PORT ?? "3001"; +const backendUrl = `http://localhost:${backendPort}`; +const frontendPort = process.env.FRONTEND_PORT ?? "5173"; +const frontendUrl = `http://localhost:${frontendPort}`; + +export { backendPort, backendUrl, frontendPort, frontendUrl }; diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 0e14ff64..0d2a66f2 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { test as base, expect, type Page } from "@playwright/test"; +import { backendUrl, frontendUrl } from "./config"; type User = { name: string; email: string; password: string }; @@ -14,11 +15,11 @@ const test = base.extend<{ user: User }>({ // 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", + `${backendUrl}/api/auth/sign-up/email`, { data: user, // The backend only accepts auth requests from the frontend's origin. - headers: { Origin: "http://localhost:5173" }, + headers: { Origin: frontendUrl }, }, ); expect(response.ok()).toBe(true); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 52a24064..51211484 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,4 +1,10 @@ import { defineConfig, devices } from "@playwright/test"; +import { + backendPort, + backendUrl, + frontendPort, + frontendUrl, +} from "./e2e/config"; /** * Read environment variables from file. @@ -8,12 +14,6 @@ import { defineConfig, devices } from "@playwright/test"; // import path from 'path'; // dotenv.config({ path: path.resolve(__dirname, '.env') }); -// CI sets the FRONTEND_PORT and BACKEND_PORT environment variables -const backendPort = process.env.BACKEND_PORT ?? "3001"; -const backendUrl = `http://localhost:${backendPort}`; -const frontendPort = process.env.FRONTEND_PORT ?? "5173"; -const frontendUrl = `http://localhost:${frontendPort}`; - /** * See https://playwright.dev/docs/test-configuration. */ From 64c8c5df27f1ca225a2a82e2f1aa4f6bfd600136 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 17:29:22 +0100 Subject: [PATCH 23/27] ci: run tests on major browsers only --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0150b91d..69bb2589 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -41,7 +41,7 @@ jobs: # System dependencies installed once manually so we do not need sudo in CI run: pnpm exec playwright install - name: Run Playwright tests - run: pnpm exec playwright test + run: pnpm exec playwright test --project=chromium --project=firefox --project=webkit - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: From 3f66d15454a04119160c92d97c5e1b9afc8026de Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 17:39:30 +0100 Subject: [PATCH 24/27] docs: add E2E test explanations --- docs/choices-and-reasoning.md | 19 +++++++++++++++++++ frontend/README.md | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/choices-and-reasoning.md b/docs/choices-and-reasoning.md index c1be4b5e..f61b0cb0 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. +In CI, we skip these two browsers, because our VM only has 1 CPU core available, and we do not want the tests to take a very long time. +However, these browsers are included by default when running the E2E tests locally before pushing to GitHub. + ## 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/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. From 6498fb76035f7561191bd9bb320816a45d79ce57 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 17:39:56 +0100 Subject: [PATCH 25/27] test: remove branded browsers Explanation for this change is included in the project choices and reasoning --- frontend/playwright.config.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 51211484..c0c9e1cb 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -67,16 +67,6 @@ export default defineConfig({ name: "Mobile Safari", use: { ...devices["iPhone 12"] }, }, - - // Test against branded browsers. - { - name: "Microsoft Edge", - use: { ...devices["Desktop Edge"], channel: "msedge" }, - }, - { - name: "Google Chrome", - use: { ...devices["Desktop Chrome"], channel: "chrome" }, - }, ], // The database must already be running (`pnpm db:start` in backend/) From b95479456a808675e71ec104cf7fbc62c1835d7d Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 17:51:19 +0100 Subject: [PATCH 26/27] ci: remove Playwright tests from ci The VM is too weak. Let's use CI for linting and formatting instead, which still adds value, while not being so compute-heavy --- .github/workflows/playwright.yml | 59 -------------------------------- docs/choices-and-reasoning.md | 6 ++-- 2 files changed, 3 insertions(+), 62 deletions(-) delete mode 100644 .github/workflows/playwright.yml diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index 69bb2589..00000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [main] - pull_request: - branches: [main] -jobs: - test: - timeout-minutes: 60 - runs-on: self-hosted - # We use different ports than the defaults because they are busy on our VM - env: - FRONTEND_PORT: 5174 - BACKEND_PORT: 3002 - DATABASE_URL: postgresql://postgres@127.0.0.1:5434/readr - BETTER_AUTH_SECRET: e2e-test-secret-do-not-use-in-production - SEED_BOOK_LIMIT: 5000 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install pnpm - run: npm install -g pnpm - - name: Install frontend dependencies - run: pnpm install - - name: Install backend dependencies - working-directory: backend - run: pnpm install - - name: Set up test database - working-directory: backend - # We have the seed data available at ~/ci-data/book-data.csv on the self-hosted runner - run: | - initdb -D db-data -U postgres -E UTF8 --auth=trust - pg_ctl -D db-data -l db-data/db-data.log -w -o "-p 5434 -c listen_addresses=127.0.0.1" start - createdb -h 127.0.0.1 -p 5434 -U postgres readr - cp ~/ci-data/book-data.csv src/db/seed/ - pnpm db:migrate - pnpm db:seed - - name: Install Playwright Browsers - # System dependencies installed once manually so we do not need sudo in CI - run: pnpm exec playwright install - - name: Run Playwright tests - run: pnpm exec playwright test --project=chromium --project=firefox --project=webkit - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: frontend/playwright-report/ - retention-days: 30 - - name: Stop and delete test database - if: always() - working-directory: backend - run: | - pg_ctl -D db-data stop || true - rm -rf db-data -defaults: - run: - working-directory: frontend diff --git a/docs/choices-and-reasoning.md b/docs/choices-and-reasoning.md index f61b0cb0..879ea02c 100644 --- a/docs/choices-and-reasoning.md +++ b/docs/choices-and-reasoning.md @@ -82,10 +82,10 @@ We run tests on the following browsers: 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. -In CI, we skip these two browsers, because our VM only has 1 CPU core available, and we do not want the tests to take a very long time. -However, these browsers are included by default when running the E2E tests locally before pushing to GitHub. + +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 From 2833ba2301147ad788de44d8960966d174d82289 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Sun, 8 Nov 2026 18:01:49 +0100 Subject: [PATCH 27/27] chore: clean up Playwright ci config Playwright no longer runs in CI, so remove all config that was related to that --- backend/.env.example | 1 - backend/src/db/seed/index.ts | 3 +-- backend/src/index.ts | 2 +- frontend/e2e/config.ts | 7 ------- frontend/e2e/fixtures.ts | 5 ++--- frontend/playwright.config.ts | 25 +++++-------------------- 6 files changed, 9 insertions(+), 34 deletions(-) delete mode 100644 frontend/e2e/config.ts diff --git a/backend/.env.example b/backend/.env.example index ac988751..9600c134 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,4 @@ DATABASE_URL=postgresql://postgres@localhost:5433/readr -PORT=3001 BETTER_AUTH_URL=http://localhost:3001 FRONTEND_URL=http://localhost:5173 diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index 37a76131..7ced6ff5 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -46,8 +46,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const parser = createReadStream(resolve(__dirname, "book-data.csv")).pipe( - // Limit set for runs in CI because we do not need the whole database for testing - parse({ columns: true, to: process.env.SEED_BOOK_LIMIT ?? null }), + parse({ columns: true }), ); let inserted = 0; diff --git a/backend/src/index.ts b/backend/src/index.ts index 0ce6ef5f..0dae8283 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,7 +17,7 @@ export type ApolloContext = { }; }; -const PORT = Number(process.env.PORT ?? 3001); +const PORT = 3001; // Server setup mostly based on Apollo Server documentation: // https://www.apollographql.com/docs/apollo-server/getting-started diff --git a/frontend/e2e/config.ts b/frontend/e2e/config.ts deleted file mode 100644 index 7f1dd269..00000000 --- a/frontend/e2e/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -// CI sets the FRONTEND_PORT and BACKEND_PORT environment variables -const backendPort = process.env.BACKEND_PORT ?? "3001"; -const backendUrl = `http://localhost:${backendPort}`; -const frontendPort = process.env.FRONTEND_PORT ?? "5173"; -const frontendUrl = `http://localhost:${frontendPort}`; - -export { backendPort, backendUrl, frontendPort, frontendUrl }; diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 0d2a66f2..0e14ff64 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import { test as base, expect, type Page } from "@playwright/test"; -import { backendUrl, frontendUrl } from "./config"; type User = { name: string; email: string; password: string }; @@ -15,11 +14,11 @@ const test = base.extend<{ user: User }>({ // Signing up through page.request stores the session cookie in the browser, so the user is logged in. const response = await page.request.post( - `${backendUrl}/api/auth/sign-up/email`, + "http://localhost:3001/api/auth/sign-up/email", { data: user, // The backend only accepts auth requests from the frontend's origin. - headers: { Origin: frontendUrl }, + headers: { Origin: "http://localhost:5173" }, }, ); expect(response.ok()).toBe(true); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index c0c9e1cb..524ae13d 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,10 +1,4 @@ import { defineConfig, devices } from "@playwright/test"; -import { - backendPort, - backendUrl, - frontendPort, - frontendUrl, -} from "./e2e/config"; /** * Read environment variables from file. @@ -32,7 +26,7 @@ export default defineConfig({ // 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: `${frontendUrl}/project2/`, + baseURL: "http://localhost:5173/project2/", // Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer trace: "on-first-retry", @@ -74,23 +68,14 @@ export default defineConfig({ { command: "pnpm dev", cwd: "../backend", - url: `${backendUrl}/api/auth/ok`, + url: "http://localhost:3001/api/auth/ok", reuseExistingServer: !process.env.CI, - env: { - PORT: backendPort, - BETTER_AUTH_URL: backendUrl, - FRONTEND_URL: frontendUrl, - }, }, { - // Tests the production build - command: `pnpm build && pnpm preview --port ${frontendPort} --strictPort`, - url: `${frontendUrl}/project2/`, + // 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, - env: { - VITE_API_URL: backendUrl, - VITE_APP_URL: frontendUrl, - }, }, ], });