diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index 4d5abdb..39d8ef1 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -38,3 +38,31 @@ jobs: - name: Run tests run: /tmp/venv/bin/python manage.py test + + e2e: + # The browser tests (e2e/, docs/refactoring.md): a real runserver on its own + # throwaway database, a book uploaded, read, highlighted, the theme switched. + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install Python dependencies + run: | + python -m venv /tmp/venv + /tmp/venv/bin/pip install --no-cache-dir -r requirements.txt + + - name: Install Playwright and Chromium + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Browser tests + env: + CI: '1' + E2E_PYTHON: /tmp/venv/bin/python + run: | + mkdir -p data + npx playwright test diff --git a/docs/refactoring.md b/docs/refactoring.md new file mode 100644 index 0000000..16c22bf --- /dev/null +++ b/docs/refactoring.md @@ -0,0 +1,52 @@ +# Refactoring (started 2026-10-14) + +diora grew fast across four code bases that share one web front end: +diora-web (server + app.js and friends), diora-android (Kotlin + web-native/*.js), +diora-desktop (Electron + desktop.js) and the vendored copies between them. +This is the plan for tidying it up without breaking anything. + +## Rules + +* **No behaviour changes in a refactoring step.** Features and fixes go in their own commits. +* **Tests before moving code.** A step only starts when what it touches is covered. +* **Small steps, each one releasable**, each one checked on web, desktop and Android. +* Whoever owns a file does its refactoring (web-native/* → the Android session; + app.js, home.js, templates, desktop → the desktop session); shared contracts live in docs/. + +## The numbers (2026-10-14) + +| file | lines | +|---|---| +| static/js/app.js | 6 864, about 275 functions, about 120 globals, 45 sections | +| static/podcasts/native-podcast.js (copy from diora-android) | 4 554 | +| static/css/app.css | 2 010 | +| static/js/podcasts-web.js | 1 899 | +| podcasts/views.py | 2 505 | +| web-native/native.css | 2 150 | + +Web JavaScript has no unit tests and four Playwright smoke tests that run nowhere. +The reading-position rules exist four times (books/views.py, app.js, desktop ops.js, +Android Ops.kt), each tested separately or not at all. + +## Steps + +1. **Safety net** (in progress) + * Playwright tests that run locally and in CI against a throwaway database: log in, open a + book (an EPUB fixture), read, the position is saved and restored, a highlight, a note + in the margin, the theme switch, radio and podcasts tabs load. + * Shared test vectors for the reading-position rules (`docs/progress-cases.json`), run by + the Django tests, desktop's node tests and Android's OpsTest — one contract, checked + in all four places. +2. **Split app.js** by its sections into plain scripts loaded in order (radio, focus, + ambient, crypto, epub, books, reading position, reader, tts, highlights, margin, …). They + stay classic scripts sharing globals, so the native layers that wrap `window.*` keep + working unchanged; template, sw.js SHELL and Android's build_web list them. No code + changes inside the moved functions. +3. **Dead code**: features and helpers nothing calls any more (found by the split and by + coverage of the tests), removed one at a time. +4. **Server**: podcasts/views.py into modules by area (feeds, episodes, queue, player sync, + import); shared helpers (`_require_auth`, JSON body parsing) in one place. +5. **CSS**: the remaining fixed colours to the theme tokens (docs/theme.md); app.css split + like app.js. +6. **Copies between repositories**: one sync script per direction with a check in CI that + the copy matches its source revision (today: sync_native.sh, sync_podcast_ui.py). diff --git a/e2e/env.js b/e2e/env.js index 67164b0..48fee64 100644 --- a/e2e/env.js +++ b/e2e/env.js @@ -1,6 +1,9 @@ // Shared constants between playwright.config.js and e2e/global-setup.js. -const PORT = 8000; +// E2E_PORT / E2E_PYTHON: a port that no dev server holds (the suite must never +// run against a real database), and the Python with the requirements installed. +const PORT = Number(process.env.E2E_PORT || 8000); const BASE_URL = `http://127.0.0.1:${PORT}`; +const PYTHON = process.env.E2E_PYTHON || 'python3'; const SERVER_ENV = { ...process.env, @@ -15,4 +18,4 @@ const TEST_USER = { password: 'e2e-test-pw-12345', }; -module.exports = { PORT, BASE_URL, SERVER_ENV, TEST_USER }; +module.exports = { PORT, BASE_URL, PYTHON, SERVER_ENV, TEST_USER }; diff --git a/e2e/global-setup.js b/e2e/global-setup.js index 07e636d..cb36a8a 100644 --- a/e2e/global-setup.js +++ b/e2e/global-setup.js @@ -11,7 +11,7 @@ const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const { chromium } = require('@playwright/test'); -const { BASE_URL, SERVER_ENV, TEST_USER } = require('./env'); +const { BASE_URL, PYTHON, SERVER_ENV, TEST_USER } = require('./env'); const ROOT = path.join(__dirname, '..'); const DB_PATH = path.join(ROOT, 'data', SERVER_ENV.DIORA_DB_NAME); @@ -22,14 +22,14 @@ module.exports = async function globalSetup() { // Fresh DB every run. fs.rmSync(DB_PATH, { force: true }); - execFileSync('python3', ['manage.py', 'migrate', '--noinput'], { + execFileSync(PYTHON, ['manage.py', 'migrate', '--noinput'], { cwd: ROOT, env: SERVER_ENV, stdio: 'inherit', }); execFileSync( - 'python3', + PYTHON, [ 'manage.py', 'shell', @@ -45,6 +45,8 @@ User.objects.create_user(${JSON.stringify(TEST_USER.username)}, "", ${JSON.strin ); fs.mkdirSync(AUTH_DIR, { recursive: true }); + // The book the reader tests read (e2e/make_epub.py). + execFileSync(PYTHON, [path.join(__dirname, 'make_epub.py'), path.join(AUTH_DIR, 'book.epub')], { stdio: 'inherit' }); const browser = await chromium.launch(); const page = await browser.newPage({ baseURL: BASE_URL }); diff --git a/e2e/make_epub.py b/e2e/make_epub.py new file mode 100644 index 0000000..6f9d905 --- /dev/null +++ b/e2e/make_epub.py @@ -0,0 +1,58 @@ +"""A small EPUB for the e2e tests: 12 chapters of numbered paragraphs (long enough +to scroll and to have a reading position), one footnote. Written to the path in +argv[1]; e2e/global-setup.js calls it for every run.""" +import sys +import zipfile + +CONTAINER = """ + + +""" + + +def chapter(n): + paras = [] + for p in range(1, 41): + text = f'Kapitel {n}, Absatz {p}. ' + ' '.join( + f'Wort{w} im Absatz {p} des Kapitels {n} steht hier, damit der Absatz einige Zeilen lang wird.' + for w in range(1, 6)) + if n == 1 and p == 3: + text += ' Mit Fußnote1.' + paras.append(f'

