tape-six 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -427,6 +427,7 @@ Test output can be controlled by flags. See [Supported flags](https://github.com
427
427
 
428
428
  The most recent releases:
429
429
 
430
+ - 1.13.0 _TypeScript declarations for the `EventServer`._
430
431
  - 1.12.0 _`tape6-server` is now pluggable, embeddable server for hermetic integration tests, runtime plugin registration, and an opt-in HTTP/2 mode._
431
432
  - 1.11.0 _Invariant host hooks for the new zero-dependency `tape-six-invariant` companion package. Workaround for a Deno 2.9.0 `node_modules/.bin` symlink regression._
432
433
  - 1.10.1 _A workaround for a Bun's bug with streams._
@@ -67,7 +67,7 @@ let traceCalls = false,
67
67
  const normalizePort = val => {
68
68
  const port = parseInt(val);
69
69
  if (isNaN(port)) return val; // named pipe
70
- if (port >= 0) return port; // port number
70
+ if (port >= 0) return port;
71
71
  return false;
72
72
  };
73
73
 
package/index.js CHANGED
@@ -175,9 +175,7 @@ const init = async () => {
175
175
 
176
176
  if (isBrowser && typeof window.addEventListener == 'function') {
177
177
  // Control plane: the parent page posts `tape6-terminate` to drain a running
178
- // test (failOnce / bail / worker deadline). The reporter arms stopTest +
179
- // fires the abort signal so the test unwinds. There is no in-page
180
- // force-kill — see dev-docs/worker-control-channel.md.
178
+ // test; there is no in-page force-kill see dev-docs/worker-control-channel.md.
181
179
  window.addEventListener('message', event => {
182
180
  if (event.data?.type === 'tape6-terminate') getReporter()?.terminate();
183
181
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tape-six",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "TAP-inspired unit test library for Node, Deno, Bun, and browsers. ES modules, TypeScript, zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/State.js CHANGED
@@ -184,9 +184,8 @@ export class State {
184
184
  typeof event.diffTime !== 'number' && (event.diffTime = event.time - this.startTime);
185
185
  }
186
186
 
187
- // Stack walking is only needed when the event will be reported as a failure.
188
- // Reporters (TapReporter, TTYReporter) only read event.at / event.stackList
189
- // inside `if (event.fail)` blocks, so for passing assertions this is wasted work.
187
+ // reporters read event.at / event.stackList only for failures stack
188
+ // walking on passing assertions is wasted work
190
189
  if (isFailed) {
191
190
  if (
192
191
  event.type === 'assert' &&
package/src/Tester.js CHANGED
@@ -460,10 +460,7 @@ export class Tester {
460
460
  }
461
461
  Tester.prototype.any = Tester.prototype._ = any;
462
462
 
463
- // Idempotent registration of a method on Tester.prototype. Same name + same
464
- // function → no-op; same name + different function → throws. Lets plugins
465
- // extend the tester (e.g., spawnBin, withTempDir, waitFor) without colliding
466
- // silently when two of them claim the same name.
463
+ // plugin extension point a same-name collision must fail loudly, not silently override
467
464
  export const registerTesterMethod = (name, fn) => {
468
465
  if (typeof name !== 'string' || !name)
469
466
  throw new TypeError('registerTesterMethod: name must be a non-empty string');
@@ -15,9 +15,6 @@ export class Reporter {
15
15
  this.terminating = false;
16
16
  }
17
17
 
18
- // Emit one record to stdout, mirroring console.log's arguments. On Bun,
19
- // console.log to a subprocess-piped stdout is lossy / stalls under load, so route
20
- // through process.stdout.write there; Node/Deno keep the proven console.log path.
21
18
  writeOut(...args) {
22
19
  if (isBun) {
23
20
  process.stdout.write(args.map(a => (typeof a == 'string' ? a : String(a))).join(' ') + '\n');
@@ -34,14 +31,9 @@ export class Reporter {
34
31
  this.state?.abort();
35
32
  }
36
33
 
37
- // Control plane: a `terminate` command reached this worker (failOnce / bail
38
- // drain, or a worker deadline). Arm stopTest and fire the abort signal across
39
- // the live state chain so a running test unwinds — StopTest at the next
40
- // assertion, t.signal rejects signal-aware awaits — and remember it so any
41
- // test that starts afterwards stops at its first assertion too (closing the
42
- // race where `terminate` lands while the worker is still starting up). The
43
- // test's cleanup (finally / afterEach / afterAll) still runs. See
44
- // dev-docs/worker-control-channel.md.
34
+ // `terminating` is remembered so a test that starts after the terminate stops
35
+ // too closes the race where `terminate` lands mid-startup. Cleanup
36
+ // (finally / afterEach / afterAll) still runs. See dev-docs/worker-control-channel.md.
45
37
  terminate() {
46
38
  this.terminating = true;
47
39
  for (let state = this.state; state; state = state.parent) {
@@ -61,8 +53,7 @@ export class Reporter {
61
53
  timer: this.timer
62
54
  });
63
55
  ++this.depth;
64
- // A terminate landed before this test started — stop it at its first
65
- // assertion rather than letting it run to completion.
56
+ // a terminate landed before this test started
66
57
  if (this.terminating) {
67
58
  this.state.stopTest = true;
68
59
  this.state.abort();
@@ -74,13 +74,10 @@ export class TestWorker extends EventServer {
74
74
  const worker = this.idToWorker[id];
75
75
  if (!worker) return;
76
76
  if (reason === 'done') {
77
- // The test already finished; just tear the worker down.
78
77
  this.#kill(id);
79
78
  return;
80
79
  }
81
- // Cooperative drain (abort): ask the child to fire its abort signal and run
82
- // cleanup hooks, then force-kill as a backstop after graceTimeout in case
83
- // the test ignores the signal and never settles.
80
+ // force-kill backstop: the test may ignore the abort signal and never settle
84
81
  if (this.graceTimers[id]) return; // already draining
85
82
  try {
86
83
  worker.postMessage({type: 'terminate', reason});
@@ -38,9 +38,7 @@ let currentReporter = null,
38
38
 
39
39
  addEventListener('message', async event => {
40
40
  const msg = event.data;
41
- // Control plane: drain on `terminate`. The reporter arms stopTest + fires the
42
- // abort signal so a running test unwinds (cleanup runs); a terminate that
43
- // lands before the reporter exists is remembered and applied at startup.
41
+ // a terminate can land before the reporter exists remember it, apply at startup
44
42
  if (msg?.type === 'terminate') {
45
43
  pendingTerminate = true;
46
44
  currentReporter?.terminate();
@@ -57,13 +57,10 @@ export class TestWorker extends EventServer {
57
57
  const worker = this.idToWorker[id];
58
58
  if (!worker) return;
59
59
  if (reason === 'done') {
60
- // The test already finished; just tear the worker down.
61
60
  this.#kill(id);
62
61
  return;
63
62
  }
64
- // Cooperative drain (abort): ask the child to fire its abort signal and run
65
- // cleanup hooks, then force-kill as a backstop after graceTimeout in case
66
- // the test ignores the signal and never settles.
63
+ // force-kill backstop: the test may ignore the abort signal and never settle
67
64
  if (this.graceTimers[id]) return; // already draining
68
65
  try {
69
66
  worker.postMessage({type: 'terminate', reason});
@@ -38,9 +38,7 @@ let currentReporter = null,
38
38
 
39
39
  addEventListener('message', async event => {
40
40
  const msg = event.data;
41
- // Control plane: drain on `terminate`. The reporter arms stopTest + fires the
42
- // abort signal so a running test unwinds (cleanup runs); a terminate that
43
- // lands before the reporter exists is remembered and applied at startup.
41
+ // a terminate can land before the reporter exists remember it, apply at startup
44
42
  if (msg?.type === 'terminate') {
45
43
  pendingTerminate = true;
46
44
  currentReporter?.terminate();
@@ -76,14 +76,11 @@ export class TestWorker extends EventServer {
76
76
  const worker = this.idToWorker[id];
77
77
  if (!worker) return;
78
78
  if (reason === 'done') {
79
- // The test already finished; worker_threads have no flush race, so just
80
- // tear the worker down.
79
+ // worker_threads have no flush race
81
80
  this.#kill(id);
82
81
  return;
83
82
  }
84
- // Cooperative drain (abort): ask the child to fire its abort signal and run
85
- // cleanup hooks, then force-kill as a backstop after graceTimeout in case
86
- // the test ignores the signal and never settles.
83
+ // force-kill backstop: the test may ignore the abort signal and never settle
87
84
  if (this.graceTimers[id]) return; // already draining
88
85
  try {
89
86
  worker.postMessage({type: 'terminate', reason});
@@ -40,9 +40,7 @@ let currentReporter = null,
40
40
  pendingTerminate = false;
41
41
 
42
42
  parentPort.on('message', async msg => {
43
- // Control plane: drain on `terminate`. The reporter arms stopTest + fires the
44
- // abort signal so a running test unwinds (cleanup runs); a terminate that
45
- // lands before the reporter exists is remembered and applied at startup.
43
+ // a terminate can land before the reporter exists remember it, apply at startup
46
44
  if (msg?.type === 'terminate') {
47
45
  pendingTerminate = true;
48
46
  currentReporter?.terminate();
@@ -62,10 +62,8 @@ export class TestWorker extends EventServer {
62
62
  }
63
63
  destroyTask(id, reason = 'done') {
64
64
  if (reason !== 'done') {
65
- // Abort trigger (failOnce / bail / worker deadline). The test runs in this
66
- // same process, so "terminate" is just reporter.terminate(): arm stopTest
67
- // + fire the abort signal. The run unwinds and closes itself with 'done',
68
- // where the reset below happens.
65
+ // same process, no force-kill the aborted run closes itself with 'done',
66
+ // where the reset below happens
69
67
  this.reporter.terminate();
70
68
  return;
71
69
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Teardown reason delivered to `destroyTask`:
3
+ * - `'done'` — the task finished; tear its worker down now.
4
+ * - `'failOnce'` — stop / bail-out: abort an in-flight task.
5
+ * - `'timeout'` — the per-worker deadline (`workerTimeout`) fired.
6
+ *
7
+ * Any reason but `'done'` means: drain cooperatively (let cleanup run),
8
+ * then force-kill after `graceTimeout` where the transport allows.
9
+ */
10
+ export type DestroyReason = 'done' | 'failOnce' | 'timeout';
11
+
12
+ /**
13
+ * A test event routed to the reporter — a loose bag (`type`, `test`,
14
+ * `name`, `marker`, `operator`, `fail`, `data`, ...). See TESTING.md.
15
+ */
16
+ export type TestEvent = {[key: string]: unknown};
17
+
18
+ /**
19
+ * The reporter surface `EventServer` needs. Structural — every tape-six
20
+ * reporter satisfies it.
21
+ */
22
+ export interface EventServerReporter {
23
+ report(event: TestEvent, suppressStopTest?: boolean): void;
24
+ state?: {stopTest?: boolean; [key: string]: unknown} | null;
25
+ }
26
+
27
+ /**
28
+ * Options consumed by the base class. Runners pass their own keys through
29
+ * (`flags`, `importmap`, `serverUrl`, `browser`, ...) — hence the index
30
+ * signature.
31
+ */
32
+ export interface EventServerOptions {
33
+ /**
34
+ * Cooperative-drain window in ms before a force-kill (default: 5000).
35
+ * CLI runners inject `TAPE6_GRACE_TIMEOUT` via `getOptions()`.
36
+ */
37
+ graceTimeout?: number;
38
+ /**
39
+ * Per-worker deadline in ms; unset or 0 disables it. CLI runners inject
40
+ * `TAPE6_WORKER_TIMEOUT` via `getOptions()`.
41
+ */
42
+ workerTimeout?: number;
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ /**
47
+ * Base class for test-worker drivers (the parallel/seq runners and the
48
+ * `tape-six-proc` / `tape-six-puppeteer` / `tape-six-playwright` siblings).
49
+ *
50
+ * Two planes: DATA (worker → reporter — the `report()`/`close()`
51
+ * event-ordering machinery) and CONTROL (reporter/runner → worker — a
52
+ * single `terminate` delivered per transport by `destroyTask`). See
53
+ * dev-docs/worker-control-channel.md.
54
+ *
55
+ * Subclasses implement the two transport hooks: `makeTask` and
56
+ * `destroyTask`.
57
+ */
58
+ export class EventServer {
59
+ constructor(reporter: EventServerReporter, numberOfTasks?: number, options?: EventServerOptions);
60
+
61
+ reporter: EventServerReporter;
62
+ numberOfTasks: number;
63
+ options: EventServerOptions;
64
+
65
+ /** Drain window in ms before a force-kill; see `EventServerOptions.graceTimeout`. */
66
+ graceTimeout: number;
67
+ /** Per-worker deadline in ms; 0 = off; see `EventServerOptions.workerTimeout`. */
68
+ workerTimeout: number;
69
+ /**
70
+ * Set on the first stop / bail-out signal; every in-flight task then gets
71
+ * `destroyTask(id, 'failOnce')`. Tasks that finish starting later must
72
+ * check it themselves (a just-created worker can miss the sweep).
73
+ */
74
+ stopRequested: boolean;
75
+
76
+ /** Completion hook: called once when the last task closes. Assigned by runners. */
77
+ done?: () => void;
78
+
79
+ // DATA-plane bookkeeping — not a consumer surface.
80
+ totalTasks: number;
81
+ fileQueue: string[];
82
+ passThroughId: string | null;
83
+ retained: Record<string, TestEvent[]>;
84
+ readyQueue: TestEvent[][];
85
+ liveTasks: Set<string>;
86
+ deadlineTimers: Record<string, ReturnType<typeof setTimeout>>;
87
+
88
+ /** DATA plane: forward one worker event, preserving per-task ordering. */
89
+ report(id: string, event: TestEvent): void;
90
+ /**
91
+ * DATA plane: a task's stream ended — release its retained events, start
92
+ * the next queued file, and call `destroyTask(id, 'done')`.
93
+ */
94
+ close(id: string): void;
95
+
96
+ /** Start a worker for `fileName` now if a slot is free, else queue it. */
97
+ createTask(fileName: string): void;
98
+ /** `createTask` every file. */
99
+ execute(files: string[]): void;
100
+
101
+ /**
102
+ * Transport hook: start a worker for `fileName` and return its task id.
103
+ * The base implementation is a no-op returning `undefined`.
104
+ */
105
+ makeTask(fileName: string): string | null | undefined;
106
+ /**
107
+ * Transport hook (CONTROL plane): deliver `terminate` to one worker.
108
+ * The base implementation is a no-op; see `DestroyReason` for the
109
+ * expected teardown semantics.
110
+ */
111
+ destroyTask(id: string, reason?: DestroyReason): void;
112
+ }
113
+
114
+ export default EventServer;
@@ -1,3 +1,5 @@
1
+ // @ts-self-types="./EventServer.d.ts"
2
+
1
3
  import defer from './defer.js';
2
4
 
3
5
  // Fallback used when no graceTimeout is supplied through options (e.g. the
@@ -5,15 +7,9 @@ import defer from './defer.js';
5
7
  // (TAPE6_GRACE_TIMEOUT) via getOptions(); see src/utils/config.js.
6
8
  const DEFAULT_GRACE_TIMEOUT = 5_000;
7
9
 
8
- // EventServer owns two planes. The DATA plane (worker -> reporter) is the
9
- // report()/close() event-ordering machinery. The CONTROL plane
10
- // (reporter/runner -> worker) is a single command, `terminate`, delivered per
11
- // transport by destroyTask(id, reason). Two triggers fire it:
12
- // - normal completion: close() terminates the just-finished worker ('done');
13
- // - stop / bail-out: when reporter.state.stopTest is set (failOnce / bail),
14
- // every in-flight worker is terminated ('failOnce').
15
- // An optional per-worker wall-clock deadline (workerTimeout, Layer 2) fires the
16
- // same command on expiry ('timeout'). See dev-docs/worker-control-channel.md.
10
+ // Two planes: DATA (worker -> reporter, the report()/close() event-ordering
11
+ // machinery) and CONTROL (reporter/runner -> worker, a single `terminate`
12
+ // delivered per transport by destroyTask). See dev-docs/worker-control-channel.md.
17
13
  export class EventServer {
18
14
  constructor(reporter, numberOfTasks = 1, options = {}) {
19
15
  this.reporter = reporter;
@@ -112,9 +108,7 @@ export class EventServer {
112
108
  execute(files) {
113
109
  files.forEach(fileName => this.createTask(fileName));
114
110
  }
115
- // Spawn one worker and register it for control-plane tracking. Routes both
116
- // the initial batch (createTask) and queue drains (close) so liveTasks and
117
- // the optional deadline cover every task, not just the first numberOfTasks.
111
+ // createTask and close() both route here so liveTasks + the deadline cover every task
118
112
  #startTask(fileName) {
119
113
  const id = this.makeTask(fileName);
120
114
  if (id == null) return id;
@@ -134,9 +128,6 @@ export class EventServer {
134
128
  delete this.deadlineTimers[id];
135
129
  }
136
130
  }
137
- // Terminate every in-flight worker (abort) on top of the existing "stop
138
- // scheduling new files." Fires at most once per run; callers decide when a
139
- // stop / bail-out signal has been observed.
140
131
  #requestStop() {
141
132
  if (this.stopRequested) return;
142
133
  this.stopRequested = true;
@@ -170,9 +170,6 @@ export const getReporterFileName = type => {
170
170
  export const DEFAULT_START_TIMEOUT = 5_000;
171
171
  export const DEFAULT_GRACE_TIMEOUT = 5_000;
172
172
 
173
- // Read a positive-millisecond env var, falling back to `fallback` when it is
174
- // unset, non-numeric, non-positive, or Infinity. Always returns `fallback` in
175
- // the browser, where these env vars don't exist.
176
173
  const readTimeoutEnv = (name, fallback) => {
177
174
  if (runtime.name === 'browser') return fallback;
178
175
  const value = runtime.getEnvVar(name);
@@ -192,8 +189,7 @@ export const getTimeoutValue = () =>
192
189
  // allows. See dev-docs/worker-control-channel.md.
193
190
  export const getGraceTimeout = () => readTimeoutEnv('TAPE6_GRACE_TIMEOUT', DEFAULT_GRACE_TIMEOUT);
194
191
 
195
- // Optional wall-clock budget per worker/file (Layer 2 termination). 0 disables
196
- // it — the default — so a worker deadline fires only when explicitly set.
192
+ // Optional wall-clock budget per worker/file (Layer 2 termination); 0 (default) disables it.
197
193
  export const getWorkerTimeout = () => readTimeoutEnv('TAPE6_WORKER_TIMEOUT', 0);
198
194
 
199
195
  export const flagNames = Object.fromEntries(
@@ -322,8 +318,7 @@ export const getOptions = extraOptions => {
322
318
  }
323
319
  options.parallel = parallel;
324
320
 
325
- // Control-channel budgets ride along in the options bag handed to the
326
- // TestWorker (EventServer). Children ignore them; only the parent acts.
321
+ // children ignore these; only the parent (EventServer) acts on them
327
322
  options.flags.graceTimeout = getGraceTimeout();
328
323
  options.flags.workerTimeout = getWorkerTimeout();
329
324
 
@@ -99,9 +99,7 @@ export const listenControlChannel = async getReporter => {
99
99
  }
100
100
  if (buffer) handleLine(buffer);
101
101
 
102
- // Control-channel EOF. If no explicit terminate arrived (parent died / pipe
103
- // broke), soft-terminate so a still-running test drains. Then fall through:
104
- // the loop empties and the runtime exits naturally, flushing stdout.
102
+ // EOF soft-terminate per the header contract
105
103
  if (!terminated) getReporter()?.terminate();
106
104
  armWatchdog(graceTimeout, getExitCode);
107
105
  };
@@ -89,11 +89,9 @@ export default class TestWorker extends EventServer {
89
89
  this.#removeIframe(id);
90
90
  return;
91
91
  }
92
- // Cooperative drain only — in-page JS can't force-kill a hung iframe script.
93
- // postMessage `terminate` so a cooperative test unwinds and runs its cleanup
94
- // hooks; if it doesn't exit within graceTimeout, remove the iframe as a
95
- // best-effort backstop. A driver-backed run (puppeteer / playwright) can do
96
- // a real kill from Node — not wired here. See dev-docs/worker-control-channel.md.
92
+ // in-page JS can't force-kill a hung iframe script — iframe removal after
93
+ // graceTimeout is a best-effort backstop; a real kill needs a driver
94
+ // (puppeteer / playwright). See dev-docs/worker-control-channel.md.
97
95
  if (this.graceTimers[id]) return;
98
96
  const iframe = document.getElementById('test-iframe-' + id);
99
97
  if (!iframe) return;