test(e2e): add a Playwright web e2e lane (reading & annotation flows) (#4214)

* feat(e2e): add Playwright web e2e lane

Adds a web-layer end-to-end suite that drives the Next.js web build
(`pnpm dev-web`) in a real browser, complementing the existing
WebdriverIO suite that drives the Tauri shell.

- playwright.config.ts: single Chromium project, auto-starts dev-web
- e2e/pages: BasePage/LibraryPage/ReaderPage page objects
- e2e/fixtures/base.ts: suppresses demo-book auto-import for a
  deterministic empty library
- e2e/tests: library shell + search, book import, reader open +
  pagination smoke specs
- e2e/fixtures/books: synthetic sample book for import tests
- scripts: test:e2e:web, test:e2e:web:ui, test:e2e:web:report

Tests run unauthenticated against isolated browser contexts;
authenticated/sync flows are out of scope until a test account is
provisioned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): cover reading and annotation flows

Expands the Playwright web e2e lane beyond library/import smoke tests to
exercise the major reading and annotation features against the real
sample-alice.epub fixture (src/__tests__/fixtures/data/).

Reading (reading.spec.ts): open + page turn, TOC chapter navigation,
in-book search, font-size change via the settings dialog, bookmark
toggle.

Annotation (annotation.spec.ts): selection popup, create highlight,
change highlight color, add a note, delete an annotation.

- ReaderPage POM gains sidebar/TOC, search, settings, bookmark and
  annotation actions; text selection is driven inside the section
  iframe (synthetic drags do not produce a selection through nested
  paginated foliate iframes)
- openBook fixture imports and opens a book so specs skip boilerplate
- books.ts centralises fixture book paths
- replaces the old reader.spec.ts smoke

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(e2e): add headed run script and always write HTML report

- test:e2e:web:headed runs the suite in a visible browser, one test at
  a time, with traces captured
- the HTML reporter now runs for local runs too, so every run writes
  playwright-report/ for test:e2e:web:report to open

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): fix headed-run flakes in reading and annotation specs

The headed run (slower rendering) surfaced two races that the headless
run happened to pass:

- TOC navigation read reading progress before the section's async
  progress update landed — now polls with expect.poll.
- visibleSectionFrame required a paragraph fully inside the viewport,
  which intermittently matched nothing — now accepts any paragraph
  intersecting the viewport and tolerates frames detaching mid-navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: run the Playwright web e2e suite in test_web_app

Adds `pnpm test:e2e:web` to the test_web_app job, after the unit/browser
tests. The job already installs the Chromium browser, and `.env.web` is
committed so the auto-started `pnpm dev-web` server has its config. On
failure the HTML report is uploaded as an artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): exclude e2e specs from the vitest run

vitest's default glob matches `*.spec.ts`, so it picked up the new
Playwright `e2e/tests/*.spec.ts` files and crashed. Exclude `e2e/`
from vitest — those specs run via `pnpm test:e2e:web`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(e2e): run the web e2e suite against a production build

`next dev` renders a full-screen error overlay when the app emits its
`next-view-transitions` "Transition was aborted" unhandled rejection,
and the overlay intercepts pointer events — making the suite flaky on
CI. CI now builds the web app (`pnpm build-web`) and the Playwright
webServer serves it via `pnpm start-web`; local runs still use
`pnpm dev-web`. Verified: 14/14 pass against the production build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(e2e): run the web e2e suite in the build_web_app job

build_web_app already runs `pnpm build-web`, so the e2e suite belongs
there — it reuses that build (the CI Playwright webServer serves it via
`pnpm start-web`) instead of building a second time in test_web_app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): run the web e2e suite with 4 workers

