// 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(); } });