{text}

') + notes = '' + if n == 12: + notes = '' + return f""" + +Kapitel {n} +

Kapitel {n}

{''.join(paras)}{notes}""" + + +def main(path): + chapters = range(1, 13) + manifest = ''.join(f'' for n in chapters) + spine = ''.join(f'' for n in chapters) + nav_items = ''.join(f'
  • Kapitel {n}
  • ' for n in chapters) + opf = f""" + + + diora-e2e-book + E2E Testbuchdiorade + + {manifest} + {spine} +""" + nav = f""" + +Inhalt""" + with zipfile.ZipFile(path, 'w') as z: + z.writestr(zipfile.ZipInfo('mimetype'), 'application/epub+zip', compress_type=zipfile.ZIP_STORED) + z.writestr('META-INF/container.xml', CONTAINER, compress_type=zipfile.ZIP_DEFLATED) + z.writestr('OEBPS/content.opf', opf, compress_type=zipfile.ZIP_DEFLATED) + z.writestr('OEBPS/nav.xhtml', nav, compress_type=zipfile.ZIP_DEFLATED) + for n in chapters: + z.writestr(f'OEBPS/ch{n}.xhtml', chapter(n), compress_type=zipfile.ZIP_DEFLATED) + + +if __name__ == '__main__': + main(sys.argv[1]) diff --git a/e2e/reader.spec.js b/e2e/reader.spec.js new file mode 100644 index 0000000..9ea0654 --- /dev/null +++ b/e2e/reader.spec.js @@ -0,0 +1,142 @@ +// 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(); + } +}); diff --git a/playwright.config.js b/playwright.config.js index 391972b..2c6bd11 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -6,7 +6,7 @@ // migrated and seeded with a test user. const path = require('path'); const { defineConfig, devices } = require('@playwright/test'); -const { BASE_URL, SERVER_ENV } = require('./e2e/env'); +const { BASE_URL, PORT, PYTHON, SERVER_ENV } = require('./e2e/env'); module.exports = defineConfig({ testDir: './e2e', @@ -27,9 +27,10 @@ module.exports = defineConfig({ ], globalSetup: require.resolve('./e2e/global-setup.js'), webServer: { - command: 'python3 manage.py runserver 127.0.0.1:8000 --noreload', + command: `${PYTHON} manage.py runserver 127.0.0.1:${PORT} --noreload`, url: BASE_URL + '/accounts/login/', - reuseExistingServer: !process.env.CI, + // Never reuse: a server already on the port has someone's real database behind it. + reuseExistingServer: false, timeout: 30_000, cwd: path.join(__dirname), env: SERVER_ENV,