- e2e/reader.spec.js: ein Test-EPUB (e2e/make_epub.py) hochladen, öffnen ohne zu senden, lesen und Lesestand nach Neuladen, Fußnote, Markierung und Notizleiste, Hell/Dunkel samt Reader, alle Tabs ohne Seitenfehler. - e2e/env.js: E2E_PORT und E2E_PYTHON; nie einen laufenden Server wiederverwenden (dahinter läge eine echte Datenbank). - CI: eigener Job e2e (Playwright + Chromium); blockiert den Docker-Build nicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
6.2 KiB
JavaScript
142 lines
6.2 KiB
JavaScript
// The reader end to end: a book goes in (encrypted in the browser), opens, is
|
|
// read, and its place, a highlight and the theme survive a reload. The safety
|
|
// net for refactoring app.js (docs/refactoring.md). One book for the whole file:
|
|
// the tests build on each other and run in order.
|
|
const path = require('path');
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
test.use({ storageState: path.join(__dirname, '.auth', 'user.json') });
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
const BOOK = path.join(__dirname, '.auth', 'book.epub');
|
|
let bookId = null;
|
|
|
|
// Errors in the page fail the test that caused them.
|
|
let pageErrors = [];
|
|
test.beforeEach(({ page }) => {
|
|
pageErrors = [];
|
|
page.on('pageerror', e => pageErrors.push(String(e)));
|
|
});
|
|
test.afterEach(() => {
|
|
expect(pageErrors, 'errors in the page').toEqual([]);
|
|
});
|
|
|
|
async function openTestBook(page) {
|
|
await page.goto('/#books');
|
|
// A new service worker taking over reloads the page once: let that settle first.
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForFunction(() => typeof openBook === 'function' && typeof _lastBookListData !== 'undefined' && Array.isArray(_lastBookListData));
|
|
await page.evaluate(id => openBook(id), bookId);
|
|
await page.waitForFunction(() => document.querySelectorAll('#reader-content [data-epub-src]').length === 12
|
|
&& typeof _readPos !== 'undefined' && _readPos && _readPos.ready);
|
|
}
|
|
|
|
async function serverBook(page) {
|
|
const books = await (await page.request.get('/books/')).json();
|
|
return books.find(b => b.id === bookId);
|
|
}
|
|
|
|
test('a book goes in and shows in the list', async ({ page }) => {
|
|
await page.goto('/#books');
|
|
await page.setInputFiles('#book-file-input', BOOK);
|
|
const found = await page.waitForFunction(() => {
|
|
const list = typeof _lastBookListData !== 'undefined' ? (_lastBookListData || []) : [];
|
|
const b = list.find(x => x.title === 'E2E Testbuch');
|
|
return b ? b.id : null;
|
|
}, null, { timeout: 20000 });
|
|
bookId = await found.jsonValue();
|
|
expect(bookId).toBeTruthy();
|
|
});
|
|
|
|
test('opening without reading sends no position', async ({ page }) => {
|
|
const posts = [];
|
|
page.on('request', r => { if (r.method() === 'POST' && /\/progress\/$/.test(r.url())) posts.push(r.url()); });
|
|
await openTestBook(page);
|
|
await page.waitForTimeout(3000);
|
|
await page.evaluate(() => saveReaderProgress());
|
|
await page.evaluate(() => closeReader());
|
|
await page.waitForTimeout(500);
|
|
expect(posts).toEqual([]);
|
|
});
|
|
|
|
test('reading moves the position, and it comes back after a reload', async ({ page }) => {
|
|
await openTestBook(page);
|
|
const content = page.locator('#reader-content');
|
|
await content.hover();
|
|
for (let i = 0; i < 12; i++) await page.mouse.wheel(0, 600);
|
|
const saved = page.waitForRequest(r => r.method() === 'POST' && /\/progress\/$/.test(r.url()));
|
|
const body = JSON.parse((await saved).postData());
|
|
expect(body.device).toBe('Browser');
|
|
expect(body.read_at).toBeGreaterThan(1e12);
|
|
const anchor = await page.evaluate(() => getPositionAnchor(document.getElementById('reader-content')));
|
|
await page.waitForTimeout(500);
|
|
const b = await serverBook(page);
|
|
expect(b.position_anchor).not.toBe('');
|
|
expect(b.device).toBe('Browser');
|
|
|
|
await page.reload();
|
|
await openTestBook(page);
|
|
const restored = await page.evaluate(() => getPositionAnchor(document.getElementById('reader-content')));
|
|
const block = a => Number(a.split(':')[0]);
|
|
expect(Math.abs(block(restored) - block(anchor))).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test('a footnote shows its text', async ({ page }) => {
|
|
await openTestBook(page);
|
|
const ref = page.locator('#reader-content [data-footnote-ref]').first();
|
|
await ref.scrollIntoViewIfNeeded();
|
|
await ref.click();
|
|
await expect(page.locator('.footnote-popover')).toContainText('Der Text der Fußnote');
|
|
});
|
|
|
|
test('a highlight is kept, and shows in the margin', async ({ page }) => {
|
|
await openTestBook(page);
|
|
// Marker mode on (the ✎ button), select the start of a paragraph on screen
|
|
// the way a mouse would, then let go.
|
|
await page.evaluate(() => {
|
|
if (!markerModeActive) toggleMarkerMode();
|
|
const p = document.querySelector('#reader-content #c1p2') || document.querySelector('#reader-content p');
|
|
p.scrollIntoView({ block: 'center' });
|
|
const text = p.firstChild;
|
|
const range = document.createRange();
|
|
range.setStart(text, 0);
|
|
range.setEnd(text, 20);
|
|
const sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
const r = range.getBoundingClientRect();
|
|
p.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.right, clientY: r.bottom }));
|
|
});
|
|
const saved = page.waitForRequest(r => r.method() === 'POST' && /\/highlights\/$/.test(r.url()), { timeout: 15000 });
|
|
await page.locator('.hl-color-btn[data-hl-color="yellow"]').click();
|
|
await page.evaluate(() => saveHighlights && saveHighlights());
|
|
await saved;
|
|
|
|
await page.reload();
|
|
await openTestBook(page);
|
|
await page.waitForFunction(() => document.querySelectorAll('#reader-content mark.epub-highlight, #reader-content .epub-highlight').length > 0);
|
|
// The margin remembers being open; open it if it isn't.
|
|
await page.evaluate(() => { if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin(); });
|
|
await expect(page.locator('#reader-margin-markers .margin-note')).toHaveCount(1);
|
|
});
|
|
|
|
test('the theme switch turns the page and the reader light, and stays', async ({ page }) => {
|
|
await page.goto('/');
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
|
await page.locator('#theme-toggle').click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
|
await page.reload();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
|
await openTestBook(page);
|
|
await expect(page.locator('#reader-overlay')).toHaveClass(/reader-theme-bright/);
|
|
await page.evaluate(() => { closeReader(); __dioraSetTheme('dark'); });
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
|
});
|
|
|
|
test('every tab opens without errors', async ({ page }) => {
|
|
await page.goto('/');
|
|
for (const tab of ['home', 'radio', 'podcasts', 'books', 'feeds', 'focus', 'ambient']) {
|
|
await page.evaluate(t => showTab(t), tab);
|
|
await expect(page.locator(`#tab-${tab}`)).toBeVisible();
|
|
}
|
|
});
|