Specs are isolated (a fresh browser context per test), so they are
safe to parallelize. `test:e2e:web:headed` keeps --workers=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-18 14:22:17 +08:00
committed by GitHub
parent 52f9634810
commit 28a7785e5d
17 changed files with 775 additions and 19 deletions
+17
View File
@@ -89,6 +89,23 @@ jobs:
run: |
pnpm build-web && pnpm check:all
- name: install playwright browsers
working-directory: apps/readest-app
run: npx playwright install --with-deps chromium
- name: run web e2e tests
id: web_e2e
working-directory: apps/readest-app
run: pnpm test:e2e:web
- name: upload e2e report
if: ${{ failure() && steps.web_e2e.outcome == 'failure' }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/readest-app/playwright-report/
retention-days: 7
test_web_app:
runs-on: ubuntu-latest
steps:
+6
View File
@@ -71,3 +71,9 @@ src-tauri/gen
.claude/settings.local.json
.claude/skills
# Playwright web e2e
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+48
View File
@@ -0,0 +1,48 @@
# End-to-end tests
Readest has two end-to-end lanes. They cover different layers and are run
separately.
## Web lane — Playwright
Drives the Next.js **web** build (`pnpm dev-web`) in a real browser. Fast, no
Rust build required. Tests run unauthenticated against a fresh browser
context, so each test starts from an isolated, empty local library.
```bash
pnpm test:e2e:web # run the web e2e suite (auto-starts pnpm dev-web)
pnpm test:e2e:web:headed # run headed, one test at a time, with traces
pnpm test:e2e:web:ui # run in the Playwright UI mode
pnpm test:e2e:web:report # open the last HTML report
```
Every run writes an HTML report to `playwright-report/`; open it with
`pnpm test:e2e:web:report`.
Layout:
| Path | Purpose |
| --------------------------------- | ------------------------------------------------------ |
| `playwright.config.ts` (app root) | Runner config, projects, web server. |
| `e2e/tests/` | Specs (`*.spec.ts`). |
| `e2e/pages/` | Page Object Model — actions/queries, no assertions. |
| `e2e/fixtures/` | Shared fixtures; `fixtures/books/` holds sample books. |
Page objects expose locators and actions; assertions stay in the specs so
failures point at test intent. To add coverage, prefer extending a page
object over inlining selectors in a spec.
The demo-book auto-import (`useDemoBooks`) is suppressed by the base fixture
so the library is deterministic; authenticated/sync flows are out of scope
for this lane until a test account is provisioned.
## Tauri lane — WebdriverIO
Drives the actual **Tauri** desktop shell via `tauri-driver`. Use this for
coverage that depends on the native build (Rust integration, window
management, platform globals).
```bash
pnpm tauri:dev:test # start the Tauri app with the webdriver feature
pnpm test:e2e # run wdio against it (specs: e2e/*.e2e.ts)
```
+49
View File
@@ -0,0 +1,49 @@
import { test as base, expect } from '@playwright/test';
import { LibraryPage } from '../pages/LibraryPage';
import { ReaderPage } from '../pages/ReaderPage';
import { SAMPLE_EPUB } from './books';
type Fixtures = {
/**
* Imports a book (the sample EPUB by default), opens it, and returns a
* {@link ReaderPage} that is ready to interact with.
*/
openBook: (filePath?: string) => Promise<ReaderPage>;
};
/**
* Base test fixture for the web e2e lane.
*
* - Overrides `page` to suppress the demo-book auto-import that `useDemoBooks`
* performs on a fresh web session (see `src/app/library/hooks/useDemoBooks.ts`),
* so every test starts from a deterministic empty library.
* - Adds the `openBook` action fixture so reading/annotation specs do not
* repeat the import-and-open boilerplate.
*/
export const test = base.extend<Fixtures>({
page: async ({ page }, use) => {
await page.addInitScript(() => {
try {
window.localStorage.setItem('demoBooksFetched', 'true');
} catch {
// localStorage may be unavailable in some contexts; ignore.
}
});
await use(page);
},
openBook: async ({ page }, use) => {
await use(async (filePath = SAMPLE_EPUB) => {
const library = new LibraryPage(page);
await library.goto();
await library.importBook(filePath);
await expect(library.bookCards()).toHaveCount(1);
await library.openFirstBook();
const reader = new ReaderPage(page);
await reader.waitForReady();
return reader;
});
},
});
export { expect };
+17
View File
@@ -0,0 +1,17 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const fixturesDir = path.dirname(fileURLToPath(import.meta.url));
/** Synthetic plain-text book — fast, used for basic import coverage. */
export const SAMPLE_TXT = path.join(fixturesDir, 'books/readest-e2e-sample.txt');
/**
* A real EPUB ("Alice's Adventures in Wonderland") from the unit-test
* fixtures. Has multiple chapters and substantial prose, so it exercises
* reading and annotation flows realistically.
*/
export const SAMPLE_EPUB = path.join(
fixturesDir,
'../../src/__tests__/fixtures/data/sample-alice.epub',
);
@@ -0,0 +1,29 @@
Readest E2E Sample
Chapter One
This is a synthetic plain-text book used by the Readest web end-to-end
test suite. It exists only so that import and reading flows have a small,
deterministic file to exercise. It is not a real publication.
The text below is intentionally repetitive filler. Its only purpose is to
give the paginator enough content to lay out across more than one page so
that page-turning can be exercised by the tests.
Chapter Two
A reader opens a book, and the words arrange themselves into pages. Each
page is a small window onto a longer whole. Turn forward and the window
slides ahead; turn back and it returns to where it was.
Lorem ipsum has long served as placeholder prose, but plain repetition
serves a test suite just as well. Sentences accumulate, paragraphs follow,
and soon there is enough material for the layout engine to work with.
Chapter Three
The end of a sample book is much like its beginning: a few lines of text,
arranged for no one in particular, standing in for the real thing. When
the test completes, this book is discarded and forgotten.
The End
+12
View File
@@ -0,0 +1,12 @@
import type { Page } from '@playwright/test';
/**
* Shared base for page objects.
*
* Page objects expose actions and queries (locators) only — assertions live
* in the specs, so a failing assertion points at test intent rather than at a
* helper.
*/
export abstract class BasePage {
constructor(protected readonly page: Page) {}
}
+57
View File
@@ -0,0 +1,57 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* The library page (`/library`, also rendered at `/`).
*/
export class LibraryPage extends BasePage {
readonly container: Locator;
readonly header: Locator;
readonly bookshelf: Locator;
readonly searchInput: Locator;
readonly clearSearchButton: Locator;
readonly emptyState: Locator;
constructor(page: Page) {
super(page);
this.container = page.locator('[aria-label="Your Library"]');
this.header = page.locator('[aria-label="Library Header"]');
this.bookshelf = page.locator('[aria-label="Bookshelf"]');
this.searchInput = page.locator('.search-input');
this.clearSearchButton = page.locator('[aria-label="Clear Search"]');
this.emptyState = page.getByRole('heading', { name: 'Start your library' });
}
async goto(): Promise<void> {
await this.page.goto('/library');
await this.container.waitFor({ state: 'visible' });
}
/**
* All book cards currently shown in the bookshelf. Book cards are
* `div[role="button"]`; the trailing "+" import tile is a `<button>`, so it
* is naturally excluded.
*/
bookCards(): Locator {
return this.bookshelf.locator('div[role="button"]');
}
/**
* Import a book file via the empty-state "Import Books" button.
*
* The file `<input>` is created off-DOM (see `useFileSelector.selectFileWeb`),
* so a `filechooser` event must be awaited rather than locating an
* `<input type="file">`.
*/
async importBook(filePath: string): Promise<void> {
const importButton = this.page.locator('.hero').getByRole('button', { name: 'Import Books' });
const chooserPromise = this.page.waitForEvent('filechooser');
await importButton.click();
const chooser = await chooserPromise;
await chooser.setFiles(filePath);
}
async openFirstBook(): Promise<void> {
await this.bookCards().first().click();
}
}
+291
View File
@@ -0,0 +1,291 @@
import { type FrameLocator, type Locator, type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* The reader page (`/reader/{ids}` on web).
*
* There is intentionally no `goto()` — callers reach the reader by opening a
* book (see the `openBook` fixture), because `/reader` depends on the book
* already being present in local storage.
*
* The header and footer bars are auto-hidden until the book is hovered;
* methods that need them call {@link revealHeader} / {@link revealFooter}.
*/
export class ReaderPage extends BasePage {
readonly viewer: Locator;
readonly foliateView: Locator;
readonly headerBar: Locator;
readonly footerBar: Locator;
readonly sidebar: Locator;
readonly notebook: Locator;
readonly tocItems: Locator;
readonly searchResults: Locator;
readonly annotationPopup: Locator;
readonly noteEditor: Locator;
readonly annotationItems: Locator;
constructor(page: Page) {
super(page);
this.viewer = page.locator('.foliate-viewer').first();
this.foliateView = page.locator('foliate-view').first();
this.headerBar = page.locator('.header-bar').first();
this.footerBar = page.locator('.footer-bar').first();
this.sidebar = page.locator('[role="navigation"][aria-label="Sidebar"]');
this.notebook = page.locator('[role="group"][aria-label="Notebook"]');
this.tocItems = page.locator('.toc-list [role="treeitem"]');
this.searchResults = page.locator('.search-results li[role="button"]');
this.annotationPopup = page.locator('.selection-popup');
this.noteEditor = page.locator('.note-editor-container');
this.annotationItems = page.locator('li.booknote-item[role="button"]');
}
/** Wait until the reader route is active and the book viewer has mounted. */
async waitForReady(): Promise<void> {
await this.page.waitForURL(/\/reader/);
await this.viewer.waitFor({ state: 'visible' });
await this.foliateView.waitFor({ state: 'attached' });
}
// --- chrome (auto-hidden header / footer bars) ---
/** Reveal the header bar by clicking its top hover strip. */
async revealHeader(): Promise<void> {
const box = await this.viewer.boundingBox();
if (box) {
await this.page.mouse.click(box.x + box.width / 2, box.y + 4);
}
}
/** Reveal the footer bar by clicking its bottom hover strip. */
async revealFooter(): Promise<void> {
const box = await this.viewer.boundingBox();
if (box) {
await this.page.mouse.click(box.x + box.width / 2, box.y + box.height - 4);
}
}
// --- pagination & progress ---
async nextPage(): Promise<void> {
await this.page.keyboard.press('ArrowRight');
}
async prevPage(): Promise<void> {
await this.page.keyboard.press('ArrowLeft');
}
/**
* Current reading position as a number parsed from the footer's
* "Reading Progress" label. The label is in the DOM regardless of whether
* the footer is visually revealed, so no reveal is needed.
*/
async readingProgress(): Promise<number> {
const label =
(await this.page
.locator('span[title="Reading Progress"]')
.first()
.getAttribute('aria-label')) ?? '';
const match = label.match(/(\d+(?:\.\d+)?)/);
return match ? Number(match[1]) : Number.NaN;
}
// --- sidebar / table of contents ---
async openSidebar(): Promise<void> {
if (await this.sidebar.isVisible()) return;
await this.revealHeader();
await this.page.locator('button[aria-label="Toggle Sidebar"]').first().click();
await this.sidebar.waitFor({ state: 'visible' });
}
/** Open the sidebar and navigate to the TOC chapter at the given index. */
async openTocChapter(index: number): Promise<void> {
await this.openSidebar();
await this.sidebar.locator('[aria-label="TOC"]').click();
await this.tocItems.nth(index).click();
}
// --- in-book search ---
/** Run an in-book search and return the number of results. */
async search(term: string): Promise<number> {
await this.openSidebar();
await this.page.locator('button[title="Show Search Bar"]').click();
await this.sidebar.locator('input.search-input').fill(term);
await this.searchResults.first().waitFor({ state: 'visible' });
return this.searchResults.count();
}
// --- reader settings ---
/**
* Open the settings dialog, increase the default font size by one step,
* and return the value before and after.
*/
async increaseFontSize(): Promise<{ before: string; after: string }> {
await this.revealHeader();
await this.headerBar.locator('button[aria-label="Font & Layout"]').click();
await this.page.locator('[data-tab="Font"]').click();
const row = this.page.locator('[data-setting-id="settings.font.defaultFontSize"]');
const input = row.locator('input').first();
await input.waitFor({ state: 'visible' });
const before = await input.inputValue();
await row.locator('[aria-label="Increase"]').click();
await expect(input).not.toHaveValue(before);
const after = await input.inputValue();
await this.page.keyboard.press('Escape');
return { before, after };
}
// --- bookmarks ---
get addBookmarkButton(): Locator {
return this.page.locator('button[aria-label="Add Bookmark"]');
}
get removeBookmarkButton(): Locator {
return this.page.locator('button[aria-label="Remove Bookmark"]');
}
// --- text selection & annotations ---
/**
* Find the iframe of the on-screen book section.
*
* The reader prerenders adjacent sections into separate iframes, so this
* scans every `.foliate-viewer iframe` and returns the one holding a `<p>`
* whose bounding box actually falls inside the viewport.
*/
private async visibleSectionFrame(): Promise<FrameLocator> {
const viewport = this.page.viewportSize() ?? { width: 1280, height: 720 };
for (let attempt = 0; attempt < 30; attempt += 1) {
const iframes = this.page.locator('.foliate-viewer iframe');
const frameCount = await iframes.count();
for (let i = 0; i < frameCount; i += 1) {
const paragraphs = iframes.nth(i).contentFrame().locator('p');
// A frame may be detaching mid-navigation; skip it if so.
const paragraphCount = await paragraphs.count().catch(() => 0);
for (let j = 0; j < Math.min(paragraphCount, 30); j += 1) {
const box = await paragraphs
.nth(j)
.boundingBox()
.catch(() => null);
// Accept a paragraph that intersects the viewport — off-screen
// prerendered sections sit fully outside it.
if (
box &&
box.width > 120 &&
box.height > 16 &&
box.x < viewport.width &&
box.x + box.width > 0 &&
box.y < viewport.height &&
box.y + box.height > 0
) {
return iframes.nth(i).contentFrame();
}
}
}
await this.page.waitForTimeout(400);
}
throw new Error('no visible book section found in the viewer');
}
/**
* Select a paragraph of book text and raise the annotation popup.
*
* Navigates to a chapter first so the page holds prose (the book opens on a
* cover page). The selection is made inside the section iframe and a
* `pointerup` is dispatched — the exact pair of signals the reader's
* annotator listens for — because synthetic mouse drags do not reliably
* produce a text selection through nested, paginated foliate iframes.
*/
async selectText(): Promise<void> {
await this.openTocChapter(3);
const frame = await this.visibleSectionFrame();
await frame.locator('body').evaluate(() => {
const paragraphs = Array.from(document.querySelectorAll('p'));
const target = paragraphs.find((p) => (p.textContent ?? '').trim().length > 60);
if (!target) {
throw new Error('no selectable paragraph in the visible section');
}
// Select a span within a text node — the reader's CFI generation
// expects text-node range endpoints, not element boundaries.
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
let textNode = walker.nextNode();
while (textNode && (textNode.textContent ?? '').trim().length < 20) {
textNode = walker.nextNode();
}
if (!textNode) {
throw new Error('no text node found in the target paragraph');
}
const length = textNode.textContent?.length ?? 0;
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, Math.min(length, 80));
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: rect.left + Math.min(20, rect.width / 2),
clientY: rect.top + rect.height / 2,
bubbles: true,
pointerType: 'mouse',
}),
);
});
await this.annotationPopup.waitFor({ state: 'visible' });
}
/** A tool button inside the annotation popup, by its accessible name. */
popupTool(name: string | RegExp): Locator {
return this.annotationPopup.getByRole('button', { name });
}
async highlightSelection(): Promise<void> {
await this.popupTool('Highlight').click();
}
async selectHighlightColor(color: string): Promise<void> {
await this.page.locator(`[aria-label="Select ${color} color"]`).click();
}
/** Annotate the current selection with a note. */
async addNote(text: string): Promise<void> {
await this.popupTool('Annotate').click();
await this.noteEditor.waitFor({ state: 'visible' });
await this.noteEditor.getByRole('textbox').fill(text);
await this.notebook.getByRole('button', { name: 'Save' }).click();
}
/** Dismiss the annotation popup if it is open. */
async dismissPopup(): Promise<void> {
if (await this.annotationPopup.isVisible().catch(() => false)) {
await this.page.keyboard.press('Escape');
await this.annotationPopup.waitFor({ state: 'hidden' }).catch(() => {});
}
}
/**
* Open the sidebar's "Annotate" tab, which lists the book's annotations
* (assert against {@link annotationItems} afterwards).
*/
async openAnnotationsTab(): Promise<void> {
await this.dismissPopup();
await this.openSidebar();
await this.sidebar.locator('[aria-label="Annotate"]').click();
}
/** Delete the first annotation from the sidebar's "Annotate" tab. */
async deleteFirstAnnotation(): Promise<void> {
await this.openAnnotationsTab();
const item = this.annotationItems.first();
await item.hover();
await item.getByRole('button', { name: 'Delete' }).click();
}
}
@@ -0,0 +1,56 @@
import { expect, test } from '../fixtures/base';
test.describe('Annotation', () => {
test('shows the annotation popup when text is selected', async ({ openBook }) => {
const reader = await openBook();
await reader.selectText();
await expect(reader.annotationPopup).toBeVisible();
await expect(reader.popupTool('Highlight')).toBeVisible();
});
test('creates a highlight from the selected text', async ({ openBook }) => {
const reader = await openBook();
await reader.selectText();
await reader.highlightSelection();
await reader.openAnnotationsTab();
await expect(reader.annotationItems).toHaveCount(1);
});
test('changes the highlight color', async ({ openBook }) => {
const reader = await openBook();
await reader.selectText();
await reader.highlightSelection();
await reader.selectHighlightColor('green');
await reader.openAnnotationsTab();
await expect(reader.annotationItems).toHaveCount(1);
});
test('adds a note to the selected text', async ({ openBook }) => {
const reader = await openBook();
const noteText = 'A note added by the e2e suite';
await reader.selectText();
await reader.addNote(noteText);
await expect(reader.notebook.getByText(noteText)).toBeVisible();
});
test('deletes an annotation', async ({ openBook }) => {
const reader = await openBook();
await reader.selectText();
await reader.highlightSelection();
await reader.openAnnotationsTab();
await expect(reader.annotationItems).toHaveCount(1);
await reader.deleteFirstAnnotation();
await expect(reader.annotationItems).toHaveCount(0);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from '../fixtures/base';
import { SAMPLE_EPUB, SAMPLE_TXT } from '../fixtures/books';
import { LibraryPage } from '../pages/LibraryPage';
test.describe('Book import', () => {
test('imports a plain-text file and surfaces it in the bookshelf', async ({ page }) => {
const library = new LibraryPage(page);
await library.goto();
await expect(library.emptyState).toBeVisible();
await library.importBook(SAMPLE_TXT);
await expect(library.bookshelf).toBeVisible();
await expect(library.bookCards()).toHaveCount(1);
});
test('imports an EPUB file and surfaces it in the bookshelf', async ({ page }) => {
const library = new LibraryPage(page);
await library.goto();
await expect(library.emptyState).toBeVisible();
await library.importBook(SAMPLE_EPUB);
await expect(library.bookshelf).toBeVisible();
await expect(library.bookCards()).toHaveCount(1);
});
});
@@ -0,0 +1,26 @@
import { expect, test } from '../fixtures/base';
import { LibraryPage } from '../pages/LibraryPage';
test.describe('Library page', () => {
test('renders the library shell and an empty-library state', async ({ page }) => {
const library = new LibraryPage(page);
await library.goto();
await expect(library.container).toBeVisible();
await expect(library.header).toBeVisible();
await expect(library.emptyState).toBeVisible();
});
test('search input accepts text and exposes a clear control', async ({ page }) => {
const library = new LibraryPage(page);
await library.goto();
await expect(library.searchInput).toBeVisible();
await library.searchInput.fill('a test query');
await expect(library.searchInput).toHaveValue('a test query');
await expect(library.clearSearchButton).toBeVisible();
await library.clearSearchButton.click();
await expect(library.searchInput).toHaveValue('');
});
});
@@ -0,0 +1,53 @@
import { expect, test } from '../fixtures/base';
test.describe('Reading', () => {
test('opens an EPUB and turns pages', async ({ openBook }) => {
const reader = await openBook();
await expect(reader.viewer).toBeVisible();
await reader.nextPage();
await reader.nextPage();
await reader.prevPage();
await expect(reader.viewer).toBeVisible();
});
test('navigates chapters via the table of contents', async ({ openBook }) => {
const reader = await openBook();
const startProgress = await reader.readingProgress();
await reader.openTocChapter(6);
// Reading progress updates asynchronously after the section loads.
await expect.poll(() => reader.readingProgress()).toBeGreaterThan(startProgress);
});
test('finds matches with in-book search', async ({ openBook }) => {
const reader = await openBook();
const resultCount = await reader.search('Alice');
expect(resultCount).toBeGreaterThan(0);
await reader.searchResults.first().click();
await expect(reader.viewer).toBeVisible();
});
test('increases the font size from the settings dialog', async ({ openBook }) => {
const reader = await openBook();
const { before, after } = await reader.increaseFontSize();
expect(Number(after)).toBeGreaterThan(Number(before));
});
test('adds and removes a bookmark', async ({ openBook }) => {
const reader = await openBook();
await reader.revealHeader();
await expect(reader.addBookmarkButton).toBeVisible();
await reader.addBookmarkButton.click();
await expect(reader.removeBookmarkButton).toBeVisible();
await reader.removeBookmarkButton.click();
await expect(reader.addBookmarkButton).toBeVisible();
});
});
+5
View File
@@ -28,6 +28,10 @@
"test:pr:tauri": "bash scripts/test-tauri.sh",
"test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh",
"test:e2e": "wdio run wdio.conf.ts",
"test:e2e:web": "playwright test",
"test:e2e:web:headed": "playwright test --headed --workers=1 --trace on",
"test:e2e:web:ui": "playwright test --ui",
"test:e2e:web:report": "playwright show-report",
"tauri:dev:test": "dotenv -e .env.tauri -- tauri dev --features webdriver",
"tauri:build:test": "dotenv -e .env.tauri -- tauri build --debug --features webdriver",
"tauri": "tauri",
@@ -189,6 +193,7 @@
},
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.10.1",
"@testing-library/dom": "^10.4.0",
+48
View File
@@ -0,0 +1,48 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright web e2e lane.
*
* This suite drives the Next.js *web* build (`pnpm dev-web`) in a real browser.
* It is complementary to — not a replacement for — the WebdriverIO suite
* (`e2e/app.e2e.ts`, run via `pnpm test:e2e`), which drives the Tauri shell.
*
* - Specs: e2e/tests/**\/*.spec.ts
* - Page objects: e2e/pages
* - Fixtures: e2e/fixtures
*
* Tests run unauthenticated against a fresh browser context, so each test
* starts from an isolated, empty local library.
*/
const PORT = 3000;
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
// Tests are isolated (a fresh browser context per test), so they run in
// parallel. `test:e2e:web:headed` overrides this to 1 to stay watchable.
workers: 4,
// Always write the HTML report so `pnpm test:e2e:web:report` can open it.
reporter: process.env.CI
? [['github'], ['html', { open: 'never' }]]
: [['list'], ['html', { open: 'never' }]],
timeout: 60_000,
expect: { timeout: 15_000 },
use: {
baseURL: `http://localhost:${PORT}`,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
// CI runs against a production build (`pnpm build-web` runs first as a
// separate CI step) — `next dev` shows an error overlay on the app's
// `next-view-transitions` unhandled rejection, which intercepts clicks.
command: process.env.CI ? 'pnpm start-web' : 'pnpm dev-web',
port: PORT,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
+2
View File
@@ -27,6 +27,8 @@ export default defineConfig({
'**/node_modules/**',
'**/dist/**',
'**/.claude/**',
// Playwright web e2e specs — run via `pnpm test:e2e:web`, not vitest.
'**/e2e/**',
'**/*.browser.test.ts',
'**/*.browser.test.tsx',
'**/*.tauri.test.ts',
+32 -19
View File
@@ -108,7 +108,7 @@ importers:
version: 1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
'@opennextjs/cloudflare':
specifier: ^1.19.4
version: 1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)
version: 1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -150,7 +150,7 @@ importers:
version: 0.6.0-pre.28-readest.0
'@serwist/next':
specifier: ^9.5.6
version: 9.5.11(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))
version: 9.5.11(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))
'@serwist/webpack-plugin':
specifier: ^9.5.6
version: 9.5.11(browserslist@4.28.2)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))
@@ -315,10 +315,10 @@ importers:
version: 5.1.11
next:
specifier: 16.2.6
version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next-view-transitions:
specifier: ^0.3.5
version: 0.3.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
version: 0.3.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
nunjucks:
specifier: ^3.2.4
version: 3.2.4(chokidar@3.6.0)
@@ -395,6 +395,9 @@ importers:
'@next/bundle-analyzer':
specifier: ^15.4.2
version: 15.5.18
'@playwright/test':
specifier: ^1.60.0
version: 1.60.0
'@tailwindcss/typography':
specifier: ^0.5.16
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
@@ -544,7 +547,7 @@ importers:
version: 5.9.3
vinext:
specifier: ^0.0.21
version: 0.0.21(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.106.2(postcss@8.5.14))
version: 0.0.21(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.106.2(postcss@8.5.14))
vite:
specifier: '>=7.3.2 <8'
version: 7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)
@@ -2396,6 +2399,11 @@ packages:
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
engines: {node: '>=14'}
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -10660,7 +10668,7 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@opennextjs/aws@4.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))':
'@opennextjs/aws@4.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))':
dependencies:
'@ast-grep/napi': 0.40.5
'@aws-sdk/client-cloudfront': 3.984.0
@@ -10676,7 +10684,7 @@ snapshots:
cookie: 1.1.1
esbuild: 0.25.4
express: 5.2.1
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
path-to-regexp: 8.4.2
urlpattern-polyfill: 10.1.0
yaml: 2.9.0
@@ -10684,17 +10692,17 @@ snapshots:
- aws-crt
- supports-color
'@opennextjs/cloudflare@1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)':
'@opennextjs/cloudflare@1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)':
dependencies:
'@ast-grep/napi': 0.40.5
'@dotenvx/dotenvx': 1.31.0
'@opennextjs/aws': 4.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
'@opennextjs/aws': 4.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
ci-info: 4.4.0
cloudflare: 4.5.0
comment-json: 4.6.2
enquirer: 2.4.1
glob: 12.0.0
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
ts-tqdm: 0.8.6
wrangler: 4.90.0
yargs: 18.0.0
@@ -10781,6 +10789,10 @@ snapshots:
'@opentelemetry/semantic-conventions@1.40.0': {}
'@playwright/test@1.60.0':
dependencies:
playwright: 1.60.0
'@polka/url@1.0.0-next.29': {}
'@poppinss/colors@4.1.6':
@@ -11428,7 +11440,7 @@ snapshots:
transitivePeerDependencies:
- browserslist
'@serwist/next@9.5.11(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))':
'@serwist/next@9.5.11(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))':
dependencies:
'@serwist/build': 9.5.11(browserslist@4.28.2)(typescript@5.9.3)
'@serwist/utils': 9.5.11(browserslist@4.28.2)
@@ -11437,7 +11449,7 @@ snapshots:
browserslist: 4.28.2
glob: 13.0.6
kolorist: 1.8.0
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
semver: 7.7.4
serwist: 9.5.11(browserslist@4.28.2)(typescript@5.9.3)
@@ -12293,13 +12305,13 @@ snapshots:
dependencies:
unpic: 4.2.2
'@unpic/react@1.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
'@unpic/react@1.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@unpic/core': 1.0.3
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@upsetjs/venn.js@2.0.0':
optionalDependencies:
@@ -15682,13 +15694,13 @@ snapshots:
netmask@2.1.1: {}
next-view-transitions@0.3.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
next-view-transitions@0.3.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@next/env': 16.2.6
'@swc/helpers': 0.5.15
@@ -15708,6 +15720,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.2.6
'@next/swc-win32-x64-msvc': 16.2.6
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.60.0
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -17425,9 +17438,9 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vinext@0.0.21(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.106.2(postcss@8.5.14)):
vinext@0.0.21(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.106.2(postcss@8.5.14)):
dependencies:
'@unpic/react': 1.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@unpic/react': 1.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@vercel/og': 0.8.6
'@vitejs/plugin-rsc': 0.5.26(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.106.2(postcss@8.5.14)))(react@19.2.5)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))
magic-string: 0.30.21