Skip to content

Testing

E2E Tests (Playwright)

185 spec test bodies across 19 spec files in tests/e2e/, running sequentially (1 worker, shared database). The setup projects add 3 executions, and prod-config.spec.ts (4 tests) runs only in its own CI job, so a complete ordinary run is 184 tests. Regenerate the exact counts with vp exec playwright test --list (run from tests/e2e/) rather than hand-counting.

Test Files

FileTestsTagsCoverage
account.spec.ts20@smoke (4)Profile, password changes, session and device management, account access
admin-authority.spec.ts4Routes whose admin authority lives inside the handler, refused to app passwords and served to the same person's session
auth.spec.ts49mostly @smokeSign-in, sessions, authorization, app passwords, OPDS authentication, user management
book-progress.spec.ts3@smokeMulti-device progress, empty state, finished badge
command-palette.spec.ts3Global search modal, navigation, book results
errors.spec.ts5@smoke / @slowError toasts, conflict handling, network failures, and duplicate file detection
first-run-setup.spec.ts2— (see below)Empty-install setup and first-admin creation. Cannot be tagged — its project depends on chromium, and dependency projects ignore --grep
hardcover.spec.ts10@smokeToken CRUD, status, sync button/log, feature toggles, persistence
home.spec.ts6@smokeDashboard stats, currently reading, recently added, wide-screen card constraints
inbox.spec.ts16@smokeList, search, pagination, empty state, metadata picker, approve, delete
ingestion.spec.ts1@slow @externalFull EPUB pipeline: detect → parse → review → approve → library
library.spec.ts18@smokeGrid/list view, filters, search, pagination, detail page, covers, downloads
isolation.spec.ts10@smoke (6)Ownership controls, per-user progress and stats, credential persistence, cache and upload isolation
prod-config.spec.ts4prod-configProduction-config install: first-run setup, sign-in, sign-out, session revocation. Only runs in the e2e-prod-config CI job
opds.spec.ts2@smokeReal-filesystem cover and ebook streaming through the live server
reading-status.spec.ts8@smokeSidebar links, status tabs, empty state, wide-screen cards, plus a parametrized loop covering the reading/finished/unread/paused tabs
settings.spec.ts8@smoke (4)Health & diagnostics (@smoke); jobs browser and queue management untagged
stats.spec.ts7@smokeBooks finished, streaks, daily activity, genre distribution
websocket-events.spec.ts9@smoke (3)Realtime event bus over WebSocket — job status, pipeline events, per-user scoping, and socket teardown when a session is revoked

Note: Feed structure, search, language filtering, authentication, and content-type contracts live in services/api-hono/src/routes/opds.test.ts. opds.spec.ts retains only the two cases that require the configured library directory and a running server: cover and ebook streaming.

Tags

TagMeaning
@smokeCritical paths — runs on PRs via --grep @smoke
@slowFile processing or queue waits
@externalExercises a boundary normally backed by an external service or filesystem event
untaggedRuns in the complete suite but not the PR smoke selection

Untagged specs (command palette, jobs browser, most WebSocket cases) validate behavior that doesn't need to block every PR but must pass on main.

@smoke is the PR gate's entire definition of "covered". The e2e job runs --grep @smoke on pull requests and the full suite only on pushes to main, so an untagged spec first executes after the merge that broke it. That is not a theoretical cost: the auth work on this branch initially left account.spec.ts, isolation.spec.ts, websocket-events.spec.ts and every auth.spec.ts block except sign-in untagged, which meant app passwords, app-password scoping, OPDS Basic auth, the admin user-management walk and the last-admin 409 were not gating anything.

The rule now: anything that pins an authentication, authorization, ownership or session invariant carries @smoke. Concretely that is the sign-in, post-sign-in redirect, session, authorization, app-password, app-password-scope, OPDS and user-management blocks of auth.spec.ts; the ownership, per-user stats, sign-out cache and upload-collision blocks of isolation.spec.ts; the WebSocket per-user event scoping, identity-switch and ban-severs-the-socket cases; and the password-change, session-token-leak, device-revocation and non-admin-access cases in account.spec.ts. Presentation and convenience coverage stays untagged so the gate stays a gate. That takes the PR selection from 89 tests to 139.

