Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ad39d41
build: add Playwright
martakam Nov 7, 2026
45318c3
feat: add basic Playwright setup
martakam Nov 7, 2026
3b190f6
ci: use self-hosted NTNU runners
martakam Nov 7, 2026
b3078f4
ci: specify runner name correctly
martakam Nov 7, 2026
fd23410
ci: temporarily remove CI tests until we get access to a runner
martakam Nov 7, 2026
5a4e4f0
test: start backend in E2E tests too
martakam Nov 7, 2026
93704fc
test: check usual user flows for books and homepage using E2E tests
martakam Nov 7, 2026
038e9ce
test: check usual auth user flows
martakam Nov 7, 2026
8d4f6de
test: check usage of favorites
martakam Nov 7, 2026
66de270
test: check usage of settings
martakam Nov 7, 2026
15a2f95
test: check dark mode is kept after reload
martakam Nov 7, 2026
5843465
test: add automatic accessibility checks
martakam Nov 7, 2026
ba317ca
chore: ignore Playwright when running Prettier
martakam Nov 7, 2026
dc94e5c
chore: ignore React Router types when running Prettier
martakam Nov 7, 2026
be0a098
test: set max number of workers
martakam Nov 7, 2026
77295c6
ci: add Playwright tests back
martakam Nov 8, 2026
2fdec81
ci: install system deps once manually
martakam Nov 8, 2026
a12d62c
ci: add environment variables for custom ports
martakam Nov 8, 2026
75163ea
ci: set up database and seeding scripts
martakam Nov 8, 2026
283c195
fix: insert last batch of books only if needed
martakam Nov 8, 2026
9c67ac6
fix: check progress only after each seeding batch
martakam Nov 8, 2026
bcb824a
ci: use correct backend URL
martakam Nov 8, 2026
64c8c5d
ci: run tests on major browsers only
martakam Nov 8, 2026
3f66d15
docs: add E2E test explanations
martakam Nov 8, 2026
6498fb7
test: remove branded browsers
martakam Nov 8, 2026
b954794
ci: remove Playwright tests from ci
martakam Nov 8, 2026
2833ba2
chore: clean up Playwright ci config
martakam Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,10 @@ build/

db-data/
book-data.csv

# Playwright
test-results/
playwright-report/
blob-report/
playwright/.cache/
playwright/.auth/
18 changes: 10 additions & 8 deletions backend/src/db/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,23 +126,25 @@ for await (const book of parser as AsyncIterable<BookRow>) {
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!");
Expand Down
19 changes: 19 additions & 0 deletions docs/choices-and-reasoning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ pnpm-lock.yaml
build
schema.graphql
*.generated.*
.react-router
playwright-report
test-results
21 changes: 21 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down Expand Up @@ -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.
Expand Down
91 changes: 91 additions & 0 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -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" }),
);
});
});
}
67 changes: 67 additions & 0 deletions frontend/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
106 changes: 106 additions & 0 deletions frontend/e2e/books.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading