readest/apps/readest-app/docs/testing.md
Huang Xin 2813ad7e6a
fix: iOS reader polish for drawing dock, short sheets, bookshelf header and read aloud (#6778)
* 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>
2026-10-14 00:25:00 +02:00

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.ts and *.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 a VIEW intent 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__.invoke is defined non-writable and non-configurable, so assigning over it from eval fails 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.Nautilus on 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, Return to select, Return to open) — or skip the picker and dispatch the app's own import-book-files event.
  • Passing --remote-debugging-port on argv does not work. The CEF runtime keeps Chromium's remote debugging switched off unless the app asks for it, which it does only for READEST_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)