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 `