From 28a7785e5df62204f77cd8bc0c5f278f0bef4fb5 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 18 May 2026 14:22:17 +0800 Subject: [PATCH] test(e2e): add a Playwright web e2e lane (reading & annotation flows) (#4214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/pull-request.yml | 17 + apps/readest-app/.gitignore | 6 + apps/readest-app/e2e/README.md | 48 +++ apps/readest-app/e2e/fixtures/base.ts | 49 +++ apps/readest-app/e2e/fixtures/books.ts | 17 + .../e2e/fixtures/books/readest-e2e-sample.txt | 29 ++ apps/readest-app/e2e/pages/BasePage.ts | 12 + apps/readest-app/e2e/pages/LibraryPage.ts | 57 ++++ apps/readest-app/e2e/pages/ReaderPage.ts | 291 ++++++++++++++++++ apps/readest-app/e2e/tests/annotation.spec.ts | 56 ++++ apps/readest-app/e2e/tests/import.spec.ts | 27 ++ apps/readest-app/e2e/tests/library.spec.ts | 26 ++ apps/readest-app/e2e/tests/reading.spec.ts | 53 ++++ apps/readest-app/package.json | 5 + apps/readest-app/playwright.config.ts | 48 +++ apps/readest-app/vitest.config.mts | 2 + pnpm-lock.yaml | 51 +-- 17 files changed, 775 insertions(+), 19 deletions(-) create mode 100644 apps/readest-app/e2e/README.md create mode 100644 apps/readest-app/e2e/fixtures/base.ts create mode 100644 apps/readest-app/e2e/fixtures/books.ts create mode 100644 apps/readest-app/e2e/fixtures/books/readest-e2e-sample.txt create mode 100644 apps/readest-app/e2e/pages/BasePage.ts create mode 100644 apps/readest-app/e2e/pages/LibraryPage.ts create mode 100644 apps/readest-app/e2e/pages/ReaderPage.ts create mode 100644 apps/readest-app/e2e/tests/annotation.spec.ts create mode 100644 apps/readest-app/e2e/tests/import.spec.ts create mode 100644 apps/readest-app/e2e/tests/library.spec.ts create mode 100644 apps/readest-app/e2e/tests/reading.spec.ts create mode 100644 apps/readest-app/playwright.config.ts diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 8519fb46..f87bf136 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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: diff --git a/apps/readest-app/.gitignore b/apps/readest-app/.gitignore index d7aa7c48..ee92b853 100644 --- a/apps/readest-app/.gitignore +++ b/apps/readest-app/.gitignore @@ -71,3 +71,9 @@ src-tauri/gen .claude/settings.local.json .claude/skills +# Playwright web e2e +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + diff --git a/apps/readest-app/e2e/README.md b/apps/readest-app/e2e/README.md new file mode 100644 index 00000000..84d55aa2 --- /dev/null +++ b/apps/readest-app/e2e/README.md @@ -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) +``` diff --git a/apps/readest-app/e2e/fixtures/base.ts b/apps/readest-app/e2e/fixtures/base.ts new file mode 100644 index 00000000..953fd51d --- /dev/null +++ b/apps/readest-app/e2e/fixtures/base.ts @@ -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; +}; + +/** + * 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({ + 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 }; diff --git a/apps/readest-app/e2e/fixtures/books.ts b/apps/readest-app/e2e/fixtures/books.ts new file mode 100644 index 00000000..992bdb81 --- /dev/null +++ b/apps/readest-app/e2e/fixtures/books.ts @@ -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', +); diff --git a/apps/readest-app/e2e/fixtures/books/readest-e2e-sample.txt b/apps/readest-app/e2e/fixtures/books/readest-e2e-sample.txt new file mode 100644 index 00000000..a3ea2a9e --- /dev/null +++ b/apps/readest-app/e2e/fixtures/books/readest-e2e-sample.txt @@ -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 diff --git a/apps/readest-app/e2e/pages/BasePage.ts b/apps/readest-app/e2e/pages/BasePage.ts new file mode 100644 index 00000000..390da5fa --- /dev/null +++ b/apps/readest-app/e2e/pages/BasePage.ts @@ -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) {} +} diff --git a/apps/readest-app/e2e/pages/LibraryPage.ts b/apps/readest-app/e2e/pages/LibraryPage.ts new file mode 100644 index 00000000..30b761ae --- /dev/null +++ b/apps/readest-app/e2e/pages/LibraryPage.ts @@ -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 { + 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 `