Repository navigation
Frontend Unit Tests
This page covers the frontend unit tests: the Vitest suite that runs ALPS's Lit components, stores and plugin frontends under jsdom. It explains how to run the suite, where tests go (plugin tests included), the helpers and shims every test file gets, what is typechecked, which tests are slow, and how to run the suite in a random order. Testing Overview shows how the three test tiers fit together, and the reference is the Frontend unit tests section of docs/E2E.md.
The suite checks logic and render decisions: what a component shows for a given server answer, which request it sends, which event it fires, how a store reacts to a refusal. It runs under jsdom rather than a browser, so it does not see layout or paint (frontend/vitest.config.ts). No server runs: tests stub fetch (and, in the few components that use them, XMLHttpRequest or EventSource) or replace a plugin's service module. When something only fails against a real IMAP server, a real SMTP submission and a real browser together, the Browser Tests cover it.
The repository has no CI, so nothing runs these tests unless you do. make test runs only the Go Tests, and no Makefile target runs Vitest. The README asks contributors to run the Go tests and the frontend unit tests before sending a change.
- Node.js and npm. Use Node.js 20 (20.19 or later), 22 (22.12 or later), or 24 and later. The locked Vite 8.0.10 declares
^20.19.0 || >=22.12.0, and the locked Vitest 4.1.10 declares^20.0.0 || ^22.0.0 || >=24.0.0, so on Node.js 23 npm prints an engine warning. - The packages from
frontend/package-lock.json. The locked versions are Vitest 4.1.10, jsdom 27.0.1, TypeScript 6.0.3 and Vite 8.0.10.
The suite needs no Go toolchain, sora, PostgreSQL or browser. It does need bash and a POSIX /bin/sh, because e2e-harness.test.ts runs scripts/e2e.sh with stand-in programs in place of go, psql, curl, lsof, npm and npx (see Where the tests live).
cd frontend
npm install # or npm ci, which installs exactly what package-lock.json lists
npm test # typechecks the tests, then runs `vitest run`: every test file oncenpm test runs npm run typecheck:test first (npm's pretest script), and Vitest starts only when the typecheck passes (see Typechecking). Run Vitest from frontend/, where vitest.config.ts lives. To run part of the suite:
npm test -- test/sieve-compiler.test.ts # one file, after the typecheck of all tests
npm test -- sieve # every test file whose path contains "sieve"
npx vitest run -t "Reply All" # tests whose full name matches the pattern, no typecheck
npx vitest --watch # stays running and re-runs tests when files change, no typecheck
npm run test:shuffle # typechecks the tests, then runs the suite in a random orderArguments after -- go to Vitest only. The typecheck always covers the whole of frontend/test.
At main d1cc02cc (November 2026) the suite had 242 test files and 3,708 tests, and npm test took about 30 seconds on a laptop, about 4 of them for the typecheck. Main 92ec0e11 has 250 test files. Vitest runs files in parallel. With --no-file-parallelism the suite took about three minutes at a5443047.
A passing run can print a few Not implemented: navigation to another Document lines, most of them from test/mailto.test.ts. They are jsdom notices, not failures. The Test Files and Tests summary lines and the exit status show the result.
npm test does not rebuild frontend/dist. In the checkout it writes only Vitest's cache, under frontend/node_modules/.vite/vitest. e2e-harness.test.ts works in a temporary directory of its own and removes it when it finishes. npm run build does rewrite frontend/dist (see Typechecking).
frontend/vitest.config.ts sets:
environment: 'jsdom'include: ['test/**/*.test.ts']setupFiles: ['test/helpers/setup.ts']
Vitest collects only frontend/test/**/*.test.ts. A *.test.ts placed next to a source in frontend/src/ or plugins/*/frontend/ is never run. A file in a subdirectory of frontend/test/ is collected.
Most files are named after the component or flow they cover, such as composer-draft-save.test.ts, mailbox-page-refresh.test.ts or login-webauthn.test.ts. Most tests fall into three kinds:
-
Module tests import a module and call it directly. For example,
sieve-compiler.test.tscompiles filter rules and reads the resulting Sieve script back. -
Component tests mount a custom element in jsdom, stub
fetch(vi.stubGlobal('fetch', …)), and then check the shadow DOM, the requests and the events.password-settings.test.tsis a short example. -
Source checks read the TypeScript sources of
frontend/srcand of everyplugins/*/frontendwithreadSources()and enforce rules across all of them. Some files that contain such checks:-
rtl-interface.test.tsfails on a physical side (margin-left,text-align: rightand the like) incssblocks andstyleattributes, apart from a short list of allowed declarations in the test itself (LOCALIZATION.md). -
sprite-address.test.tsfails when a source other thansrc/utils/ui.tsmentionssprite.svg, so icons are drawn throughrenderIcon()(PLUGINS.md). -
i18n-dictionaries.test.tschecks every dictionary's keys, plural forms and placeholders, and checks that every literalt()key resolves (LOCALIZATION.md). It finds the core dictionaries by their files insrc/i18n/, and expects each plugin to have a dictionary for every code inSUPPORTED_LANGUAGES, so a new language is checked as soon as its files exist.
These checks cover a new plugin's frontend as soon as its files exist.
-
One file is of none of these kinds. e2e-harness.test.ts runs scripts/e2e.sh itself, with small shell scripts standing in for go, psql, curl, lsof, npm and npx, and checks where the script tells Playwright to write its traces and screenshots (see Browser Tests). It starts no real server and does not touch frontend/dist.
A plugin's frontend tests go in frontend/test, not in the plugin's directory. They import the plugin's code through a relative path:
import { SieveCompiler, type FilterRule } from '../../plugins/managesieve/frontend/sieve-compiler';Two settings in frontend/vitest.config.ts are there for these imports:
-
resolve.aliaspinslit,@lit,openpgpandrruletofrontend/node_modules. Plugin directories have nonode_modulesof their own, so without the aliases a test that imports a plugin file stops withFailed to resolve import "lit" from "../plugins/…".frontend/vite.config.tshas the same aliases for the build. A plugin frontend that starts using another package needs an alias for it in both files. -
server.fs.allow: ['..']lets Vite read files in the directory abovefrontend/, which holdsplugins/. The comment beside it says it keeps such tests from failing with "Denied ID".
The in-tree plugins' tests follow the plugin's name or feature:
| Plugin | Test files |
|---|---|
| caldav |
calendar-*.test.ts, tasks-*.test.ts, assigned-tasks, invitations, meetings, dav-configuration-state
|
| carddav |
contact-*.test.ts, contacts-*.test.ts
|
| gpg | gpg-*.test.ts |
| managesieve |
sieve-*.test.ts, auto-reply-settings
|
| password | password-settings |
The table is not complete. Files named after a behavior rather than a plugin import plugin code too: some import one plugin, such as dav-gone (caldav) or settings-busy (managesieve), and some import several, such as field-labels, focus-visible and most sidebar-* files. To find every test that loads a plugin, search for its path, for example grep -l "plugins/caldav/" test/*.test.ts in frontend/. The base plugin has no plugin frontend: its interface is frontend/src. For the whole plugin test layout (Go tests, unit tests, browser specs), see Plugins and Testing a Plugin in docs/PLUGINS.md.
test/helpers/setup.ts runs before every test file. It installs shims for browser APIs that jsdom lacks, each one only when the API is missing:
-
scrollIntoView, as a no-op -
ResizeObserver, as a no-op -
<dialog>showModal(),show()andclose().close()firesclose. - the Popover API:
popover,showPopover()andhidePopover(), which firetogglewithnewState -
matchMedia, which answersmin-width/max-widthqueries frominnerWidthand returns false for every other query -
document.execCommand, which returns false
The shims are installed once for every file. Components call these APIs from deferred work, such as a Lit update, firstUpdated or a timer. A throw there happens outside anything a test awaits, so Vitest reports it against whichever test happens to be running.
The other helper modules in frontend/test/helpers:
| Module | Exports | Use |
|---|---|---|
dom.ts |
mount, update, cleanup, shadow, shadowAll, text, record, flush, click, waitFor, and the install… shims |
Mount a custom element and wait for its first render, query its shadow root (shadow fails naming the selector), read its visible text without <style> and <script> contents, collect events, let microtasks and a setTimeout(0) settle, click and wait for the re-render, wait for a condition |
sidebar.ts |
settleSidebar, inSidebar, setSidebar
|
Mount sidebar content inside a real alps-sidebar, then collapse, expand or peek it |
sources.ts |
readSources |
Read source text from disk with node:fs for the source checks |
styles.ts |
rulesOf, ruleMatches, backgroundOf, backgroundsOf
|
Parse a component's CSS and find the rules whose selector matches a node, for checks such as "does any rule paint this background". There is no cascade: specificity and inheritance are ignored and every @media block counts |
Some things to know when you use them:
- Always
await mount(...). Before the first render the shadow root is empty, so a check that something is absent would pass for a component that does render it. - Call
cleanup()fromafterEach. It removes everythingmount()created. -
waitFor(condition, what, maxTicks = 200)counts ticks, not milliseconds, so a loaded machine waits as long as an idle one. It needs a realsetTimeout. In a test with fake timers, either fake onlyDate(vi.useFakeTimers({ toFake: ['Date'] }), asmeetings.test.tsdoes) or switch back withvi.useRealTimers()before you callwaitFor. -
readSources()reads files withnode:fson purpose. Vite's transform cache can serve a file's previous contents, and a check against stale text proves nothing.
frontend/test/password-settings.test.ts shows the usual shape: import the element, stub fetch, mount, act, check, clean up. Here are its helper and its first test, with the table of cases shortened:
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, mount, record } from './helpers/dom';
import '../../plugins/password/frontend/password-settings';
type El = HTMLElement & Record<string, any>;
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
async function change(status: number, body: unknown) {
vi.stubGlobal('fetch', vi.fn(async () => Response.json(body, { status })));
const i18nStore = Object.assign(new EventTarget(), { t: (key: string) => `translated:${key}` });
const page = await mount<El>('alps-password-settings', { i18nStore });
page.passwordForm = { old: 'current', new: 'new password', confirm: 'new password' };
const notices = record<CustomEvent>(window, 'show-toast');
await page.submitPasswordChange();
return { page, notice: notices.at(-1)!.detail };
}
describe('password change messages', () => {
it.each([
[403, 'current_password_incorrect', 'webauthn.errors.wrong_password'],
[429, 'rate_limited', 'settings.password.tooManyAttempts'],
// …
])('translates refusal %s/%s while keeping the entered passwords', async (status, code, key) => {
const { page, notice } = await change(Number(status), { code, error: 'English from server' });
expect(notice.message).toBe(`translated:${key}`);
expect(notice.type).toBe('error');
expect(page.passwordForm.old).toBe('current');
});
});A test whose result depends on the time zone should set the zone itself, as calendar-views.test.ts and contact-view.test.ts do: they set process.env.TZ and restore it in afterEach. A test that leaves the zone alone runs in the machine's zone and must pass in every zone (see Slow tests, test order and time zones). A test that depends on the date fixes the clock with vi.useFakeTimers({ now: ... }) or vi.setSystemTime(...).
Command (in frontend/) |
What it checks | Side effect |
|---|---|---|
npm run build |
tsc over src and ../plugins/*/frontend/**/*.ts (the include of tsconfig.json), then vite build
|
Rewrites the checked-in frontend/dist
|
npx tsc |
The same tsc step without the build (tsconfig.json sets noEmit: true) |
None |
npm run typecheck:test |
The unit tests: test and vitest.config.ts, through tsconfig.test.json, with Vite's client types and Node.js types, and every source file they import. npm test runs it first |
None |
npm run typecheck:e2e |
The browser specs: e2e and playwright.config.ts, through tsconfig.e2e.json, with Node.js types |
None |
tsconfig.test.json extends tsconfig.json, so the tests are checked with the sources' compiler options, and an unused import, local or parameter in a test is an error. Apart from include, it differs only in types: it adds the Node.js types, which the test helpers that read files or start processes need. A separate config keeps those types out of tsconfig.json, where browser code would otherwise see Node.js globals. Vitest itself compiles the tests without checking their types, so npx vitest run and npx vitest --watch run a test file that has type errors.
A change to a component's types can break a test that uses it. npx tsc and npm run build do not see that, because they leave frontend/test out. npm test, or npm run typecheck:test on its own, does.
The docs list no frontend unit test as slow or flaky. In the runs made for this page, the suite passed every time in its default order. These files took the longest:
| File | Time (one run) |
|---|---|
login-webauthn.test.ts |
about 8 s |
reader-conversation-actions.test.ts |
about 7.5 s |
login-webauthn-recovery.test.ts, contact-save-lifetime.test.ts, mailbox-page-refresh.test.ts, mailbox-page-listing-cache.test.ts, mailbox-page-failed-switch.test.ts, settings-store.test.ts
|
about 3.5 s each |
Because files run in parallel, these times overlap. The Vitest part of the run still takes about 25 seconds.
npm test runs the files in parallel and the tests within each file in the order they are written. npm run test:shuffle typechecks the tests, as npm test does, then runs vitest run --sequence.shuffle, which shuffles both the files and the tests within each file. A test that only passes after another test has run, or that breaks when another test runs first, fails there. Vitest prints the order's seed at the start, as Running tests with seed "<seed>", and the same seed gives the same order again:
npm run test:shuffle
npm run test:shuffle -- --sequence.seed=<seed>Each test should start from the state a fresh page has. Tests of components that keep state outside the element, in localStorage, in a store that outlives a page, or in navigation still queued from the last test, put that state back in beforeEach. auto-reply-settings.test.ts, mailbox-page-failed-switch.test.ts and mailbox-page-select-all-matching.test.ts, for example, fire session-cleared on window, as a sign-out does, and clear localStorage before each test. The last of these also lets the navigation an earlier test queued settle before it mounts its page. A test that needs jsdom to treat a focus as keyboard focus says so itself rather than relying on the previous test's clicks, as the keyboard-focus test in popup.test.ts does.
The full suite passed with TZ=UTC, with TZ=Pacific/Kiritimati (UTC+14) and in Central European time in the runs made for this page. A test that runs in the machine's zone has to hold in every zone, including those far from UTC, such as America/Adak (UTC−9 in September) and Pacific/Kiritimati (UTC+14). meetings.test.ts, for example, moves a meeting by adding the same number of days to the start and end dates the editor shows, rather than by setting fixed dates, so a meeting that crosses midnight in the machine's zone keeps its length.
- If
npm teststops before Vitest starts, witherror TS…lines fromtsc, the typecheck failed. Each line names the file and position. Fix the types;npx vitest runshows the tests' own results in the meantime. - Re-run the one file (
npm test -- test/<file>.test.ts) or the one test (npx vitest run -t "<name>") to get a short report. - An error that Vitest reports as unhandled can come from a different test. A component's deferred work can throw after its own test has finished, and the error is then reported against whichever test is running. If a new component calls a browser API that jsdom lacks, a shim in
test/helpers/dom.ts, installed fromsetup.ts, is the place for it. - Each test file runs in its own environment, but the tests within a file share one. If a test passes when run alone with
-tand fails when its whole file runs, or fails only undernpm run test:shuffle, an earlier test in that file has left something behind. Check that the file callscleanup()andvi.unstubAllGlobals()(or restores its spies) inafterEach, and puts back any state kept outside the element inbeforeEach(see Test order). - A test that imports a plugin file and stops with
Failed to resolve import "<package>" from "../plugins/…"reaches a package thatresolve.aliasdoes not pin. See Plugin frontend tests.
- Testing Overview: the three test tiers and when to run each.
- Go Tests and Browser Tests: the other two tiers.
- Plugins: how plugin frontends are built and loaded.
- docs/E2E.md: the testing reference.
- docs/LOCALIZATION.md: the localization tests and what to change when you add a language.
These pages describe ALPS as it is on the main branch; the reference documents live in the repository's docs/ directory.
Architecture
- Architecture Overview
- Mail Server Requirements
- Sessions and Sign-in
- Storage in IMAP METADATA
- Mail Providers
- Plugins
- Features
- Security Features
- Clustering
Tests
Configuration
- Configuration Overview
- Server
- Providers and SMTP
- TLS
- Cluster
- Rate Limiting and WebAuthn
- Logging and Cache
- Plugins
- Branding
Common Setups