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'
{''.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'