first-run-setup.spec.ts cannot be tagged

Its first-run project declares dependencies: ["chromium"] so it sorts last — it wipes every account, so nothing may follow it. Playwright does not apply --grep to a dependency project: it runs the whole thing. Tagging anything in that file therefore drags the entire chromium project into the PR run (139 tests becomes all 184) and the gate silently stops being a gate.

First-run setup runs on main only. If you want it on PRs, the honest change is to run the full suite on PRs, not to tag the file.

What CI actually exercises

Green does not mean "every configuration works" — it means the configurations below were tried.

Config branchExercised byNotes
NODE_ENV=development + E2E_TEST=1, plain HTTPe2e job (3 shards), ./scripts/test-e2e.shThe main suite. Dev trustedOrigins, in-memory KV and secondary storage, /__test/* mounted, Better Auth rate limiting relaxed.
NODE_ENV=production, no E2E_TEST, plain HTTPe2e-prod-config job (prod-config.spec.ts)Empty trustedOrigins resolved from a required BETTER_AUTH_URL, Redis-backed KV and secondary storage, no support routes, Pino logging.
NODE_ENV=testvp run test (Vitest, PGlite)Unit and integration only — never boots a real server.
Real HTTPS / a TLS-terminating reverse proxyNothingLIBRIS_COOKIE_SECURE=1, Secure cookies, TRUST_PROXY_HEADERS=1 and forwarded-header handling are covered by unit tests at best.
NODE_ENV=development without E2E_TESTNothingThe interactive dev path, including the pretty-terminal logger transport.

The last two rows are the standing gap. Read them before concluding that a green matrix clears a deployment change.

Running Tests

Playwright global setup waits for the API, clears BullMQ queue history from Redis, resets the database, bootstraps the admin, creates a regular user, signs both in, and issues each an app password through POST /api/app-passwords. Their sessions and app passwords are exposed to the specs through the E2E helper environment so tests can deliberately exercise either cookie or header authentication.

Docker mode runs backing services from docker-compose.test.yml on non-default ports to avoid colliding with a local stack: Postgres on 5433 and Redis on 6380.

The Playwright image ships a Node older than this workspace's engines.node floor, so the playwright service's entrypoint installs the version pinned in .node-version via n before running pnpm install. It lands in the playwright-node-toolchain volume, so only the first run pays the download. CI reaches the same state differently — its Playwright container runs setup-vp with node-version-file. Bump .node-version and both paths follow; no compose edit needed.

Docker mode (recommended):

bash
./scripts/test-e2e.sh                       # all tests
./scripts/test-e2e.sh --grep @smoke         # smoke only
./scripts/test-e2e.sh tests/e2e/auth.spec.ts  # single file

Dev-server mode (faster iteration):

bash
docker compose -f docker-compose.test.yml up -d --wait
cp .env.test.example .env.test
vp run -F @libris/e2e e2e                          # all tests
cd tests/e2e && vp exec playwright test --grep @smoke   # smoke only
cd tests/e2e && vp exec playwright test --ui            # interactive Playwright UI

Infrastructure

FilePurpose
playwright.config.tsConfig: 1 worker, retries in CI, reporters, webServer auto-start, two setup projects. E2E_PROD_CONFIG=1 collapses it to the single prod-config project
global-setup.tsWaits for API health, resets DB (delete all rows), seeds admin + regular-user keys. Stops after the reset under E2E_PROD_CONFIG=1 — that run has no support routes to seed through
auth.setup.tssetup project — logs in the admin key via API, saves session to .auth/user.json
user-auth.setup.tsuser-setup project — logs in the non-admin E2E_USER_API_KEY, saves .auth/regular-user.json for multi-user tests
fixtures.tsCustom fixtures: authedPage (pre-authenticated page)
helpers/index.tsseedOrganizedBook(), deleteAllBooks(), waitForJob(), goPath(), etc.
helpers/resolve-urls.tsrequireDatabaseUrl() / requireRedisUrl() — DB/Redis URL resolution used by global-setup.ts

Database Seeding

Tests seed data via direct PostgreSQL queries (not API calls), using the postgres client from helpers/index.ts. Each spec's beforeEach cleans up its own data.

Debugging

  • Screenshots/videos/traces on failure: tests/e2e/test-results/
  • HTML report: tests/e2e/playwright-report/index.html
  • Debug mode: PWDEBUG=1 ./scripts/test-e2e.sh -g "test name"

API Exploration (Bruno)

The ignored bruno/ directory contains a Bruno API collection auto-generated from the OpenAPI spec. Generate it locally with vp run bruno:import before using it for manual testing and exploration.

Opening the Collection

GUI: Open Bruno → Open Collection → select the bruno/ folder → pick the Local environment from the top-right dropdown.

CLI:

bash
bru run --env Local bruno/            # Run all requests
bru run --env Local bruno/library/    # Run a specific folder
bru run --env Local bruno/health/     # Quick health check

Environments

EnvironmentFileBase URL
Localbruno/environments/Local.ymlhttp://localhost:3000

Requests use {{baseUrl}} for the server and inherit collection-level Bearer authentication from {{apiKey}}. Set apiKey to an app password issued from Settings → Connections; browser session cookies are not needed for Bruno.

Regenerating the Collection

When API routes change, regenerate the collection from the OpenAPI spec:

bash
vp run bruno:import

This runs scripts/bruno-import.sh which:

  1. Starts the Hono dev server if not already running (DB/Redis not required)
  2. Fetches the OpenAPI spec from /_docs/openapi.json
  3. Imports into bruno/ grouped by OpenAPI tags
  4. Removes internal/test endpoints
  5. Preserves existing environment files
  6. Configures inherited Authorization: Bearer authentication

The generated collection is not committed. Run vp run bruno:import after cloning and whenever the API routes change.

Structure

Requests are organized by OpenAPI tag into folders:

FolderEndpoints
auth/First-run setup status and bootstrap (/api/setup)
app-passwords/Mint, list, and revoke app passwords
books/Approve metadata, delete, get candidates
credentials/Service credential management
dashboard/Dashboard data
hardcover/Sync status, trigger sync, sync log
health/Health check
inbox/List, get, upload, rescan, cover images, status
jobs/Queue status, failed jobs, retry
library/List, get, update, covers, downloads, refetch, reorg
Reading_Status/Status counts, filtered lists
search/Command palette suggestions
settings/Get/update settings
stats/Reading statistics

Unit Tests (Vitest)

  • API service config: services/api-hono/vite.config.ts (test block — PGlite in-memory DB, mocked env)
  • Web app config: apps/web/vite.config.ts (vue()-only plugin set under process.env.VITEST so shallowMount sees raw .u-badge HTML instead of stubbed components)
  • Run: vp run test (root aggregator, recursive across workspaces) or vp run -F @libris/api-hono test (API only)

Test config lives in each workspace's vite.config.ts test block, per Vite+ guidance — there are no vitest.config.ts files.

A test that cannot fail is worse than no test

It gets counted as coverage. An adversarial review of the multi-user auth and security-hardening work found several issues closed on the strength of tests that were structurally incapable of failing; a follow-up pass replaced them and left these rules behind.

The bar for a new test is that you watched it go red. Break the code it claims to cover — delete the lock, drop the WHERE, remove the guard — and confirm it fails. If it stays green it does not test that thing. Name the mutation you used in the commit message.

Shapes to refuse:

ShapeWhy it cannot fail
Asserting on the echoed request body a handler sent backA handler echoing its input satisfies it with the write deleted. Re-read the persisted row.
Inserting rows yourself, then SELECTing them back with the same filterNo src/ code runs — it asserts that PostgreSQL honours a WHERE clause. Drive the endpoint.
A concurrency test on PGlite or an in-memory MapOne connection / one JS turn. A row lock cannot contend and a Map write cannot be lost.
expect(x).toBeDefined() where x cannot be undefinedThe function returns an object on every path.
String-matching source text (a Lua script against /INCR/)Pins the text, not the behaviour. Any rewrite keeping the substring passes.
expect(status).not.toBe(200)Satisfied by 500 and 429 — cannot tell "refused" from "crashed".
expect(rows.length).toBeGreaterThanOrEqual(n) where exactly n existCannot notice a listing that leaked someone else's rows.
A negative control produced by omitting a wrapper, middleware or flagOnly meaningful while that thing is genuinely optional. Once the wiring moves into createApp, the flag disables nothing and the "proves the bug" test silently becomes decoration. Re-check these whenever wiring moves.

A test that only pins the schema is legitimate — name it SCHEMA: so a close-reason cannot cite it as endpoint coverage. src/db/db.test.ts and the reading-progress block of tests/auth-access-control.test.ts follow that convention.

Tests that need a real PostgreSQL or Redis

Most of the suite runs on PGlite, which is one embedded backend on one connection. Anything whose subject is concurrency, or whose subject is the production driver's own behaviour, cannot run there:

FileNeedsWhy PGlite will not do
tests/last-admin-lock.postgres.test.tsPostgreSQLSELECT ... FOR UPDATE can only block a different session, so on one connection the lock never contends.
tests/admin-subtree-http.postgres.test.tsPostgreSQLlastAdminMiddleware holds its transaction open across next(), and Better Auth's write inside next() needs a second connection. PGlite has one, behind an exclusive mutex, so the request deadlocks.
tests/reading-status-isolation.postgres.test.tsPostgreSQL/api/reading-status/* reads its rows out of a raw db.execute(). postgres-js's array-like RowList is the shape rowsOf() has to get right for production; PGlite only ever exercises the { rows } half.
tests/redis-increment.test.tsRediscreateMemoryKVStore.increment is a synchronous Map write that cannot lose an update, so it passes against a broken production path.

tests/backing-services.ts resolves the connections and creates a uniquely named throwaway database per suite (dropped afterwards), so several checkouts can share one server.

VariableDefault
LIBRIS_TEST_POSTGRES_URLpostgres://libris_test:libris_test@localhost:5433/libris_test
LIBRIS_TEST_REDIS_URLredis://localhost:6380

Both defaults match docker-compose.test.yml, so a full local run is:

bash
docker compose -f docker-compose.test.yml up -d --wait postgres redis
vp run -F @libris/api-hono test

Without them those suites skip loudly, printing [SKIPPED — NOT COVERED] and how to start the service. When CI is set they fail instead of skipping — a silently skipped concurrency test is the exact failure mode this convention exists to remove. The CI test job therefore runs postgres:17 and redis:7 service containers and points both variables at them.

API Unit/Integration Test Files

Paths relative to services/api-hono/. Test DB uses in-memory PGlite with mocked BullMQ queues.

FileCoverage
src/db/db.test.tsDatabase schema, migrations, and query helpers
src/db/rows.test.tsrowsOf() / rowCount(): the postgres-js vs PGlite db.execute() result shapes
src/env.test.tsEnvironment variable parsing (Redis URL, required vars, defaults)
src/lib/epub/embed-metadata.test.tsEPUB metadata embedding (OPF rewriting)
src/lib/hardcover/client.test.tsHardcover GraphQL client (request shaping, response mapping)
src/lib/hardcover/matching.test.tsHardcover ISBN / title matching for sync linkage
src/lib/hardcover/pull-status.test.tsPulling existing Hardcover reading statuses (DNF → paused mapping)
src/lib/languages.test.tsCanonical ISO 639-1 language normalization (aliases, BCP-47, 639-2/3)
src/lib/metadata/clients/metadata-clients.test.tsExternal metadata API clients (MSW mocked)
src/lib/metadata/detect-language.test.tsContent-based language detection (tinyld)
src/lib/metadata/extractors/epub.test.tsEPUB metadata extraction (OPF parsing, cover detection)
src/lib/metadata/sanitize.test.tsHTML stripping and metadata field sanitization
src/lib/progress-aggregate.test.tsPer-device reading-progress aggregation
src/lib/reading-aggregate.test.tsPer-(user, book) reading aggregate derivation
src/lib/reading-status.test.tsReading status derivation from KoSync progress
src/lib/socket-guard.test.tsWebSocket connection auth guard
src/middleware/rate-limit.test.tsPer-IP tiered rate limiting (auth / keyCreation / general)
src/routes/api/books.test.ts/api/books/* approve, delete, candidates (integration)
src/routes/api/dashboard.test.ts/api/dashboard owner scoping of inbox count, byte volume and pipeline
src/routes/api/events.test.ts/api/events upgrade guard, connection cap, and closing sockets on revocation
src/routes/api/hardcover.test.ts/api/hardcover/* search, sync status, trigger, log (integration)
src/routes/api/inbox.test.ts/api/inbox/* list, detail, approve, delete (integration)
src/routes/api/library.test.ts/api/library/* list, detail, covers, downloads (integration)
src/routes/api/settings.test.ts/api/settings/* get/update including combined status endpoint
src/routes/opds.test.tsOPDS feed endpoints (integration, Hono test client + PGlite)
src/services/cache.test.tsRoute cache invalidation degrading, deferring and retrying when the KV store is down
src/services/cache-invalidation.test.tsA real mutation evicts the cached OPDS/stats entry, and cached mounts still match invalidated prefixes
src/services/queue-diagnostics.test.tsBullMQ aggregation for home/settings diagnostics
src/services/rate-limit.test.tsTier limits, window anchoring, and the in-memory fallback used when Redis is down
src/services/settings.test.tsApp settings service CRUD
src/shared/checksum.test.tsFile checksum helpers used by the ingestion pipeline
src/shared/kosync-auth.test.tsKoSync header-based auth (x-auth-user / x-auth-key)
src/shared/request-ip.test.tsTrusted-proxy chain validation, IPv6 /64 buckets, and auth IP injection
src/shared/route-policy.test.tsRoute auth policy lookup table (public/api-key/admin/opds/kosync)
src/workers/book-detected.test.tsBOOK_DETECTED worker: checksum, format detect, dedup
src/workers/book-fetch-metadata.test.tsBOOK_FETCH_METADATA worker: Hardcover lookup, promote to review
src/workers/book-parse-file.test.tsBOOK_PARSE_FILE worker: metadata extraction orchestration
src/workers/cleanup-orphaned-files.test.tsScheduled orphan-file cleanup worker
tests/last-admin-lock.postgres.test.tsThe last-admin row lock under real contention (needs PostgreSQL)
tests/admin-subtree-http.postgres.test.ts/api/auth/admin/* over HTTP: last-admin 409s and remove-user book reassignment (needs PostgreSQL)
tests/user-deletion-atomicity.postgres.test.tsWhy remove-user's book reassignment must commit outside the last-admin transaction (needs PostgreSQL)
tests/reading-status.test.tsPer-user /api/reading-status/counts and /{status} over HTTP
tests/reading-status-isolation.postgres.test.tsThe same two endpoints on the postgres-js driver's result shape (needs PostgreSQL)
tests/redis-increment.test.tsAtomicity of both rate-limit increments (needs Redis)

Web Unit Test Files

Paths relative to apps/web/. There is no vitest.config.ts; config lives in the apps/web/vite.config.ts test block, which applies the vue() plugin only under process.env.VITEST (so shallowMount sees raw HTML instead of stubbed components).

FileCoverage
src/components/MetadataFieldPicker.test.tsField-by-field candidate picker logic and validation

When reproducing queue/admin diagnostics issues against local dev servers, clear Redis queue history as well as Postgres state:

bash
vp run -F @libris/api-hono reset:bullmq

That command deletes only Libris BullMQ keys, which keeps Home and Settings queue diagnostics aligned with a fresh local reset.

CI Pipeline

See ci-cd.md for the full CI workflow. Summary:

  • PRs: @smoke E2E tests (3 shards), plus the whole e2e-prod-config job
  • main push: Full E2E suite, plus e2e-prod-config
  • Results are surfaced by GitHub's native checks UI. A failure uploads the Playwright report, the traces, and the API server log.