Writing plugins - uhop/tape-six GitHub Wiki
Writing tape-six plugins
A plugin is any module that, when imported, attaches one or more methods to Tester.prototype. After the import, every tester instance — every t passed to a test() callback — gains those methods.
tape-six ships registerTesterMethod for the registration step. The built-in t.OK() evaluator in src/OK.js uses this exact pattern and is the canonical worked example.
When to write a plugin
- Helpers that depend on a specific runtime (
node:child_process,node:fs) or a third-party library you don't want to pull intotape-sixcore. - Domain-specific assertion helpers (HTTP, database, filesystem) that compose existing
t.*calls. - Reporting helpers that emit
comment/stdout/assertevents throught.reporter.report(...).
If the helper is pure JS, cross-platform, and has at least two consumers in different projects, propose it as a tape-six core feature instead.
registerTesterMethod
import {registerTesterMethod} from 'tape-six';
Signature:
registerTesterMethod(name: string, fn: (...args: any[]) => any): void;
Idempotency rules:
- Same name + same function — no-op. Re-imports of the same plugin module don't fail.
- Same name + different function — throws. Collisions between plugins surface loudly at registration time, not when a test calls the method.
- New name — installed on
Tester.prototype.
The name must be a non-empty string; the fn must be a function. Type errors are thrown synchronously.
Per-file installation — important
Plugin installation lives in the JS module graph, which is per-context. Each runner uses a different isolation model:
| Runner | Isolation |
|---|---|
tape6-seq |
All test files share one process context. |
tape6 (default) |
Each test file (or batch) runs in its own worker thread. |
tape6-proc (from tape-six-proc) |
Each test file runs in its own subprocess. |
browser (tape6-server) |
Each test file runs in its own iframe. |
A plugin imported in test file A is only visible in test file B if A and B share the same context. That happens to be true under tape6-seq but is not true under tape6, tape6-proc, or in the browser. Tests that pass locally under one runner can fail under another.
The rule: every test file that uses a plugin must import it directly.
import 'tape-six-spawn'; // ← in every file that calls t.spawnBin()
import test from 'tape-six';
test('cli exits 0', async t => {
const {code} = await t.spawnBin('node', ['-v']);
t.equal(code, 0);
});
Module imports dedupe within a single context, so the plugin's side-effect runs once per context. registerTesterMethod's idempotency (same fn → no-op) keeps repeated imports safe — never a "plugin already installed" error from a normal import graph.
Don't put plugin imports in a "test setup" file you load once and forget. There is no "once" across all the contexts a single npm test invocation can spin up.
Worked example: a t.spawnBin() plugin
A complete plugin that adds a subprocess helper to every tester:
// tape-six-spawn.js
import {spawn} from 'node:child_process';
import {registerTesterMethod} from 'tape-six';
const spawnBin = function spawnBin(bin, args = [], {env, cwd, input} = {}) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, {
env: {...process.env, ...env},
cwd,
stdio: input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => (stdout += chunk));
child.stderr.on('data', chunk => (stderr += chunk));
child.on('error', reject);
child.on('close', (code, signal) => resolve({code, signal, stdout, stderr}));
if (input !== undefined) {
child.stdin.end(input);
}
});
};
registerTesterMethod('spawnBin', spawnBin);
Consumer code:
import 'tape-six-spawn'; // side-effect: registers t.spawnBin
import test from 'tape-six';
test('CLI prints --version', async t => {
const {code, stdout} = await t.spawnBin('node', ['-e', 'console.log("v1")']);
t.equal(code, 0);
t.equal(stdout.trim(), 'v1');
});
TypeScript: declare your method on Tester
A plugin's prototype mutation is invisible to TS unless you declare it. Use module augmentation alongside the registration:
// tape-six-spawn.d.ts
import 'tape-six';
declare module 'tape-six' {
interface Tester {
spawnBin(
bin: string,
args?: string[],
options?: {env?: NodeJS.ProcessEnv; cwd?: string; input?: string}
): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}>;
}
}
tape-six's Tester is declared as an interface (not a class) in index.d.ts precisely to make this kind of augmentation natural — adding methods doesn't require declaring the full class shape.
Emitting events from a plugin
Tester methods that produce assertions should call this.reporter.report({...}) with the standard event shape:
{
type: 'assert', // omit to default to 'assert'
name: 'should match', // assertion description (string)
test: this.testNumber, // current test number
marker: new Error(), // for stack-trace location on failure
time: this.timer.now(), // millisecond timestamp
operator: 'spawnBinExit', // a short operator name (your choice)
fail: code !== 0, // truthy → assertion failed
data: {expected: 0, actual: code}
}
For comment-style events that don't count as assertions, use t.comment(msg) directly. For pass/fail/equality, you can also delegate to the existing t.* methods rather than emitting events directly:
registerTesterMethod('expectExitCode', async function (bin, args, expected) {
const {code} = await this.spawnBin(bin, args);
this.equal(code, expected, `${bin} exited with ${expected}`);
});
Aliases
Tape-six's setAliases(source, aliases) is built on registerTesterMethod, so it's idempotent for free:
import {setAliases} from 'tape-six/Tester.js';
registerTesterMethod('expectExitCode', expectExitCode);
setAliases('expectExitCode', 'exitCodeShouldBe, expectExit');
Naming
Plugins augmenting Tester.prototype share a flat namespace. Two strategies prevent collisions:
-
Use specific names. Prefer
t.spawnBin()overt.spawn(),t.withTempDir()overt.tempDir(). -
Group under a sub-object. Register a single method that returns a namespace:
registerTesterMethod('proc', function () { return { spawnBin: (...args) => spawnBin(...args), which: name => /* ... */ }; }); // Usage: const proc = t.proc(); await proc.spawnBin('ls');
The second pattern keeps top-level t.* clean when a plugin contributes many helpers.
See also
- Tester — full list of built-in methods on
Tester.prototype. - 3rd-party assertion libraries — for assertion libs that don't need a plugin (they throw, tape-six catches).
src/OK.js— the in-tree worked example.