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 into tape-six core.
  • Domain-specific assertion helpers (HTTP, database, filesystem) that compose existing t.* calls.
  • Reporting helpers that emit comment/stdout/assert events through t.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:

  1. Use specific names. Prefer t.spawnBin() over t.spawn(), t.withTempDir() over t.tempDir().

  2. 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