Refactoring Schritt 1: Browser-Tests für den Reader, in der CI; Plan in docs/refactoring.md
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Has been cancelled

- 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>
This commit is contained in:
mrwnslz 2026-10-14 18:25:06 +02:00
parent fd8f20ba3a
commit ffcb6fba4f
7 changed files with 294 additions and 8 deletions

View file

@ -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

52
docs/refactoring.md Normal file
View file

@ -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).

View file

@ -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 };

View file

@ -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 });

58
e2e/make_epub.py Normal file
View file

@ -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 = """<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</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ßnote<a href="ch12.xhtml#fn1" epub:type="noteref"><sup>1</sup></a>.'
paras.append(f'<p id="c{n}p{p}">{text}</p>')
notes = ''
if n == 12:
notes = '<aside epub:type="footnote" id="fn1"><p>1 Der Text der Fußnote.</p></aside>'
return f"""<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Kapitel {n}</title></head>
<body><h1>Kapitel {n}</h1>{''.join(paras)}{notes}</body></html>"""
def main(path):
chapters = range(1, 13)
manifest = ''.join(f'<item id="c{n}" href="ch{n}.xhtml" media-type="application/xhtml+xml"/>' for n in chapters)
spine = ''.join(f'<itemref idref="c{n}"/>' for n in chapters)
nav_items = ''.join(f'<li><a href="ch{n}.xhtml">Kapitel {n}</a></li>' for n in chapters)
opf = f"""<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="id">diora-e2e-book</dc:identifier>
<dc:title>E2E Testbuch</dc:title><dc:creator>diora</dc:creator><dc:language>de</dc:language>
</metadata>
<manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>{manifest}</manifest>
<spine>{spine}</spine>
</package>"""
nav = f"""<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Inhalt</title></head><body><nav epub:type="toc"><ol>{nav_items}</ol></nav></body></html>"""
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])

142
e2e/reader.spec.js Normal file
View file

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

View file

@ -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,