diora-web/e2e/reader.spec.js

170 lines
7.6 KiB
JavaScript
Raw Normal View History

// 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('an open margin keeps the text column in the middle when there is room', async ({ page }) => {
const column = () => page.evaluate(() => {
const c = document.getElementById('reader-content');
const p = c.querySelector('p');
const r = p.getBoundingClientRect();
const m = document.getElementById('reader-margin').getBoundingClientRect();
return {left: r.left, right: r.right, width: r.width, marginRight: m.right, window: window.innerWidth};
});
await page.setViewportSize({ width: 1600, height: 900 });
await openTestBook(page);
const closed = await column();
await page.evaluate(() => { if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin(); });
await page.waitForTimeout(400); // the margin and the padding slide together
const open = await column();
expect(Math.abs(open.left - closed.left)).toBeLessThanOrEqual(2);
expect(open.width).toBeCloseTo(closed.width, 0);
// Not enough room: the column moves aside, but keeps its width and clears the margin.
await page.setViewportSize({ width: 1000, height: 900 });
await page.waitForTimeout(400);
const narrow = await column();
expect(narrow.left).toBeGreaterThanOrEqual(narrow.marginRight);
await page.evaluate(() => { toggleAnnotationsMargin(); });
});
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('/');
await page.waitForLoadState('networkidle');
await page.waitForFunction(() => typeof showTab === 'function' && typeof loadBookList === 'function');
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();
}
});