forked from akai/readest
28a7785e5d
* 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>
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|