Skip to main content

Running Tests

ImapKit is written in TypeScript under src/. Tests use the built-in Node.js test runner, and TypeScript test files run through tsx. Linting uses ESLint and the TypeScript compiler, formatting uses Prettier.

git clone https://github.com/postalsys/imapkit.git
cd imapkit
npm install
npm test

npm install also builds the package (the prepare script) and points core.hooksPath at .githooks.

Commands​

CommandWhat it does
npm testESLint and the type check (npm run lint), the build, then every test. This is what to run before a commit.
npm run test:unitEvery test, without lint and build: node --import tsx --test test/*.test.ts.
npm run test:coverageThe tests with Node's built-in coverage for src/. Fails below 94% line coverage. Needs Node.js 22.8 or newer, CI runs it on Node 24.
npm run test:bunThe suite under Bun.
npm run test:denoThe suite under Deno.
npm run buildCompiles src/ into dist/esm and dist/cjs with type declarations, and collects the plugin help text for imapkit --help.
npm run lintESLint and tsc type checking.
npm run formatPrettier on every file. npm run format:check only checks, CI fails on unformatted files.

Tests import src/ directly through tsx, so most of them do not need a build. Only test/package.test.ts and test/cli.test.ts load the built dist/, and they skip with "run npm run build first" when it is missing.

A single file or test case​

node --import tsx --test test/uid-fetch.test.ts
node --import tsx --test --test-name-pattern="returns server ID" test/id.test.ts

Test files start their servers on random ports, so the runner runs them in parallel.

Bun and Deno​

CI runs the whole suite on the latest Bun and Deno releases next to Node.js 20, 22 and 24. Deno does not pass a done callback to node:test hooks, so hooks in the tests return promises. Keep that pattern when adding hooks.

Formatting​

Prettier uses single quotes, 4 spaces and 160 columns. The pre-commit hook in .githooks/pre-commit formats staged files and adds them back to the commit. A staged file that also has unstaged changes is only checked, not rewritten, so the commit is aborted if it is unformatted: run npm run format and stage again.

The response guardrail​

Every transcript a test gets from the test helpers first goes through test/helpers/validate-responses.ts. It checks that a compliant client can parse everything the server sent:

  • CRLF framing and literals
  • the RFC 3501 section 9 shape of tagged, untagged and + responses: every OK, NO, BAD and BYE response carries human readable text (also untagged ones with only a response code), no 8-bit data outside literals, nz-numbers for FETCH and EXPUNGE, FETCH lists in pairs
  • ImapFlow's response parser accepts every response

A failure there means ImapKit sent something a compliant client can not parse. Fix the server, not the check.

The fuzz test​

test/fuzz.test.ts mutates valid IMAP commands and replays them against a server with every plugin loaded except STARTTLS, COMPRESS, LOGINDISABLED, METADATA-SERVER and the alternatives LITERAL- and SAVELIMIT. It checks that the server answered every command once, that its output passes the response guardrail and that it still responds afterwards.

The seed is fixed by default, so CI is deterministic. On a failure the test prints FUZZ_SEED, the iteration and the input. Reproduce a run, or widen it with more iterations or another seed:

FUZZ_SEED=7 FUZZ_ITERATIONS=5000 node --import tsx --test test/fuzz.test.ts

FUZZ_ITERATIONS defaults to 400.

Test layers​

LayerWhere
Protocol tests per commandtest/<command>.test.ts and test/<plugin>.test.ts
Strictness checkstest/conformance.test.ts, table driven, one row per RFC rule (see Strict by Design)
Multiple sessionstest/sessions.test.ts (EXPUNGE timing, flag updates, IDLE, \Recent), test/multi-access.test.ts (RFC 2180 scenarios)
RFC 2683 recommendationstest/implementation-recommendations.test.ts
A real client end to endtest/imapflow.test.ts, ImapFlow with all plugins and with none
MIME fidelitytest/mime-fidelity.test.ts, golden BODYSTRUCTURE and ENVELOPE wire forms checked against Dovecot, fixtures in test/fixtures/mime/
MIME parsertest/mime.test.ts
Package shapetest/package.test.ts, the ES module and CommonJS exports of the built package
Compare tooltest/compare.test.ts, the parsing, normalizing and seeding of the Dovecot compare tool, against ImapKit only, so npm test needs no Docker
Fuzzingtest/fuzz.test.ts

Writing a test​

The usual pattern uses setupServer() from test/helpers/. It registers hooks that start a fresh server on a random port before every test of the enclosing describe block and close it afterwards. ctx.run(commands, callback) replays IMAP command strings like a compliant client: it waits for each tagged response, sends literal data only after the + continuation, and sends the next list entry as continuation data when the server asks for one (DONE, SASL responses).

import { describe, it } from 'node:test';
import assert from 'node:assert';
import { setupServer } from './helpers/index.js';

describe('XYZ', () => {
const ctx = setupServer(() => ({ plugins: ['IDLE'], storage: { INBOX: {}, '': {} } }));

it('refuses a missing mailbox', (t, done) => {
ctx.run(['A1 LOGIN testuser testpass', 'A2 SELECT Nope', 'A3 LOGOUT'], resp => {
assert.match(resp.toString(), /^A2 NO /m);
done();
});
});
});

Assert on the full transcript, preferably with line anchored regular expressions (/^A3 NO \[TRYCREATE\]/m). ctx.server is the live server for inspecting state. For interleaved connections use openSession() or useSessions() from test/helpers/session.ts.

Keep helpers out of the top level of test/: every test/*.test.ts file there runs as a test file.

Changes to RFC behavior cite the section in code comments and tests, checked against the source text at https://www.rfc-editor.org/rfc/rfcNNNN.txt.