@git.zone/tstest
🧪 A powerful, modern test runner for TypeScript — beautiful output, multi-runtime support, and a batteries-included test framework that makes testing actually enjoyable.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Availability and Links
Why tstest?
Most TypeScript test runners feel like an afterthought — clunky configuration, ugly output, and poor TypeScript support. tstest was built from the ground up for TypeScript developers who want:
- 🎯 Zero config — Point it at your test directory and go
- 🚀 Multi-runtime — Run the same tests on Node.js, Deno, Bun, and Chromium
- 🎨 Beautiful output — Color-coded results with emojis, progress bars, and visual diffs
- ⚡ Built-in everything — Assertions, snapshots, fixtures, retries, timeouts, parallel execution
- 🔧 Server-side tooling — Free ports, HTTPS certs, ephemeral databases, S3 storage, a headless browser — all out of the box
Installation
pnpm install --save-dev @git.zone/tstest
Module Exports
tstest ships as four modules, each optimized for a different use case:
| Export Path | Environment | Purpose |
|---|---|---|
@git.zone/tstest |
CLI | Test runner — discovers and executes test files |
@git.zone/tstest/tapbundle |
Browser + Node | Core test framework — tap, expect, lifecycle hooks |
@git.zone/tstest/tapbundle_serverside |
Node.js only | Server-side utilities — ports, certs, databases, browser, shell |
@git.zone/tstest/tapbundle_protocol |
Isomorphic | TAP Protocol V2 — structured metadata, events, diffs |
Quick Start
1. Write a test
// test/test.math.ts
import { tap, expect } from '@git.zone/tstest/tapbundle';
tap.test('should add numbers', async () => {
expect(2 + 2).toEqual(4);
});
tap.test('should handle async operations', async (tools) => {
await tools.delayFor(100);
const result = await fetchData();
expect(result).toBeTruthy();
});
export default tap.start();
2. Run it
# Run the project's test set (test/ unless .smartconfig.json says otherwise)
tstest
# Run all tests in a directory
tstest test/
# Run four test files at once
tstest --concurrency 4
# Run only the test files your uncommitted changes can affect
tstest --changed
# ...or everything your branch changed since it left main
tstest --changed main
# Run a specific file
tstest test/test.math.ts
# Use glob patterns
tstest "test/**/*.ts"
# Verbose mode (shows console output)
tstest test/ --verbose
# Watch mode
tstest test/ --watch
3. See beautiful output
🔍 Test Discovery
Mode: directory
Pattern: test
Found: 4 test file(s)
▶️ test/test.math.ts (1/4)
Runtime: Node.js
✅ should add numbers (2ms)
✅ should handle async operations (105ms)
Summary: 2/2 PASSED in 1.2s
📊 Test Summary
┌────────────────────────────────┐
│ Total Files: 4 │
│ Total Tests: 8 │
│ Passed: 8 │
│ Failed: 0 │
│ Duration: 2.4s │
└────────────────────────────────┘
ALL TESTS PASSED! 🎉
Multi-Runtime Architecture
tstest supports running your tests across four JavaScript runtimes, letting you verify cross-platform compatibility with zero extra effort.
Test File Naming Convention
Name your test files with runtime specifiers to control where they run:
| Pattern | Runtimes | Example |
|---|---|---|
*.ts |
Node.js (default) | test.api.ts |
*.node.ts |
Node.js only | test.server.node.ts |
*.chromium.ts |
Chromium browser | test.dom.chromium.ts |
*.deno.ts |
Deno | test.http.deno.ts |
*.bun.ts |
Bun | test.fast.bun.ts |
*.all.ts |
All runtimes | test.universal.all.ts |
*.node+chromium.ts |
Node.js + Chromium | test.isomorphic.node+chromium.ts |
*.node+deno.ts |
Node.js + Deno | test.cross.node+deno.ts |
*.chromium.nonci.ts |
Chromium, skip in CI | test.visual.chromium.nonci.ts |
Runtime Execution Order
When multiple runtimes are specified, tests execute in this order: Node.js → Chromium → Deno → Bun
How Chromium Files Run
A Chromium test file is bundled with tsbundle (esbuild), served from a port of its own and imported by a test page in a browser of its own, which is closed when the file finishes - nothing a page can see survives into another file.
- The test page starts on
load. It is a few inline lines with nothing else to fetch, so the evaluation starts as soon as it has loaded instead of after the network has been idle for half a second. - Bundles are built ahead. While a file runs, the bundle of the file that
starts next is built, so it is ready - or closer to it - when its turn
comes. With a
perTestfileprepare script nothing is built ahead, because that script runs right before its file and may change what the bundle is built from.
Migration from Legacy Naming
# Dry run — see what would change
tstest migrate --dry-run
# Apply migrations (uses git mv to preserve history)
tstest migrate --write
| Legacy Pattern | Modern Equivalent |
|---|---|
*.browser.ts |
*.chromium.ts |
*.both.ts |
*.node+chromium.ts |
CLI Options
The path is optional: without one, tstest runs the project's configured test set (see Project Configuration).
| Option | Description |
|---|---|
--version |
Show the installed tstest version |
--help, -h |
Show the usage |
--quiet, -q |
Minimal output — perfect for CI |
--verbose, -v |
Show all console output from tests |
--no-color |
Disable colored output |
--json |
Output results as JSON (CI/CD pipelines) |
--logfile |
Save detailed logs with error/diff tracking |
--tags <tags> |
Run only tests with specific tags |
--timeout <seconds> |
Timeout test files after N seconds |
--concurrency <n> |
Run up to N test files at once (default 1) |
--changed [<ref>] |
Run only the test files affected by the changes since <ref>'s merge base with HEAD; without a ref, by the uncommitted changes (see Affected-Only Runs) |
--affected <paths> |
Run only the test files affected by these changed paths (comma-separated) |
--reuse, --no-reuse |
Reuse a run with the same run key that passed in full, or never |
--no-ledger |
Do not record this run in the run ledger |
--startFrom <n> |
Start from test file number N |
--stopAt <n> |
Stop at test file number N |
--watch, -w |
Re-run tests on file changes |
--watch-ignore <patterns> |
Ignore patterns in watch mode |
| Command | Description |
|---|---|
tstest clean |
Remove the artifacts tstest wrote |
tstest clean --dry-run |
List what would be removed, remove nothing |
tstest ledger check |
Exit 0 when the test set already passed in full for the current run key |
Project Configuration
tstest reads the @git.zone/tstest section of .smartconfig.json, the same
mechanism tsbundle, tswatch, tsrust and tsdocker use. Every key is optional:
a project without the section runs test/, one file at a time, records its
runs in the ledger and never reuses one. Where a key has a CLI option, the
CLI option wins.
{
"@git.zone/tstest": {
"test": "test/",
"concurrency": 4,
"timeout": 600,
"suites": {
"rust": "cargo test --manifest-path rust/Cargo.toml"
},
"prepare": {
"once": "docker compose up -d",
"perTestfile": "node scripts/reset-fixtures.mjs"
},
"cleanup": {
"onRelease": true,
"afterRun": false,
"artifacts": ["logs", "cache", "testfiles"],
"additionalPaths": [".nogit/my-project-fixtures"]
},
"ledger": {
"reusePassedRun": true,
"maxAgeHours": 24
}
}
}
| Key | Default | CLI | Description |
|---|---|---|---|
test |
"test/" |
tstest <path> |
The test set: a directory, file or glob, or a list of them. Plain tstest runs it. |
concurrency |
1 |
--concurrency <n> |
How many test files run at once |
timeout |
none | --timeout <s> |
Seconds after which a test file or suite fails as timed out |
suites |
{} |
Further test commands of the test set, by name | |
prepare |
Commands run before the run and before each file | ||
cleanup |
tstest clean |
What to remove, and when | |
ledger |
--reuse, --no-ledger |
The run ledger | |
affected |
--changed, --affected |
What the import graph of an affected-only run cannot see |
concurrency
With concurrency above 1, tstest keeps up to that many test files running
and starts the next one as soon as one finishes. What a test proves does not
change: every file still runs in a process (or browser) of its own, with its
own timeout, its own perTestfile prepare script and its own log file.
- Output. A file that runs alongside others writes its whole output as
one block when it finishes - start line, console output (with
--verbose), results and summary - so no line of one file ever lands between the lines of another.--logfilewrites one log per file, as always. The final summary has the same shape as in a serial run. - Order. Files whose last run took longest start first, so a long file
never starts last and runs on alone; the durations come from the run ledger.
Files the ledger has not seen yet start first, in discovery order. File
numbers - and therefore
--startFromand--stopAt- always refer to the discovery order. - Parallel groups.
para__Nfiles keep their meaning: after the other files, each group runs on its own with all of its files at once, whatever the concurrency. Their output is grouped per file as well. - Default. With
concurrency1 nothing changes: one file after another, output written as it happens.
Choosing a value. The default stays 1, because files can only run at the same time when they are independent of each other - no fixed ports, no shared fixtures or databases, no global state outside their own process. Each running file costs a process (a browser for a Chromium file) and, while it is bundled, an esbuild process of several hundred MB. On a machine shared with other work, start with 2 to 4 and raise it only while the machine has idle cores; the run ledger orders the files longest first, so a moderate value already keeps every slot busy.
suites
A test set is often more than TypeScript files - a Rust crate, a Go module, a
shell check. suites makes such commands part of it:
{ "@git.zone/tstest": { "suites": { "rust": "cargo test", "lint": "pnpm run lint" } } }
Each suite runs from the project root after the test files (alongside them
when concurrency is above 1), passes when it exits 0 within timeout, and
is reported as suites/<name> like a test file with one test - its output is
shown with --verbose, and with the failure otherwise. Suites run whenever
the configured test set runs; a run of another path, or one narrowed with
--tags, leaves them out.
ledger
tstest records every run in a ledger inside the git repository, at
<git-common-dir>/tstest/ - shared by all worktrees, never part of the
repository content, readable only by the user (directory 0700, files
0600), and pruned to the newest keepRuns runs. Each record is signed with
a key that never leaves that directory, so a record that was altered or
copied in from elsewhere is ignored. Outside a git repository there is no
ledger.
A record holds the run's per-file durations and results and its run key, a hash over everything the outcome depends on:
- the test set, including the suites and their commands;
- command-line settings that change what a run proves:
--timeoutand--web(settings in.smartconfig.jsonare part of the tree); - the HEAD tree of a worktree that is fully clean, untracked files included;
- the installed dependencies:
node_modules/.pnpm/lock.yaml(at the root of a pnpm workspace for its members),node_modules/.package-lock.json,node_modules/.yarn-state.yml,deno.lock,bun.lock. A project with apackage.jsonbut none of thenode_modulesfiles has unknown dependencies, and its runs get no key - they are recorded, never reused; - the toolchain: Node.js, tstest, the platform and every runtime's version;
- the machine and user (hashed machine id, uid);
- the environment files (
envFiles) and variables (CI,NODE_ENV,NODE_OPTIONSandenvVars) - stored only as a keyed hash.
Files git ignores are not part of the run key - the tree covers tracked
files only, and a clean status rules out untracked ones - with the exception
of the envFiles. Any ignored file a run's outcome depends on (a local
fixture, a generated config) belongs in envFiles: despite the name, every
listed file counts by presence and content, whatever it holds. There is no
other way to add files to the key.
A run passed in full when it ran the whole configured test set - no other
path, no --tags, --startFrom or --stopAt, not in watch mode - every file
and suite passed, and the worktree was clean before and unchanged after it.
With reusePassedRun (or --reuse), tstest does not repeat a run whose run
key passed in full within maxAgeHours; it prints which run it stands on -
key, time, duration and worktree - and exits 0. tstest ledger check answers
the same question without running anything, for a release preflight:
tstest ledger check # exit 0 and the reused run, or exit 1 and why not
tstest ledger check --timeout 60 # for a test script that runs `tstest --timeout 60`
tstest ledger check --json # { reusable, reason, key, run }
Because --timeout and --web are part of the run key, ledger check must
be given the same ones as the test run it stands for - or the timeout lives in
.smartconfig.json, where both read it. The same check is exported as
checkLedger(cwd, { maxAgeHours, timeoutSeconds, web }) from
@git.zone/tstest.
With --json, a reused run prints a reusedRun event followed by the usual
summary event, carrying the reused run's totals and no per-test
fileResults.
| Key | Default | Description |
|---|---|---|
enabled |
true |
Record runs (--no-ledger turns it off for one run) |
reusePassedRun |
false |
Reuse a run whose key passed in full (--reuse / --no-reuse) |
maxAgeHours |
24 |
How old a reused run may be |
keepRuns |
200 |
How many runs the ledger keeps |
envFiles |
[".nogit/env.json", ".env"] |
Files whose presence and content are part of the run key |
envVars |
[] |
Variables part of the run key besides CI, NODE_ENV, NODE_OPTIONS |
A suite's own toolchain (for example the Rust compiler) is not part of the run
key; if it matters, expose its version through a variable listed in envVars.
affected
What an affected-only run cannot see in the import graph, as globs relative to the project root:
{
"@git.zone/tstest": {
"affected": {
"runAll": ["assets/**", "scripts/**"],
"alwaysRun": ["test/test.package-entrypoints.node.ts"]
}
}
}
| Key | Default | Description |
|---|---|---|
runAll |
[] |
Paths whose change runs the whole test set - files the tests read at runtime, scripts they call |
alwaysRun |
[] |
Test files that run in every affected-only run - for example tests of the built output, which read it rather than import it |
prepare
Replaces the test:before, test:before:once and test:before:testfile
package.json scripts, which remain supported for projects that already use
them. The config wins where both are present.
| Key | Description |
|---|---|
once |
Runs once before the whole test run |
perTestfile |
Runs before each individual test file |
cleanup
Test runs leave data on disk, and on a long-lived machine it accumulates indefinitely. Cleanup removes it.
| Key | Default | Description |
|---|---|---|
onRelease |
true |
Clean as part of gitzone release. Set false to opt out. |
afterRun |
false |
Also clean after every local test run |
artifacts |
["logs", "cache", "testfiles"] |
Which artifact groups to remove |
additionalPaths |
[] |
Extra project-relative paths to remove |
Artifact groups map to fixed locations:
| Group | Path | Notes |
|---|---|---|
logs |
.nogit/testlogs/ |
Written by --logfile |
cache |
.nogit/tstest_cache/<run id>/<test file>/ |
Browser bundle cache, per run |
testfiles |
.nogit/testfiles/ |
Fixtures downloaded by tapnodetools |
snapshots |
.nogit/test_snapshots/ |
Not cleaned by default |
Every run bundles into its own subdirectory of .nogit/tstest_cache/ and
removes a test file's directory as soon as that file finishes, so concurrent
tstest runs in one checkout are supported: they never share, overwrite or
delete each other's bundles. tstest clean and cleanup.onRelease remove the
whole .nogit/tstest_cache/ directory; cleanup.afterRun removes only the
run that just finished.
snapshots is excluded from the defaults deliberately. Snapshots are
assertion baselines, not output: deleting one makes the next matchSnapshot
re-record it instead of comparing, so the assertion silently passes. Only
list snapshots when you actually intend to discard those baselines.
additionalPaths entries must be relative and stay inside the project.
Absolute paths, and paths that escape the project root, are refused and
reported rather than followed.
Writing Tests with tapbundle
Basic Syntax
import { tap, expect } from '@git.zone/tstest/tapbundle';
tap.test('basic test', async () => {
expect(2 + 2).toEqual(4);
});
tap.test('with tools', async (tools) => {
await tools.delayFor(100);
tools.timeout(5000);
expect(true).toBeTrue();
});
export default tap.start();
Test Modifiers
// Skip
tap.skip.test('not ready yet', async () => { /* skipped */ });
// Only (exclusive)
tap.only.test('focus on this', async () => { /* only this runs */ });
// Todo
tap.todo.test('implement later', async () => { /* marked as todo */ });
// Fluent chaining
tap.timeout(5000)
.retry(3)
.tags('api', 'integration')
.test('complex test', async (tools) => { /* configured */ });
Test Organization with describe()
tap.describe('User Management', () => {
tap.beforeEach(async () => {
// setup before each test
});
tap.afterEach(async () => {
// cleanup after each test
});
tap.test('should create user', async () => { /* ... */ });
tap.test('should delete user', async () => { /* ... */ });
tap.describe('Permissions', () => {
tap.test('should set admin role', async () => { /* ... */ });
});
});
Pre-Tasks and Post-Tasks
tap.preTask('setup database', async () => {
await initializeDatabase();
});
tap.test('uses the database', async () => { /* ... */ });
tap.postTask('cleanup database', async () => {
await cleanupDatabase();
});
Test Tools
Every test function receives a tools parameter packed with utilities:
tap.test('tools demo', async (tools) => {
// ⏱️ Delays
await tools.delayFor(1000);
await tools.delayForRandom(100, 500);
// ⏭️ Skip
tools.skipIf(process.env.CI === 'true', 'Skipping in CI');
tools.skip('reason');
// 🔁 Retry & timeout
tools.retry(3);
tools.timeout(10000);
// 📦 Context sharing between tests
tools.context.set('userId', 12345);
const userId = tools.context.get('userId');
// 🔮 Deferred promises
const deferred = tools.defer();
setTimeout(() => deferred.resolve('done'), 100);
await deferred.promise;
// 🎯 Error capture
const error = await tools.returnError(async () => {
throw new Error('Expected error');
});
expect(error).toBeInstanceOf(Error);
// ✅ Allow failure (test won't fail the suite)
tools.allowFailure();
});
Snapshot Testing
tap.test('snapshot test', async (tools) => {
const output = generateComplexOutput();
await tools.matchSnapshot(output);
await tools.matchSnapshot(output.header, 'header');
});
// Update snapshots: UPDATE_SNAPSHOTS=true tstest test/
Test Fixtures
import { tap, expect, TapTools } from '@git.zone/tstest/tapbundle';
TapTools.defineFixture('testUser', async (data) => ({
id: Date.now(),
name: data?.name || 'Test User',
email: data?.email || 'test@example.com',
}));
tap.test('fixture test', async (tools) => {
const user = await tools.fixture('testUser', { name: 'John' });
expect(user.name).toEqual('John');
// Factory pattern for multiple instances
const users = await tools.factory('testUser').createMany(5);
expect(users).toHaveLength(5);
});
Parallel Execution
// Within a file
tap.parallel().test('parallel test 1', async () => { /* ... */ });
tap.parallel().test('parallel test 2', async () => { /* ... */ });
// Across files — same suffix = parallel group
// test.api.para__1.ts ←─ run together
// test.db.para__1.ts ←─ run together
// test.auth.para__2.ts ←─ runs after para__1 completes
Chromium files in a parallel group each launch and close a browser of their own, so one file finishing never affects another that is still running.
To run many files at once without naming groups, use --concurrency <n> (or concurrency in .smartconfig.json); see concurrency.
Assertions (expect)
tapbundle uses @push.rocks/smartexpect for assertions with automatic diff generation on failures:
// Equality
expect(value).toEqual(5);
expect(obj).toDeepEqual({ a: 1, b: 2 });
// Types
expect('hello').toBeTypeofString();
expect(42).toBeTypeofNumber();
expect([]).toBeArray();
// Comparisons
expect(5).toBeGreaterThan(3);
expect(0.1 + 0.2).toBeCloseTo(0.3, 10);
// Truthiness
expect(true).toBeTrue();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
// Strings
expect('hello world').toStartWith('hello');
expect('hello world').toEndWith('world');
expect('hello world').toInclude('lo wo');
expect('hello world').toMatch(/^hello/);
// Arrays
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toContainAll([1, 3]);
expect([1, 2, 3]).toHaveLength(3);
// Objects
expect(obj).toHaveProperty('name');
expect(obj).toMatchObject({ name: 'John' });
// Functions & Promises
expect(() => { throw new Error(); }).toThrow();
await expect(Promise.resolve('val')).resolves.toEqual('val');
await expect(Promise.reject(new Error())).rejects.toThrow();
// Custom
expect(7).customAssertion(v => v % 2 === 1, 'Value is not odd');
Server-Side Tools (tapbundle_serverside)
For Node.js-only tests, import server-side utilities:
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { TapNodeTools } from '@git.zone/tstest/tapbundle_serverside';
const tapNodeTools = new TapNodeTools(tap);
🌐 Network Utilities
Find free local ports for test servers — no more port conflicts:
tap.test('should start server on free port', async () => {
// Single free port (random in range 3000–60000)
const port = await tapNodeTools.findFreePort();
// Custom range
const port2 = await tapNodeTools.findFreePort({ startPort: 8000, endPort: 9000 });
// With exclusions
const port3 = await tapNodeTools.findFreePort({ exclude: [8080, 8443] });
});
tap.test('should allocate multiple ports', async () => {
// Multiple distinct ports
const [httpPort, wsPort, adminPort] = await tapNodeTools.findFreePorts(3);
// Consecutive port range (e.g., 4000, 4001, 4002)
const portRange = await tapNodeTools.findFreePortRange(3, {
startPort: 20000,
endPort: 30000,
});
});
🔒 HTTPS Certificates
Generate self-signed certs for testing secure connections:
tap.test('should serve over HTTPS', async () => {
const { key, cert } = await tapNodeTools.createHttpsCert('localhost');
const server = https.createServer({ key, cert }, handler);
server.listen(port);
});
💻 Shell Commands
const result = await tapNodeTools.runCommand('ls -la');
console.log(result.exitCode); // 0
🔐 Environment Variables
const apiKey = await tapNodeTools.getEnvVarOnDemand('GITHUB_API_KEY');
// Resolves from process.env, then .nogit/env.json or .nogit/env.yml, then Docker secrets; returns undefined when unset. Nothing is prompted or written.
🗄️ Ephemeral MongoDB-Wire Database
const mongo = await tapNodeTools.createSmartmongo();
// ... run database tests ...
await mongo.stop();
This starts a single-node, memory-backed NoSQLDB server through @push.rocks/smartmongo. It downloads no MongoDB binary, implements only NoSQLDB's documented MongoDB wire-protocol surface, provides no replication, and uses NoSQLDB binaries published for Linux and macOS on x64 and arm64.
Migrating from v5
Version 6 renames createSmartStorage() to createObjectStorage() and returns the canonical @lossless.org/objectstorage 10 ObjectStorage class. The old helper is not retained. createSmartmongo() now uses SmartMongo 9 and exposes its underlying NoSQLDB server as noSqlDbServer. Both helpers create disposable test data; this migration changes no persisted database or object-storage format.
📦 Local S3 Storage
const s3 = await tapNodeTools.createObjectStorage();
// ... run object storage tests ...
await s3.stop();
The helper uses standalone ObjectStorage 10 in a separate private temporary
directory for each instance. It requires GNU/Linux amd64 or arm64 with glibc
2.34 or later, the matching dynamic loader, libm.so.6 and libgcc_s.so.1.
Musl-only systems such as default Alpine are unsupported by this storage helper.
It honors
If-None-Match: * on PutObject and CompleteMultipartUpload: a conflicting
write returns 412 PreconditionFailed and preserves the existing object. Callers
can await stop() to close a server early. Helper cleanup waits for admitted
startup, stops remaining servers, and removes only its own temporary directories
after shutdown completes. Incomplete cleanup retains ownership for a later retry;
new storage creation is rejected while cleanup is running.
🖥️ Headless Browser
Drive a real Chromium against a server your Node.js test starts itself:
import type { TTapNodeBrowser } from '@git.zone/tstest/tapbundle_serverside';
tap.test('loads the page in Chromium', async () => {
const browser: TTapNodeBrowser = await tapNodeTools.createBrowser();
const page = await browser.newPage();
await page.goto(`http://127.0.0.1:${port}/`);
expect(await page.evaluate(() => document.title)).toEqual('My App');
});
createBrowser() returns the puppeteer Browser, launched through
@push.rocks/smartbrowser/automation (system google-chrome, chromium or
chromium-browser first, no sandbox under CI or root). Pages, reloads,
service workers and worker targets are the plain puppeteer API, and the
exported TTapNodeBrowser type names the handle without a puppeteer dependency
in your project. Helper cleanup closes every browser that is still open.
Test File Directives
Control runtime behavior directly from your test files using special comment directives at the top of the file. Directives must appear before any import statements.
Deno Permissions
By default, Deno tests run with --allow-read, --allow-env, --allow-net, --allow-write, --allow-sys, --allow-import, and --sloppy-imports. Deno discovers the project's configuration and nodeModulesDir setting; tstest does not run a separate dependency installation. An explicit // tstest:deno:flag:--node-modules-dir=manual or =auto directive selects that mode. Add directives to request additional permissions:
// tstest:deno:allowAll
import { tap, expect } from '@git.zone/tstest/tapbundle';
tap.test('test with full Deno permissions', async () => {
// Runs with --allow-all (e.g., for FFI, subprocess spawning, etc.)
});
export default tap.start();
Available Directives
| Directive | Effect |
|---|---|
// tstest:deno:allowAll |
Grants all Deno permissions (--allow-all) |
// tstest:deno:allowRun |
Adds --allow-run for subprocess spawning |
// tstest:deno:allowFfi |
Adds --allow-ffi for native library calls |
// tstest:deno:allowHrtime |
Adds --allow-hrtime for high-res timers |
// tstest:deno:flag:--unstable-ffi |
Passes any arbitrary Deno flag |
// tstest:node:flag:--max-old-space-size=4096 |
Passes flags to Node.js |
// tstest:bun:flag:--smol |
Passes flags to Bun |
Multiple Directives
Combine as many directives as needed:
// tstest:deno:allowRun
// tstest:deno:allowFfi
// tstest:deno:flag:--unstable-ffi
import { tap, expect } from '@git.zone/tstest/tapbundle';
tap.test('test with Rust FFI', async () => {
// Has --allow-run, --allow-ffi, and --unstable-ffi in addition to defaults
});
export default tap.start();
Shared Directives via 00init.ts
Directives in a 00init.ts file apply to all test files in that directory. Test file directives are merged with (and extend) init file directives.
// test/00init.ts
// tstest:deno:allowRun
// test/test.mytest.deno.ts
// tstest:deno:allowFfi
// Both --allow-run (from 00init.ts) and --allow-ffi are active
import { tap, expect } from '@git.zone/tstest/tapbundle';
Advanced Features
Watch Mode
tstest test/ --watch
tstest test/ --watch --watch-ignore "dist/**,coverage/**"
- 👀 Shows which files triggered the re-run
- ⏱️ 300ms debouncing to batch rapid changes
- 🔄 Clears console between runs
Visual Diffs
When assertions fail, you get beautiful diffs:
❌ should return correct user data
Object Diff:
{
name: "John",
- age: 30,
+ age: 31,
email: "john@example.com"
}
Enhanced Logging
tstest test/ --logfile
| Folder | Contents |
|---|---|
.nogit/testlogs/ |
Current run logs |
.nogit/testlogs/previous/ |
Previous run logs |
.nogit/testlogs/00err/ |
Failed test logs |
.nogit/testlogs/00diff/ |
Changed output diffs |
JSON Output (CI/CD)
tstest test/ --json > test-results.json
{"event":"discovery","count":4,"pattern":"test","executionMode":"directory"}
{"event":"testResult","testName":"prepare test","passed":true,"duration":1}
{"event":"summary","summary":{"totalFiles":4,"totalTests":4,"totalPassed":4,"totalFailed":0,"failedFiles":0}}
Runtime setup errors, nonzero exits, signals, timeouts, bailouts, and incomplete test plans fail the file and command. They appear in executionErrors and failedFiles; assertion counts include only reported tests. A runtime that fails before defining tests therefore contributes zero assertions and one failed file.
A file that fails this way also reports its error output, in every mode and without --verbose: the stderr of a Node.js, Deno, Bun or Docker test process, or the console.error lines of a Chromium page (an uncaught error or unhandled rejection inside the page is not captured). So the stack of a crash, such as an unhandled rejection that ends the process, is part of the failure report. Only the last 16 KiB are kept; a truncated block says how many bytes it omits. With --json the block is a fileErrorOutput event and the file's errorOutput in the summary. A passing file prints none.
Tag Filtering
tap.tags('unit', 'api').test('api unit test', async () => { /* ... */ });
tstest test/ --tags unit,api
Test File Range
tstest test/ --startFrom 5 --stopAt 10 # Run files 5-10 only
Affected-Only Runs
Run only the test files a change can affect, instead of the whole set:
tstest --changed # the uncommitted changes: staged, unstaged, untracked
tstest --changed main # everything since the branch left main, uncommitted included
tstest --changed=origin/main # the same, with the ref spelled explicitly
tstest --affected src/a.ts,src/b.ts # these paths changed
tstest --changed --watch # then re-run what each later change affects
--changed <ref> compares with the merge base of <ref> and HEAD, so work
that landed on main after your branch left it is not counted as yours. The
word after --changed is taken as the ref when git knows it as a commit and
it is not an existing path; --changed=<ref> says it explicitly. Both flags
can be combined, and with a test path or glob, which they narrow further.
A test file is selected when:
- it changed itself;
- its static import graph reaches a changed file. The graph is traced with
esbuild, with the resolution the bundler and tsx use: relative imports,
.jsspecifiers naming.tsfiles, tsconfigpathsaliases, literal dynamicimport()s, CSS and the assets CSS or code imports. Installed packages are not followed (they change with the lockfile), but a package that resolves to a directory outsidenode_modules- a workspace member - is. Styles written inside TypeScript are TypeScript, and count like any other source. What the00init.tsof its directory reaches counts as reached by every test file there; - its imports cannot be fully traced: one of the files it reaches has an
import that does not resolve - a file that is gone, a Deno import map alias,
a package that is not installed - or it is not a TypeScript file at all (a
Docker test file). Imports by scheme (
node:,npm:,jsr:,https:) are not followed and do not count as unresolved; affected.alwaysRunnames it.
Every test file is selected when a file changed that decides how all of
them are installed, compiled or run - package.json, a lockfile
(pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lock, deno.lock),
pnpm-workspace.yaml, deno.json, .npmrc, a tsconfig*.json,
.smartconfig.json, npmextra.json or a 00init.ts - inside the project or
in a directory above it up to the repository root; when a changed path
matches affected.runAll; and when the import graph cannot be traced at all,
for example because a file does not parse. Any other change - documentation,
a file no test file reaches - selects nothing by itself.
What the graph cannot see is a file a test reads at runtime - a fixture
read with fs, a built bundle served to a browser, a worker started from a
new URL(...), a module loaded by an import() of a computed path - and a
dependency expressed only through a string, such as a custom element used by
its tag name in a template without importing its module. Name such files in
affected.runAll, or such tests in affected.alwaysRun. --changed takes
its changes from git, so a file git ignores - a generated source, say - never
counts as changed; name it with --affected when it matters. Suites have no
import graph and run as in a run of the whole set.
Precision depends on the imports: a test file that imports a package's
barrel (src/index.ts) reaches every module the barrel exports, and is
selected by a change to any of them. Tests that import the module they test
are selected only by changes that module can see.
The run prints what it selected (with --verbose, each file and why) and is
recorded in the ledger as a partial run: it is never a full run and never
stands in for one. With --watch, the first run selects by --changed and
--affected, and every later run by the files changed since the run before.
Browser Testing with webhelpers
import { tap, webhelpers } from '@git.zone/tstest/tapbundle';
tap.test('DOM test', async () => {
const element = await webhelpers.fixture(webhelpers.html`
<div class="container">
<h1>Hello</h1>
</div>
`);
expect(element.querySelector('h1').textContent).toEqual('Hello');
});
TapWrap (Global Lifecycle)
import { TapWrap } from '@git.zone/tstest/tapbundle';
const tapWrap = new TapWrap({
before: async () => { await globalSetup(); },
after: async () => { await globalCleanup(); },
});
tapbundle Protocol V2
tstest includes an enhanced TAP protocol that extends TAP 13 with structured metadata while staying backwards compatible. Protocol markers (⟦TSTEST:...⟧) are invisible to standard TAP parsers.
import { ProtocolEmitter, ProtocolParser } from '@git.zone/tstest/tapbundle_protocol';
// Emit
const emitter = new ProtocolEmitter();
console.log(emitter.emitProtocolHeader()); // ⟦TSTEST:PROTOCOL:2.0.0⟧
console.log(emitter.emitTest({
ok: true, testNumber: 1, description: 'test',
metadata: { time: 42, tags: ['unit'] }
}).join('\n'));
// Parse
const parser = new ProtocolParser();
const messages = parser.parseLine('ok 1 - test ⟦TSTEST:time:42⟧');
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.