tape-six 1.11.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,8 @@ 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`._
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._
430
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._
431
433
  - 1.10.1 _A workaround for a Bun's bug with streams._
432
434
  - 1.10.0 _Worker control channel stops in-flight workers, draining cleanup before a force-kill._
package/TESTING.md CHANGED
@@ -649,7 +649,7 @@ In this example, running `tape6` on Node will execute tests matching `/tests/nod
649
649
 
650
650
  ## Browser testing
651
651
 
652
- Browser tests use `tape6-server`, a static file server bundled with `tape-six` that provides a web UI for running tests.
652
+ Browser tests use `tape6-server`, a pluggable test server bundled with `tape-six` that serves static files and provides a web UI for running tests. Fixture plugins can be mounted under URL prefixes (`--plugin`, the `tape6.server.plugins` config, or `PUT /--plugins` at runtime), the core is embeddable in tests via `createTestServer()` from `tape-six/test-server.js`, and `--h2` switches to HTTP/2 (Node-only). See the [tape6-server wiki page](https://github.com/uhop/tape-six/wiki/Utility-%E2%80%90-tape6%E2%80%90server) for the plugin contract.
653
653
 
654
654
  ### Setup
655
655
 
@@ -1,26 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import http from 'node:http';
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
3
  import process from 'node:process';
7
4
  import {fileURLToPath} from 'node:url';
8
5
 
9
- import {
10
- getConfig,
11
- resolveTests,
12
- resolvePatterns,
13
- printVersion,
14
- printHelp
15
- } from '../src/utils/config.js';
16
- import {startServer} from '../src/server.js';
17
-
18
- const fsp = fs.promises;
19
-
20
- const toPosix = files =>
21
- path.sep === path.win32.sep
22
- ? files.map(f => f.replaceAll(path.win32.sep, path.posix.sep))
23
- : files;
6
+ import {printVersion, printHelp} from '../src/utils/config.js';
7
+ import {createTestServer} from '../src/test-server.js';
8
+ import {detectColors, makePaint} from '../src/test-server/trace.js';
24
9
 
25
10
  const showSelf = () => {
26
11
  const self = new URL(import.meta.url);
@@ -32,17 +17,23 @@ const showSelf = () => {
32
17
  process.exit(0);
33
18
  };
34
19
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
35
- printHelp('tape6-server', 'Static file server for browser testing', 'tape6-server [options]', [
20
+ printHelp('tape6-server', 'Pluggable test server for browser testing', 'tape6-server [options]', [
21
+ ['--plugin path', 'Register a plugin module (repeatable)'],
22
+ ['--h2', 'Serve HTTPS with HTTP/2 (+ HTTP/1.1 via ALPN); Node only'],
23
+ ['--no-remote-plugins', 'Disable PUT/DELETE on /--plugins'],
36
24
  ['--trace', 'Enable request trace logging'],
37
25
  ['--self', 'Print the path to this script and exit'],
38
26
  ['--help, -h', 'Show this help message and exit'],
39
27
  ['--version, -v', 'Show version and exit']
40
28
  ]);
41
29
  console.log('\nEnvironment:');
42
- console.log(' HOST Server hostname (default: localhost)');
43
- console.log(' PORT Server port (default: 3000)');
44
- console.log(' SERVER_ROOT Root directory for serving files (default: cwd)');
45
- console.log(' WEBAPP_PATH Path to the web app directory');
30
+ console.log(' HOST Server hostname (default: localhost)');
31
+ console.log(' PORT Server port (default: 3000)');
32
+ console.log(' SERVER_ROOT Root directory for serving files (default: cwd)');
33
+ console.log(' WEBAPP_PATH Path to the web app directory');
34
+ console.log(' TAPE6_PROTOCOL Protocol: h1 or h2 (default: h1)');
35
+ console.log(' TAPE6_CERT Path to a TLS certificate (h2)');
36
+ console.log(' TAPE6_KEY Path to its private key (h2)');
46
37
  process.exit(0);
47
38
  }
48
39
  if (process.argv.includes('--version') || process.argv.includes('-v')) {
@@ -51,198 +42,79 @@ if (process.argv.includes('--version') || process.argv.includes('-v')) {
51
42
  }
52
43
  if (process.argv.includes('--self')) showSelf();
53
44
 
54
- const mimeTable = {
55
- css: 'text/css',
56
- csv: 'text/csv',
57
- eot: 'application/vnd.ms-fontobject',
58
- gif: 'image/gif',
59
- html: 'text/html',
60
- ico: 'image/vnd.microsoft.icon',
61
- jpg: 'image/jpeg',
62
- js: 'text/javascript',
63
- json: 'application/json',
64
- otf: 'font/otf',
65
- png: 'image/png',
66
- svg: 'image/svg+xml',
67
- ttf: 'font/ttf',
68
- txt: 'text/plain',
69
- webp: 'image/webp',
70
- woff: 'font/woff',
71
- woff2: 'font/woff2',
72
- xml: 'application/xml'
73
- },
74
- defaultMime = 'application/octet-stream',
75
- rootFolder = process.env.SERVER_ROOT || process.cwd(),
76
- traceCalls = process.argv.includes('--trace'),
77
- hasColors =
78
- process.stdout.isTTY &&
79
- (typeof process.stdout.hasColors == 'function' ? process.stdout.hasColors() : true);
80
-
81
- let webAppPath = process.env.WEBAPP_PATH;
82
- if (!webAppPath) {
83
- const url = import.meta.url;
84
- if (!/^file:\/\//i.test(url))
85
- throw Error('Cannot identify the location of the web application. Use WEBAPP_PATH.');
86
- webAppPath = path.relative(
87
- rootFolder,
88
- path.join(path.dirname(fileURLToPath(url)), '../web-app/')
89
- );
90
- if (path.sep === path.win32.sep)
91
- webAppPath = webAppPath.replaceAll(path.win32.sep, path.posix.sep);
92
- }
93
-
94
- const mimeAliases = {mjs: 'js', cjs: 'js', htm: 'html', jpeg: 'jpg'};
95
- Object.keys(mimeAliases).forEach(name => (mimeTable[name] = mimeTable[mimeAliases[name]]));
96
-
97
- const join = (...args) => args.map(value => value || '').join(''),
98
- paint = hasColors
99
- ? (prefix, suffix = '\x1B[39m') =>
100
- text =>
101
- join(prefix, text, suffix)
102
- : () => text => text,
103
- grey = paint('\x1B[2;37m', '\x1B[22;39m'),
104
- red = paint('\x1B[41;97m', '\x1B[49;39m'),
105
- green = paint('\x1B[32m'),
106
- yellow = paint('\x1B[93m'),
107
- blue = paint('\x1B[44;97m', '\x1B[49;39m');
108
-
109
- const link = (url, text = url) => paint('\x1B]8;;' + url + '\x1B\\', '\x1B]8;;\x1B\\')(text);
110
-
111
- const sendFile = (req, res, fileName, ext, justHeaders) => {
112
- if (!ext) {
113
- ext = path.extname(fileName).toLowerCase();
114
- }
115
- let mime = ext && mimeTable[ext.substring(1)];
116
- if (!mime || typeof mime != 'string') {
117
- mime = defaultMime;
118
- }
119
- res.writeHead(200, {'Content-Type': mime});
120
- if (justHeaders) {
121
- res.end();
122
- } else {
123
- fs.createReadStream(fileName).pipe(res);
124
- }
125
- traceCalls && console.log(green('200') + ' ' + grey(req.method) + ' ' + grey(req.url));
126
- };
127
-
128
- const sendJson = (req, res, json, justHeaders) => {
129
- res.writeHead(200, {'Content-Type': 'application/json'});
130
- if (justHeaders) {
131
- res.end();
132
- } else {
133
- res.end(JSON.stringify(json));
134
- }
135
- traceCalls && console.log(green('200') + ' ' + grey(req.method) + ' ' + grey(req.url));
136
- };
137
-
138
- const sendRedirect = (req, res, to, code = 307) => {
139
- res.writeHead(code, {Location: to});
140
- res.end();
141
- traceCalls && console.log(blue(code) + ' ' + grey(req.method) + ' ' + grey(req.url));
142
- };
143
-
144
- const bailOut = (req, res, code = 404) => {
145
- res.writeHead(code).end();
146
- traceCalls && console.log(red(code) + ' ' + grey(req.method) + ' ' + grey(req.url));
147
- };
148
-
149
- const server = http.createServer(async (req, res) => {
150
- const method = req.method.toUpperCase();
151
- if (method !== 'GET' && method !== 'HEAD') return bailOut(req, res, 405);
152
-
153
- const url = new URL(req.url, 'http://' + req.headers.host);
154
- if (url.pathname === '/--tests') {
155
- return sendJson(
156
- req,
157
- res,
158
- toPosix(await resolveTests(rootFolder, 'browser')),
159
- method === 'HEAD'
160
- );
161
- }
162
- if (url.pathname === '/--patterns') {
163
- return sendJson(
164
- req,
165
- res,
166
- toPosix(await resolvePatterns(rootFolder, url.searchParams.getAll('q'))),
167
- method === 'HEAD'
168
- );
169
- }
170
- if (url.pathname === '/--importmap') {
171
- const cfg = await getConfig(rootFolder);
172
- return sendJson(req, res, cfg.importmap || {imports: {}}, method === 'HEAD');
173
- }
174
- if (url.pathname === '/favicon.ico') {
175
- const faviconFile = path.join(rootFolder, webAppPath, 'favicon.ico');
176
- const stat = await fsp.stat(faviconFile).catch(() => null);
177
- if (stat && stat.isFile()) return sendFile(req, res, faviconFile, '.ico', method === 'HEAD');
178
- return bailOut(req, res);
179
- }
180
- if (url.pathname === '/' || url.pathname === '/index' || url.pathname === '/index.html') {
181
- url.pathname = webAppPath;
182
- return sendRedirect(req, res, url.href);
183
- }
184
-
185
- if (path.normalize(url.pathname).includes('..')) return bailOut(req, res, 403);
186
-
187
- const fileName = path.join(rootFolder, url.pathname),
188
- ext = path.extname(fileName).toLowerCase(),
189
- stat = await fsp.stat(fileName).catch(() => null);
190
- if (stat && stat.isFile()) return sendFile(req, res, fileName, ext, method === 'HEAD');
191
-
192
- if (stat && stat.isDirectory()) {
193
- if (fileName.length && fileName[fileName.length - 1] == path.sep) {
194
- const altFile = path.join(fileName, 'index.html'),
195
- stat = await fsp.stat(altFile).catch(() => null);
196
- if (stat && stat.isFile()) return sendFile(req, res, altFile, '.html', method === 'HEAD');
197
- } else {
198
- url.pathname += path.posix.sep;
199
- return sendRedirect(req, res, url.href);
45
+ const plugins = [];
46
+ let traceCalls = false,
47
+ h2 = false,
48
+ remotePlugins = true;
49
+ {
50
+ const args = process.argv.slice(2);
51
+ for (let i = 0; i < args.length; ++i) {
52
+ const arg = args[i];
53
+ if (arg === '--trace') {
54
+ traceCalls = true;
55
+ } else if (arg === '--h2') {
56
+ h2 = true;
57
+ } else if (arg === '--no-remote-plugins') {
58
+ remotePlugins = false;
59
+ } else if (arg === '--plugin') {
60
+ if (++i < args.length) plugins.push(args[i]);
61
+ } else if (arg.startsWith('--plugin=')) {
62
+ plugins.push(arg.substring('--plugin='.length));
200
63
  }
201
- return bailOut(req, res);
202
- }
203
-
204
- if (!ext && fileName.length && fileName[fileName.length - 1] != path.sep) {
205
- const altFile = fileName + '.html',
206
- stat = await fsp.stat(altFile).catch(() => null);
207
- if (stat && stat.isFile()) return sendFile(req, res, altFile, '.html', method === 'HEAD');
208
64
  }
209
-
210
- bailOut(req, res);
211
- });
212
-
213
- server.on('clientError', (error, socket) => {
214
- if (error.code === 'ECONNRESET' || !socket.writable) return;
215
- socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
216
- });
65
+ }
217
66
 
218
67
  const normalizePort = val => {
219
68
  const port = parseInt(val);
220
69
  if (isNaN(port)) return val; // named pipe
221
- if (port >= 0) return port; // port number
70
+ if (port >= 0) return port;
222
71
  return false;
223
72
  };
224
73
 
225
74
  const portToString = port => (typeof port === 'string' ? 'pipe' : 'port') + ' ' + port;
226
75
 
227
76
  const host = process.env.HOST || 'localhost',
228
- port = normalizePort(process.env.PORT || '3000');
77
+ port = normalizePort(process.env.PORT || '3000'),
78
+ rootFolder = process.env.SERVER_ROOT || process.cwd(),
79
+ protocol = h2 ? 'h2' : process.env.TAPE6_PROTOCOL || undefined;
80
+
81
+ const hasColors = detectColors(),
82
+ paint = makePaint(hasColors),
83
+ grey = paint('\x1B[2;37m', '\x1B[22;39m'),
84
+ red = paint('\x1B[41;97m', '\x1B[49;39m'),
85
+ yellow = paint('\x1B[93m');
86
+
87
+ const link = (url, text = url) => paint('\x1B]8;;' + url + '\x1B\\', '\x1B]8;;\x1B\\')(text);
229
88
 
230
89
  try {
231
- await startServer(server, {host, port});
232
- const bind = portToString(port);
90
+ const testServer = await createTestServer({
91
+ rootFolder,
92
+ webAppPath: process.env.WEBAPP_PATH,
93
+ host,
94
+ port,
95
+ protocol,
96
+ plugins,
97
+ remotePlugins,
98
+ trace: traceCalls
99
+ });
100
+ const bind = portToString(testServer.port);
233
101
  console.log(
234
102
  grey('Listening on ') +
235
103
  yellow(host || 'all network interfaces') +
104
+ yellow(' (' + testServer.protocol + ')') +
236
105
  grey(' at ') +
237
106
  yellow(bind) +
238
107
  grey(', serving static files from ') +
239
108
  yellow(rootFolder)
240
109
  );
241
- if (host && bind) {
110
+ const mounted = testServer.plugins();
111
+ if (mounted.length) {
242
112
  console.log(
243
- grey('Open ') + link('http://' + host + ':' + port + '/') + grey(' in your browser')
113
+ grey('Plugins: ') +
114
+ yellow(mounted.map(p => p.name + (p.prefix ? ' (' + p.prefix + ')' : '')).join(', '))
244
115
  );
245
116
  }
117
+ console.log(grey('Open ') + link(testServer.base + '/') + grey(' in your browser'));
246
118
  console.log();
247
119
  } catch (error) {
248
120
  if (error.syscall === 'listen') {
package/index.d.ts CHANGED
@@ -8,11 +8,7 @@
8
8
  * - `null` or any primitive — strict equality.
9
9
  */
10
10
  export type AssertionMatcher =
11
- | (new (...args: any[]) => Error)
12
- | RegExp
13
- | ((value: unknown) => boolean)
14
- | object
15
- | null;
11
+ (new (...args: any[]) => Error) | RegExp | ((value: unknown) => boolean) | object | null;
16
12
 
17
13
  /**
18
14
  * Test options
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/llms-full.txt CHANGED
@@ -335,21 +335,100 @@ Sequential in-process runner. Same options as `tape6` but no threads.
335
335
 
336
336
  ### tape6-server
337
337
 
338
- Web server for browser testing:
338
+ Pluggable test server for browser testing: static files + web UI + fixture plugins.
339
339
 
340
340
  ```bash
341
341
  tape6-server [options...]
342
342
  ```
343
343
 
344
+ - `--plugin path` — register a plugin module (repeatable; path relative to the server root).
345
+ - `--h2` — serve HTTPS with HTTP/2 + HTTP/1.1 via ALPN (Node-only server mode).
346
+ - `--no-remote-plugins` — disable `PUT`/`DELETE` on `/--plugins`.
344
347
  - `--trace` — enable request trace logging.
345
348
  - `--self` — print the path to this script and exit.
346
349
  - `--help`, `-h` — show help message and exit.
347
350
  - `--version`, `-v` — show version and exit.
348
351
 
349
352
  Configured via environment variables: `HOST` (default: localhost), `PORT` (default: 3000),
350
- `SERVER_ROOT` (default: cwd), `WEBAPP_PATH`.
353
+ `SERVER_ROOT` (default: cwd), `WEBAPP_PATH`, `TAPE6_PROTOCOL` (`h1`/`h2`),
354
+ `TAPE6_CERT`/`TAPE6_KEY` (TLS cert/key paths for h2; auto-generates a self-signed cert via
355
+ openssl when omitted).
351
356
  Navigate to `http://localhost:3000` for the web UI.
352
357
 
358
+ Request routing: reserved control endpoints (`/--tests`, `/--patterns`, `/--importmap`,
359
+ `/--plugins`) → plugins by longest prefix → static files (GET/HEAD only; plugins see all verbs).
360
+
361
+ ## Test server plugins and embedding
362
+
363
+ Fixture plugins let real-wire tests (streaming, SSE, stateful ETag resources, upload sinks)
364
+ run against `tape6-server`. A plugin module default-exports an async factory returning a
365
+ handler record:
366
+
367
+ ```js
368
+ export default async function fixtures(api, options) {
369
+ // api: {rootFolder, config, base, protocol, log, trace}
370
+ return {
371
+ name: 'fixtures', // registry key (defaults to the module/function name)
372
+ prefix: '/--fixtures/', // claimed namespace, longest-prefix routed
373
+ fetch(request, api) {
374
+ // WHATWG Request → Response | (async) iterable | undefined (= pass to the next plugin)
375
+ return Response.json({ok: true});
376
+ },
377
+ close() {} // optional teardown: runs on deregistration and server shutdown
378
+ };
379
+ }
380
+ ```
381
+
382
+ - Request bodies are Web streams (`duplex: 'half'`); `Response` stream bodies are written
383
+ unbuffered; client disconnect fires `request.signal`.
384
+ - `fetch` may return an (async) iterable instead of a `Response`: `Uint8Array` chunks pass
385
+ through, strings are UTF-8 encoded, other values become JSONL lines. A module may
386
+ default-export a bare (async) generator function:
387
+
388
+ ```js
389
+ export default async function* numbers({url}) {
390
+ const n = Number(new URL(url).searchParams.get('n')) || 10;
391
+ for (let i = 0; i < n; ++i) yield {n: i}; // streamed as application/x-ndjson
392
+ }
393
+ ```
394
+
395
+ - `raw(req, res, api)` is an escape hatch on raw Node objects; returning exactly `false`
396
+ passes the request on.
397
+ - A tiny reference plugin ships in-tree: `tape-six/test-server/plugins/echo.js` mounts
398
+ `/--echo` and reflects method/query/headers/body as JSON.
399
+
400
+ Registration channels:
401
+
402
+ - **Static:** `package.json` → `"tape6": {"server": {"plugins": ["path", {"module": "path", "options": {}}]}}`
403
+ or the repeatable `--plugin` CLI flag.
404
+ - **Dynamic (wire):** `GET /--plugins` lists `{name, prefix, source}`; `PUT /--plugins` with
405
+ `{"module": "path", "options": {}}` mounts a module (resolved against the server root and
406
+ contained to it; re-PUT replaces by name and closes the old instance); `DELETE
407
+ /--plugins/<name>` deregisters. Disable mutations with `--no-remote-plugins`.
408
+
409
+ Embedding (hermetic Node/Bun/Deno integration tests; kernel-assigned ports make parallel test
410
+ files collision-free):
411
+
412
+ ```js
413
+ import {createTestServer, withTestServer} from 'tape-six/test-server.js';
414
+
415
+ const {base, close} = await createTestServer({rootFolder, plugins: [myPlugin], port: 0});
416
+ // ... fetch(base + '/--fixtures/...') ...
417
+ await close();
418
+
419
+ // or scoped:
420
+ await withTestServer({rootFolder, plugins: [myPlugin]}, async base => {
421
+ // server closes when the handler settles
422
+ });
423
+ ```
424
+
425
+ Inline plugins (factories, records, generator functions) are accepted in the `plugins` array
426
+ and via `server.register(spec)`. Other config keys under `tape6.server`: `protocol`
427
+ (`"h1"`/`"h2"`), `cert`/`key` (TLS paths), `remotePlugins` (boolean), `webAppPath`.
428
+
429
+ Stateful fixtures should key state by an explicit `scope` (query param or `X-Scope` header)
430
+ minted per test, so parallel workers sharing one server don't collide.
431
+
353
432
  ### Runtime-specific runners
354
433
 
355
434
  - `tape6-node` — Node.js runner.
@@ -389,6 +468,8 @@ Browser: `http://localhost:3000/?flags=FO`
389
468
  - `TAPE6_TEST_FILE_NAME` — set by runners to identify the current test file.
390
469
  - `TAPE6_GRACE_TIMEOUT` — ms a worker gets to drain (run cleanup, flush) after the runner asks it to stop, before being force-killed (default: 5000).
391
470
  - `TAPE6_WORKER_TIMEOUT` — per-file wall-clock budget in ms; on expiry the runner stops that worker (default: 0, disabled). The complement to the per-test `timeout` option: this one can stop a worker that ignores `t.signal`.
471
+ - `TAPE6_PROTOCOL` — test server protocol: `h1` (default) or `h2`.
472
+ - `TAPE6_CERT` / `TAPE6_KEY` — TLS certificate/key paths for the test server's h2 mode.
392
473
 
393
474
  ## Browser testing
394
475
 
package/llms.txt CHANGED
@@ -235,6 +235,25 @@ npx tape6-seq # run sequentially (no worker threads)
235
235
  npx tape6-server --trace # start browser test server
236
236
  ```
237
237
 
238
+ ### Test server (fixtures + embedding)
239
+
240
+ `tape6-server` is pluggable: fixture plugins mount under URL prefixes (`--plugin path`,
241
+ config `tape6.server.plugins`, or `PUT /--plugins` at runtime) and handle requests with a
242
+ WHATWG `fetch(request) → Response | (async) iterable | undefined` contract; an (async)
243
+ generator function streams JSONL. `--h2` switches to HTTPS with HTTP/2 (Node-only). For
244
+ hermetic integration tests embed it on an OS-assigned port:
245
+
246
+ ```js
247
+ import {createTestServer, withTestServer} from 'tape-six/test-server.js';
248
+
249
+ await withTestServer({plugins: [myPlugin]}, async base => {
250
+ const res = await fetch(base + '/--fixtures/data?n=3');
251
+ // ...
252
+ });
253
+ ```
254
+
255
+ Full plugin contract: llms-full.txt § Test server plugins and embedding.
256
+
238
257
  ## Environment variables
239
258
 
240
259
  - `TAPE6_FLAGS` — flags string.
@@ -245,6 +264,8 @@ npx tape6-server --trace # start browser test server
245
264
  - `TAPE6_TEST_FILE_NAME` — set by runners to identify the current test file.
246
265
  - `TAPE6_GRACE_TIMEOUT` — ms a worker gets to drain (run cleanup, flush) after the runner asks it to stop, before being force-killed (default: 5000).
247
266
  - `TAPE6_WORKER_TIMEOUT` — per-file wall-clock budget in ms; on expiry the runner stops that worker (default: 0, disabled). Complements the per-test `timeout` option: this one can stop a worker that ignores `t.signal`.
267
+ - `TAPE6_PROTOCOL` — test server protocol: `h1` (default) or `h2`.
268
+ - `TAPE6_CERT` / `TAPE6_KEY` — TLS certificate/key paths for the test server's h2 mode.
248
269
 
249
270
  ## Configuration (package.json)
250
271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tape-six",
3
- "version": "1.11.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",
@@ -103,7 +103,8 @@
103
103
  }
104
104
  },
105
105
  "devDependencies": {
106
- "@types/node": "^26.0.1",
106
+ "@types/node": "^26.1.0",
107
+ "prettier": "^3.9.4",
107
108
  "typescript": "^6.0.3"
108
109
  }
109
110
  }
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();
@@ -5,7 +5,7 @@ import EventServer from '../../utils/EventServer.js';
5
5
  const srcName = new URL('../../', import.meta.url),
6
6
  baseName = Bun.pathToFileURL(Bun.cwd + sep);
7
7
 
8
- export default class TestWorker extends EventServer {
8
+ export class TestWorker extends EventServer {
9
9
  constructor(reporter, numberOfTasks = globalThis.navigator?.hardwareConcurrency || 1, options) {
10
10
  super(reporter, numberOfTasks, options);
11
11
  this.counter = 0;
@@ -74,13 +74,10 @@ export default 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});
@@ -102,3 +99,5 @@ export default class TestWorker extends EventServer {
102
99
  }
103
100
  }
104
101
  }
102
+
103
+ export default TestWorker;
@@ -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();
@@ -6,7 +6,7 @@ import EventServer from '../../utils/EventServer.js';
6
6
  const srcName = new URL('../../', import.meta.url),
7
7
  baseName = pathToFileURL(Deno.cwd() + sep);
8
8
 
9
- export default class TestWorker extends EventServer {
9
+ export class TestWorker extends EventServer {
10
10
  constructor(reporter, numberOfTasks = globalThis.navigator?.hardwareConcurrency || 1, options) {
11
11
  super(reporter, numberOfTasks, options);
12
12
  this.counter = 0;
@@ -57,13 +57,10 @@ export default 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});
@@ -85,3 +82,5 @@ export default class TestWorker extends EventServer {
85
82
  }
86
83
  }
87
84
  }
85
+
86
+ export default TestWorker;