* fix(reader): fill the drawing dock's bottom safe area with its own background The dock was lifted above the home indicator by the bottom inset, leaving a transparent gap where the reader footer showed through. It now sits at the bottom edge and pads the inset with its own background. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(library): title the bookshelf editor with the selected shelf on phones On phones the selected shelf's name becomes the page title beside the back button and the tabs, icons only, get a centered row of their own, so the built-in shelves and the add button fit on one line. Wrapped rows are now centered on the screen rather than within the slot beside the back button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tts): keep a hidden mini player hidden through brief stalls between paragraphs The buffering probe catches the short wait while the next paragraph loads, which re-showed the card and re-armed its linger, and the end of buffering showed it again. A hidden card now comes back only when a stall lasts 500ms, and the end of buffering just restarts the countdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): only full-height sheets clear the status bar and show a back button A content-height sheet such as a confirmation, or a sheet snapped short of the screen, rests at the bottom: it no longer pads its drag handle for the status bar or shows the back button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tts): stop the word highlight stepping back at a sentence start on iOS chunk-start fires as the item is handed to AVPlayer, which can take a few hundred ms to open it. The media clock ran ahead from the event and lit the second word, then the next poll pulled it back to the first. The clock now holds until a poll sees the item playing, and the position reports playing from timeControlStatus rather than the rate, which is set while loading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tts): preload the next chapter's opening for Edge TTS and custom voices Only windowed engines carried preloads past a chapter end, so with Edge TTS or a custom service a chapter change started with nothing synthesized. A new preloadsAudio capability marks engines whose preloads keep the audio; they now fill their usual handful of paragraphs from the next chapter too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: run selected unit test files with pnpm test --run pnpm test -- <files> forwards the separator and runs more than the files asked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(agent): update agent memories Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tts): keep the iOS word clock from stepping back on pause or running ahead on resume Pausing froze the clock at the last poll, up to 250ms behind where it had got, so the highlight could step back a word. Resuming restarted it before AVPlayer was rendering again. Pause now freezes the extrapolated position, and resume polls and lets the player report when it is playing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tts): preload the next chunk's opening at the end of a chunk A section over 512 KB of HTML is read in chunks, and the live document ends at each chunk's end. The cross-boundary preload took that for the chapter's end and preloaded the next section, so moving to the next chunk found nothing synthesized. It now starts a throwaway reader at the next chunk's anchor in a fresh copy of the section, and only goes on to the next section after the last chunk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ui): drag a content-height sheet up instead of setting its height Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tts): use the contrast button for play/pause in the full player Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tts): preload the next section in the language it is read in, and more - A section file with only xml:lang is rendered with the book's language as its lang, which is what the live read speaks it in. The fresh copy used to preload the next section kept xml:lang (zh-CN vs zh), so its audio was cached under keys playback never looked up and each new section started with a wait. The fresh copy now takes the book's language the same way. - E-ink drops the tinted sentence under the word highlight in word mode. - The lyric sheet follows the document read, not only the section index, so a section read in chunks no longer shows the first chunk's lines under the next chunk's position. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(tts): resize the full player on desktop and size the lyric text - A corner grip resizes the full Read Aloud player with the pointer on desktop, up to the whole window, and the size is kept for the next time it opens. Once sized, the lyric sheet takes the height it is given. - A−/A+ in the lyric view step the line size from 12 to 38px, kept per device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tts): keep the lyric size and player size working when storage is blocked Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
10 KiB
Testing
Readest uses several test tiers. Unit, browser, Tauri integration, and Android device tests use Vitest; full Tauri UI E2E uses WebDriverIO with Mocha.
Unit Tests (pnpm test)
Runs tests in a jsdom environment. No browser or Tauri runtime required.
pnpm test # Run all unit tests
pnpm test --run src/__tests__/utils/misc.test.ts # Run only these files (no `--`)
pnpm test --watch # Watch mode
- Config:
vitest.config.mts - Pattern:
src/**/*.test.ts(excludes*.browser.test.tsand*.tauri.test.ts) - Environment: jsdom
- Use for: Pure logic, utilities, services that don't need real browser APIs or Tauri IPC.
Browser Tests (pnpm test:browser)
Runs tests in a real Chromium browser via Playwright. Required for code that depends on Web Workers, SharedArrayBuffer, OPFS, or other browser-only APIs.
pnpm test:browser
- Config:
vitest.browser.config.mts - Pattern:
src/**/*.browser.test.ts - Browser: Chromium (headless, via
@vitest/browser-playwright) - Use for: WASM modules (e.g.
@tursodatabase/database-wasm), Web Worker integration such as bundled dictionary plugins, and browser-specific storage APIs.
Tauri Integration Tests (pnpm test:tauri)
Runs Vitest tests inside the Tauri WebView, with access to Tauri IPC and native plugin commands. Tests execute in the actual app environment.
Step 1: Start the Tauri App
In one terminal, start the app with the webdriver feature enabled:
pnpm tauri:dev:test # Dev mode (uses tauri dev server, faster iteration)
pnpm tauri:build:test # Debug release build (closer to production)
These commands compile the Rust backend with --features webdriver, which:
- Includes
tauri-plugin-webdriver(embeds a W3C WebDriver server on port 4445) - Adds a runtime capability granting plugin permissions to remote URLs (
http://127.0.0.1:*), so Vitest's browser-mode iframe can call Tauri IPC
Keep this running while you run tests.
Step 2: Run Tests
In another terminal:
pnpm test:tauri
Vitest connects directly to the embedded WebDriver server (port 4445) in the running Tauri app and executes tests inside its WebView.
- Config:
vitest.tauri.config.mts - Pattern:
src/**/*.tauri.test.ts - Browser provider:
@vitest/browser-webdriverio(connects to port 4445) - Use for: Tauri plugin commands (turso, native-tts, etc.), native filesystem, Tauri IPC.
Writing Tauri Tests
Tests access Tauri IPC via a shared helper:
import { invoke } from '../tauri/tauri-invoke';
it('calls a plugin command', async () => {
const result = await invoke('plugin:turso|load', { options: { path: 'sqlite::memory:' } });
expect(result).toBeDefined();
});
The invoke() helper accesses window.top.__TAURI_INTERNALS__ (Vitest runs in an iframe, Tauri injects IPC into the main frame).
Limitations: Only custom invoke commands and plugin commands listed in the webdriver capability work. Standard Tauri JS APIs (e.g. @tauri-apps/api) that rely on URL: local may not work from the Vitest iframe.
Android Device E2E (pnpm test:android)
Drives the installed Readest app on an adb-connected Android device or
emulator: gestures are injected with adb shell input, and the app's state is
probed through the WebView's Chrome DevTools Protocol (forwarded from the
webview_devtools_remote_<pid> abstract socket). This is the only lane that
exercises real Android touch selection, native handle behavior, and page-turn
gestures (e.g. the issue #1553 hyphen-selection fixes).
# One-time: install a dev build on the device/emulator
pnpm dev-android
# Start an emulator if no device is attached (see `emulator -list-avds`)
emulator -avd Pixel_9_Pro &
# Run the lane (soft-skips when no adb/device/app is available)
pnpm test:android
# With several devices attached, pick one:
ANDROID_SERIAL=emulator-5554 pnpm test:android
- Config:
vitest.android.config.mts(node environment, serial execution,retry: 1) - Pattern:
src/**/*.android.test.ts - Helpers:
src/__tests__/android/helpers/—adb.ts(gestures),cdp.ts(DevTools client),reader.ts(app-level probes) - Fixtures: plain EPUBs from
src/__tests__/fixtures/data/(e.g.sample-alice.epub), opened transiently via aVIEWintent so the device library is never modified - Use for: native text selection, touch gestures, selection handles, anything that only reproduces in the Android WebView compositor.
Conventions
- Probe, don't hardcode: locate words/handles at runtime via CDP and derive
device pixels from
devicePixelRatio— never bake in coordinates. - Poll, don't sleep: use
waitFor()on an observable condition (selection state, handle count, frame position); reserve fixed pauses for gesture pacing (long-press hold, corner dwell). - Discover, don't assume: the harness finds a hyphenated on-screen paragraph at runtime and derives every gesture target from live layout, so any English fixture works regardless of fonts or screen size (hyphenation is on by default in the app).
- Serial only: one device, one app — the config disables parallelism.
CI
.github/workflows/android-e2e.yml runs the lane on an x86_64 emulator
(ubuntu runner with KVM): it builds a debug APK for x86_64 (no signing
secrets needed), boots a cached AVD via reactivecircus/android-emulator-runner,
installs the APK, and runs pnpm test:android. It is intentionally not
PR-blocking — it runs nightly, on workflow_dispatch, or when a PR gets the
e2e-android label.
E2E Tests (WDIO)
Full end-to-end tests using WebDriverIO, for UI-level testing against the running Tauri app. Same two-step workflow as Tauri integration tests.
# Terminal 1: start the app (same as for Tauri integration tests)
pnpm tauri:dev:test
# Terminal 2: run E2E tests
pnpm test:e2e
- Config:
wdio.conf.ts - Pattern:
e2e/**/*.e2e.ts - Framework: Mocha (via
@wdio/mocha-framework) - Connects to: port 4445 (embedded WebDriver server)
- Use for: UI interaction tests, window management, navigation flows.
Debugging the Linux CEF Build (CDP)
The Linux CEF runtime is Chromium, so the app window is a normal Chrome DevTools
Protocol target — the same interface Chrome, Playwright and Puppeteer speak. This
is the only way to inspect the running desktop app on Linux (WebKitWebDriver, used
by pnpm tauri:dev:test, drives the wry runtime instead).
# Terminal 1: dev build with the CDP endpoint on 127.0.0.1:9222
pnpm tauri:dev:cdp
# Terminal 2
pnpm cdp targets # list debuggable targets
pnpm cdp eval 'document.title' # run JS in the app window
pnpm cdp eval 'window.__TAURI_INTERNALS__.invoke("plugin:app|version")'
pnpm cdp click 'button[aria-label="Import Books"]' # real pointer events
pnpm cdp screenshot /tmp/library.png
pnpm cdp logs 30 # tail the app console
pnpm tauri:dev:cdp is pnpm tauri dev with READEST_CDP_PORT=9222. Any CEF
build honours that variable — a packaged .deb, the Flatpak, or tauri build
output — so the same tooling works against release binaries:
READEST_CDP_PORT=9222 readest
CDP_PORT=9222 pnpm cdp targets
scripts/cdp.mjs is dependency-free (node's global fetch and WebSocket); the
endpoint listens on loopback only. For anything richer, connect a real client to
the same port — chrome://inspect in a local Chrome, or
chromium.connectOverCDP('http://127.0.0.1:9222').
Limits
- The IPC bridge cannot be stubbed.
window.__TAURI_INTERNALS__.invokeis defined non-writable and non-configurable, so assigning over it fromevalfails silently and the real command still runs. You can call it, not fake it. - Native dialogs are outside the page. The file picker is an XDG portal
window (
org.gnome.Nautiluson GNOME), not a CEF surface, so CDP cannot see or click it. Driving an import end-to-end needs X11 input (XTEST via python-xlib: focus the chooser,Ctrl+L, type the path,Returnto select,Returnto open) — or skip the picker and dispatch the app's ownimport-book-filesevent. - Passing
--remote-debugging-porton argv does not work. The CEF runtime keeps Chromium's remote debugging switched off unless the app asks for it, which it does only forREADEST_CDP_PORT; Chromium logs "DevTools remote debugging is disallowed by the system admin" for a stray switch.
crengine XPointer Oracle (KOReader sync)
KOReader stores positions as crengine XPointers; src/utils/xcfi.ts converts them to and from
CFIs. The only trustworthy reference for what crengine emits is crengine itself, so
apps/readest.koplugin/scripts/xpointer-oracle.lua runs headlessly inside a KOReader emulator
build and dumps crengine's XPointer pair and text for every visible word of an EPUB:
cd <koreader-emulator>/koreader
KO_HOME=/tmp/ko-oracle ./luajit /path/to/readest/apps/readest.koplugin/scripts/xpointer-oracle.lua \
/path/to/book.epub /path/to/book.crengine.json 40 # keep one word in 40
src/__tests__/utils/xcfi.crengine-oracle.test.ts then checks both sync directions for every
sampled word: crengine's pointers must resolve to exactly that word, and a highlight on that word
must convert back to exactly crengine's pointers. Committed fixtures live in
src/__tests__/fixtures/crengine/ (generated from EPUBs under fixtures/data/). To validate a
local book that cannot be committed:
XPOINTER_ORACLE=/path/to/book.crengine.json pnpm test src/__tests__/utils/xcfi.crengine-oracle.test.ts
The JSON's epub field is an absolute path or a file next to the JSON. The emulator is a local
build (kodev in a KOReader checkout); nothing in CI needs it.
Test File Naming
| Suffix | Runner | Environment |
|---|---|---|
*.test.ts |
pnpm test |
jsdom |
*.browser.test.ts |
pnpm test:browser |
Chromium (Playwright) |
*.tauri.test.ts |
pnpm test:tauri |
Tauri WebView |
*.e2e.ts |
pnpm test:e2e |
Tauri app (WDIO) |
*.android.test.ts |
pnpm test:android |
Android device (CDP) |