tape-six 1.9.0 → 1.10.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
@@ -122,6 +122,8 @@ See how it can be used in [tests/](https://github.com/uhop/tape-six/tree/master/
122
122
 
123
123
  For AI assistants: see [llms.txt](https://github.com/uhop/tape-six/blob/master/llms.txt) and [llms-full.txt](https://github.com/uhop/tape-six/blob/master/llms-full.txt) for LLM-optimized documentation.
124
124
 
125
+ When you use `tape-six` as a dependency, point your AI coding agent at the guides shipped inside `node_modules/tape-six/`: [`TESTING.md`](https://github.com/uhop/tape-six/blob/master/TESTING.md) and the `skills/` directory (`write-tests`, `run-tests`). They are written so an agent can write and run `tape-six` tests correctly without fetching anything.
126
+
125
127
  The whole API is based on two objects: `test` and `Tester`.
126
128
 
127
129
  ### `test`
@@ -425,7 +427,8 @@ Test output can be controlled by flags. See [Supported flags](https://github.com
425
427
 
426
428
  The most recent releases:
427
429
 
428
- - 1.9.0 _New features: `t.plan(n)` emits a TAP-comment diagnostic on mismatch, `registerTesterMethod(name, fn)` registers tester plugins idempotently. New wiki: 3rd-party library catalog and "Writing plugins" page. Removed `chai` from dev dependencies._
430
+ - 1.10.0 _Worker control channel stops in-flight workers, draining cleanup before a force-kill._
431
+ - 1.9.0 _New features: `t.plan(n)` emits a TAP-comment diagnostic on mismatch, `registerTesterMethod(name, fn)` registers tester plugins idempotently._
429
432
  - 1.8.0 _New subpath modules: `tape-six/server` (HTTP server harness) and `tape-six/response` (response reading helpers for `Response` and `IncomingMessage`)._
430
433
  - 1.7.14 _Updated vendored `deep6` to 1.2.0._
431
434
  - 1.7.13 _Replaced `process.exit()` / `Deno.exit()` with `process.exitCode` in test runners and `index.js` for graceful stdout flushing. Updated dependencies, GitHub Actions._
package/TESTING.md CHANGED
@@ -335,127 +335,6 @@ test('suite B', dbOpts, async t => {
335
335
  });
336
336
  ```
337
337
 
338
- ## Testing HTTP code
339
-
340
- Two subpath modules ship for tests that need HTTP-shaped fixtures. Both work on Node, Bun, and Deno via `node:http`. Browsers don't get them — running an HTTP server inside a webpage isn't a use case.
341
-
342
- > **Placement matters.** Test files that import `tape-six/server`, `tape-six/response`, or any other node-only API (e.g. `node:http`, `node:fs`, `node:child_process`) **must not** match a `tape6.tests` pattern that's universal to all environments — the browser worker would try to load them and fail with `Worker error` on the missing built-in. Put them in a CLI-only location (e.g. `tests/cli/test-*.js`) and add the matching glob to `tape6.cli` so it covers Node/Bun/Deno but not browsers. See [Configuring test discovery](#configuring-test-discovery).
343
-
344
- ### `tape-six/server` — server harness
345
-
346
- ```js
347
- import {withServer, startServer, setupServer} from 'tape-six/server';
348
- ```
349
-
350
- Three exports for three lifetimes. `withServer` is the per-test scoped resource (95% case). `startServer` is the procedural primitive. `setupServer` registers `beforeAll`/`afterAll` for suite-shared servers.
351
-
352
- **Per-test server** — start before the test body, tear down in `finally`:
353
-
354
- ```js
355
- import test from 'tape-six';
356
- import {withServer} from 'tape-six/server';
357
-
358
- test('GET / returns 200', t =>
359
- withServer(myHandler, async base => {
360
- const res = await fetch(`${base}/`);
361
- t.equal(res.status, 200);
362
- }));
363
- ```
364
-
365
- `myHandler` is a `(req, res) => void` callback (`http.RequestListener`). `base` is the bound URL, e.g. `"http://127.0.0.1:54321"`. Default host is `'127.0.0.1'` (explicit IPv4); pass `{host, port}` to override.
366
-
367
- `withServer` handles the symmetric case where the test makes requests directly. It's also the right tool when the SUT is something else (a CLI spawned in a subprocess) and the handler is a mock — the test body just looks different:
368
-
369
- ```js
370
- test('CLI hits the API correctly', t =>
371
- withServer(mockApi, async base => {
372
- const result = await spawnCli({env: {API_URL: base}});
373
- t.equal(result.exitCode, 0);
374
- t.equal(recordedRequests.length, 1);
375
- }));
376
- ```
377
-
378
- Either side may be the SUT. The names `serverHandler` / `clientHandler` reflect role on the wire, not which side is being tested.
379
-
380
- **Suite-shared server** — register hooks once, share across many tests:
381
-
382
- ```js
383
- import test, {beforeEach} from 'tape-six';
384
- import {setupServer} from 'tape-six/server';
385
-
386
- let recorded;
387
- const server = setupServer((req, res) => {
388
- recorded.push({method: req.method, url: req.url});
389
- res.writeHead(204).end();
390
- });
391
- beforeEach(() => {
392
- recorded = [];
393
- });
394
-
395
- test('records exactly one request', async t => {
396
- await fetch(`${server.base}/foo`);
397
- t.equal(recorded.length, 1);
398
- });
399
-
400
- test('records the path correctly', async t => {
401
- await fetch(`${server.base}/bar`);
402
- t.equal(recorded[0].url, '/bar');
403
- });
404
- ```
405
-
406
- The returned handle has live getters — `server.base` reads the current state on each access. **Don't destructure** at module load (`const {base} = setupServer(...)`); `base` is `undefined` until `beforeAll` runs. Per-test state reset (the `recorded = []` above) is user-side via your own `beforeEach`; `setupServer` owns the server lifecycle, you own state.
407
-
408
- **Procedural primitive** — for multi-phase tests or non-test code:
409
-
410
- ```js
411
- import http from 'node:http';
412
- import {startServer} from 'tape-six/server';
413
-
414
- const lc = await startServer(http.createServer(handler), {host, port});
415
- try {
416
- // ... lc.base, lc.port, lc.host ...
417
- } finally {
418
- await lc.close();
419
- }
420
- ```
421
-
422
- `startServer` accepts a fully-constructed server (so the caller can attach `'clientError'` listeners or configure TLS first). It races `'listening'` against `'error'` so port-busy / `EACCES` rejects with the original error rather than hanging — the failure mode existing helpers across the ecosystem suffer from on busy CI machines.
423
-
424
- `close()` is idempotent and calls `server.closeAllConnections()` (when available, Node 18.2+) so keep-alive sockets from `fetch` don't delay teardown.
425
-
426
- ### `tape-six/response` — response reading helpers
427
-
428
- ```js
429
- import {asText, asJson, asBytes, header, headers} from 'tape-six/response';
430
- ```
431
-
432
- Five reading helpers that work uniformly on both W3C `Response` (from `fetch`) and Node `http.IncomingMessage` (from `http.request`).
433
-
434
- ```js
435
- import test from 'tape-six';
436
- import {withServer} from 'tape-six/server';
437
- import {asJson, header} from 'tape-six/response';
438
-
439
- test('GET / returns JSON', t =>
440
- withServer(handler, async base => {
441
- const res = await fetch(`${base}/`);
442
- t.equal(res.status, 200);
443
- t.match(header(res, 'content-type'), /^application\/json/);
444
- const body = await asJson(res);
445
- t.deepEqual(body, {ok: true});
446
- }));
447
- ```
448
-
449
- | Helper | Returns | Notes |
450
- | ------------------- | ------------------------ | ----------------------------------- |
451
- | `asText(res)` | `Promise<string>` | UTF-8 |
452
- | `asJson(res)` | `Promise<unknown>` | parses JSON |
453
- | `asBytes(res)` | `Promise<Uint8Array>` | raw bytes |
454
- | `header(res, name)` | `string \| null` | case-insensitive single-header read |
455
- | `headers(res)` | `Record<string, string>` | all headers, lowercase keys |
456
-
457
- For status code, just use `res.status` (W3C `Response`) or `res.statusCode` (Node `IncomingMessage`) directly — no helper needed.
458
-
459
338
  ## Migrating from other test frameworks
460
339
 
461
340
  `tape-six` supports `describe`/`it` and `before`/`after` aliases. When called inside a test body, all top-level functions automatically delegate to the current tester. This means migration from Mocha, Jest, or `node:test` is nearly mechanical — change the import and swap assertions.
package/index.js CHANGED
@@ -162,6 +162,33 @@ const init = async () => {
162
162
  setCurrentReporter?.(reporter);
163
163
  }
164
164
 
165
+ if (isBrowser && typeof window.addEventListener == 'function') {
166
+ // Control plane: the parent page posts `tape6-terminate` to drain a running
167
+ // test (failOnce / bail / worker deadline). The reporter arms stopTest +
168
+ // fires the abort signal so the test unwinds. There is no in-page
169
+ // force-kill — see dev-docs/worker-control-channel.md.
170
+ window.addEventListener('message', event => {
171
+ if (event.data?.type === 'tape6-terminate') getReporter()?.terminate();
172
+ });
173
+ }
174
+
175
+ if (isCli) {
176
+ // Control plane (proc transport): a child spawned by tape-six-proc is
177
+ // marked with TAPE6_CONTROL. Open the stdin control channel so the parent
178
+ // can drain / terminate it, and so it stays alive after `end` until told to
179
+ // exit — see dev-docs/worker-control-channel.md.
180
+ const controlled =
181
+ (isDeno && Deno.env.get('TAPE6_CONTROL')) ||
182
+ (isBun && Bun.env.TAPE6_CONTROL) ||
183
+ (isNode && process.env.TAPE6_CONTROL);
184
+ if (controlled) {
185
+ const {listenControlChannel} = await import(
186
+ new URL('./src/utils/control-channel.js', import.meta.url)
187
+ );
188
+ listenControlChannel(getReporter);
189
+ }
190
+ }
191
+
165
192
  return {options, isBrowser, isBun, isDeno, isNode, isCli};
166
193
  };
167
194
 
package/llms-full.txt CHANGED
@@ -279,108 +279,6 @@ test('cli', async t => {
279
279
 
280
280
  Plugin installation is **per-file**. `tape6-seq` runs all files in one process; `tape6` uses workers; `tape6-proc` (from the sister package `tape-six-proc`) uses subprocesses; browser tests run in iframes. Each isolation context has its own module graph, so a plugin import in file A is invisible to file B when they're in different contexts. Every test file that uses a plugin must import it directly. `registerTesterMethod`'s idempotency makes repeated imports safe. See [Writing plugins](https://github.com/uhop/tape-six/wiki/Writing-plugins) for the full guide.
281
281
 
282
- ## Subpath modules
283
-
284
- Two helper modules ship as separate subpath imports for tests that need HTTP-shaped fixtures. Both are cross-runtime (Node, Bun, Deno via `node:http`); browsers don't need them because running an HTTP server inside a webpage isn't a use case.
285
-
286
- ### tape-six/server — HTTP server harness
287
-
288
- ```js
289
- import {withServer, startServer, setupServer} from 'tape-six/server';
290
- ```
291
-
292
- #### `withServer(serverHandler, clientHandler, opts?)` — scoped resource (95% case)
293
-
294
- Spins up an `http.Server` with `serverHandler`, runs `clientHandler` with the bound base URL, tears down in `finally`. Cleanup runs whether `clientHandler` resolves, rejects, or throws synchronously. Returns whatever `clientHandler` returns.
295
-
296
- ```js
297
- test('GET / returns 200', t =>
298
- withServer(handler, async base => {
299
- const res = await fetch(`${base}/`);
300
- t.equal(res.status, 200);
301
- }));
302
- ```
303
-
304
- `serverHandler` is the per-request callback Node invokes on each incoming request. `clientHandler` is the per-scope test body, called once with `(base, lifecycle)` — naming reflects role on the wire (server side / client side), not which side is the SUT. Either side may be the code under test.
305
-
306
- `clientHandler` may make HTTP requests itself (`fetch(base)`-style), or set up a separate SUT that does (e.g., a spawned CLI given `${base}` as its endpoint env var), or do neither (multi-phase setup + assertions).
307
-
308
- #### `startServer(server, opts?)` — procedural primitive
309
-
310
- For multi-phase tests that span the lifecycle, or non-test code (e.g. `bin/tape6-server`) that wants long-term control. Accepts a fully-constructed `http.Server` (so callers can attach `'clientError'` handlers, configure TLS, etc., before listen).
311
-
312
- ```js
313
- const lc = await startServer(http.createServer(handler), {host, port});
314
- // ... use lc.base ...
315
- await lc.close();
316
- ```
317
-
318
- Returns a lifecycle handle:
319
-
320
- - `server` — the underlying `http.Server`.
321
- - `base` — bound base URL, e.g. `"http://127.0.0.1:54321"`.
322
- - `port` — actual bound port (OS-assigned when `port: 0`).
323
- - `host` — bound host.
324
- - `close()` — idempotent. Calls `server.closeAllConnections()` (when available) so keep-alive sockets don't delay teardown.
325
-
326
- Races `'listening'` against `'error'`: port-busy / `EACCES` rejects with the original error rather than hanging. Existing helpers across the toolkit ecosystem don't do this and have hung CI machines as a result.
327
-
328
- #### `setupServer(serverHandler, opts?)` — hook helper
329
-
330
- Registers `beforeAll` (start) and `afterAll` (close) and returns a live-getter handle for suite-shared servers:
331
-
332
- ```js
333
- const server = setupServer(handler);
334
-
335
- test('first', async t => {
336
- const res = await fetch(`${server.base}/foo`);
337
- });
338
-
339
- test('second', async t => {
340
- const res = await fetch(`${server.base}/bar`);
341
- });
342
- ```
343
-
344
- The returned object is `Object.freeze`d and uses property getters that read the live lifecycle. Don't destructure at module load (`const {base} = setupServer(...)`) — `base` is `undefined` until `beforeAll` runs.
345
-
346
- State reset stays user-side. For mock-server scenarios that need per-test state clearing, compose your own `beforeEach`:
347
-
348
- ```js
349
- const server = setupServer((req, res) => {
350
- recorded.push({method: req.method, url: req.url});
351
- res.writeHead(204).end();
352
- });
353
- let recorded;
354
- beforeEach(() => { recorded = []; });
355
- ```
356
-
357
- `setupServer` owns the suite lifecycle; the caller owns suite state.
358
-
359
- #### Options
360
-
361
- ```ts
362
- interface ServerOptions {
363
- host?: string; // default '127.0.0.1' — explicit IPv4 avoids dual-stack ambiguity
364
- port?: number; // default 0 — OS-assigned
365
- }
366
- ```
367
-
368
- ### tape-six/response — HTTP response helpers
369
-
370
- Reading helpers that work uniformly with both `Response` (fetch results) and Node `http.IncomingMessage`:
371
-
372
- ```js
373
- import {asText, asJson, asBytes, header, headers} from 'tape-six/response';
374
- ```
375
-
376
- - `asText(res)` → `Promise<string>` — body as UTF-8.
377
- - `asJson(res)` → `Promise<unknown>` — body parsed as JSON.
378
- - `asBytes(res)` → `Promise<Uint8Array>` — body as raw bytes.
379
- - `header(res, name)` → `string | null` — case-insensitive single-header read. Array-valued `IncomingMessage` headers (e.g. `set-cookie`) are joined with `, `.
380
- - `headers(res)` → `Record<string, string>` — all headers as a plain object with lowercase keys.
381
-
382
- For status code, just use `res.status` — same on both `Response` and `IncomingMessage`.
383
-
384
282
  ## Configuring tests
385
283
 
386
284
  Configuration is read from `tape6.json` or the `"tape6"` section of `package.json`:
@@ -480,6 +378,8 @@ Browser: `http://localhost:3000/?flags=FO`
480
378
  - `TAPE6_JSONL` — force JSONL reporter (any non-empty value).
481
379
  - `TAPE6_MIN` — force minimal reporter (any non-empty value).
482
380
  - `TAPE6_TEST_FILE_NAME` — set by runners to identify the current test file.
381
+ - `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).
382
+ - `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`.
483
383
 
484
384
  ## Browser testing
485
385
 
package/llms.txt CHANGED
@@ -143,37 +143,6 @@ test('suite', async t => {
143
143
  test.beforeAll(() => { /* ... */ });
144
144
  ```
145
145
 
146
- ## Subpath modules
147
-
148
- ### tape-six/server — HTTP server harness
149
-
150
- Helpers for tests that need an ephemeral `node:http` server. Cross-runtime (Node, Bun, Deno; not for browser-side tests).
151
-
152
- ```js
153
- import {withServer, startServer, setupServer} from 'tape-six/server';
154
- ```
155
-
156
- - `withServer(serverHandler, clientHandler, opts?)` — scoped resource: spin up a server, run the test body with the base URL, tear down in `finally`. The 95% case.
157
- - `startServer(server, opts?)` — procedural primitive: returns `{server, base, port, host, close}`. For multi-phase tests or non-test code (e.g., `bin/tape6-server`) that wants long-term control. Races `'listening'` against `'error'` so port-busy rejects instead of hanging.
158
- - `setupServer(serverHandler, opts?)` — registers `beforeAll`/`afterAll` and returns a live-getter context for suite-shared servers. Don't destructure the returned object at module load — properties read live state on each access.
159
- - Options: `{host = '127.0.0.1', port = 0}`. Default host is explicit IPv4.
160
-
161
- `serverHandler` is the per-request callback (Node calls it once per incoming request). `clientHandler` is the per-scope test body (called once with the base URL). Either side may be the SUT.
162
-
163
- ### tape-six/response — HTTP response helpers
164
-
165
- Reading helpers that work uniformly with both `Response` (fetch results) and `http.IncomingMessage` (Node low-level requests).
166
-
167
- ```js
168
- import {asText, asJson, asBytes, header, headers} from 'tape-six/response';
169
- ```
170
-
171
- - `asText(res)` — body as UTF-8 string.
172
- - `asJson(res)` — body parsed as JSON.
173
- - `asBytes(res)` — body as `Uint8Array`.
174
- - `header(res, name)` — single header value, case-insensitive. Returns `null` if absent.
175
- - `headers(res)` — all headers as a plain object with lowercase keys.
176
-
177
146
  ## Common patterns
178
147
 
179
148
  ### Basic test file
@@ -248,41 +217,6 @@ describe('my module', () => {
248
217
  });
249
218
  ```
250
219
 
251
- ### Testing HTTP code with `withServer`
252
-
253
- ```js
254
- import test from 'tape-six';
255
- import {withServer} from 'tape-six/server';
256
- import {asJson} from 'tape-six/response';
257
-
258
- test('GET /users returns the list', t =>
259
- withServer(myHandler, async base => {
260
- const res = await fetch(`${base}/users`);
261
- t.equal(res.status, 200);
262
- const body = await asJson(res);
263
- t.equal(body.length, 3);
264
- }));
265
- ```
266
-
267
- Suite-shared server (multiple tests against one instance):
268
-
269
- ```js
270
- import test, {beforeEach} from 'tape-six';
271
- import {setupServer} from 'tape-six/server';
272
-
273
- const server = setupServer((req, res) => {
274
- recorded.push({method: req.method, url: req.url});
275
- res.writeHead(204).end();
276
- });
277
- let recorded;
278
- beforeEach(() => { recorded = []; });
279
-
280
- test('records one request', async t => {
281
- await fetch(`${server.base}/foo`);
282
- t.equal(recorded.length, 1);
283
- });
284
- ```
285
-
286
220
  ### Skip and TODO
287
221
 
288
222
  ```js
@@ -307,6 +241,8 @@ npx tape6-server --trace # start browser test server
307
241
  - `TAPE6_JSONL` — force JSONL reporter (any non-empty value).
308
242
  - `TAPE6_MIN` — force minimal reporter (any non-empty value).
309
243
  - `TAPE6_TEST_FILE_NAME` — set by runners to identify the current test file.
244
+ - `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).
245
+ - `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`.
310
246
 
311
247
  ## Configuration (package.json)
312
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tape-six",
3
- "version": "1.9.0",
3
+ "version": "1.10.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",
@@ -8,15 +8,6 @@
8
8
  "types": "index.d.ts",
9
9
  "exports": {
10
10
  ".": "./index.js",
11
- "./server": {
12
- "types": "./src/server.d.ts",
13
- "default": "./src/server.js"
14
- },
15
- "./response": {
16
- "types": "./src/response.d.ts",
17
- "default": "./src/response.js"
18
- },
19
- "./bin/*": "./bin/*",
20
11
  "./*": "./src/*"
21
12
  },
22
13
  "bin": {
@@ -112,7 +103,7 @@
112
103
  }
113
104
  },
114
105
  "devDependencies": {
115
- "@types/node": "^25.6.0",
106
+ "@types/node": "^25.9.1",
116
107
  "typescript": "^6.0.3"
117
108
  }
118
109
  }
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: run-tests
3
+ description: Set up `npm test` and run tape-six tests for a project. Use when configuring a project's test scripts in package.json, picking a runner, or debugging test invocation.
4
+ ---
5
+
6
+ # Run Tests
7
+
8
+ Configure and invoke tape-six tests for a project that uses `tape-six`.
9
+
10
+ ## What the runner is for
11
+
12
+ **tape-six test files are directly executable** — `node tests/test-foo.js` (or `bun tests/test-foo.js`, `deno run -A tests/test-foo.js`) works without any runner, no transpilation, no harness wrapping the test code. This is a defining property of tape-six.
13
+
14
+ The `tape6` runner exists only for **orchestration**: parallel execution via worker threads, glob-based test discovery, per-environment filtering (Node / Bun / Deno / browser), and aggregated reporting. When iterating on a single file, run it directly — it's faster and gives cleaner stack traces. Use the runner for `npm test` (full-suite invocation) and CI.
15
+
16
+ ## Steps
17
+
18
+ 1. **Install** tape-six as a dev dependency: `npm install -D tape-six`.
19
+
20
+ 2. **Add the test script** to `package.json#scripts`:
21
+
22
+ ```jsonc
23
+ {
24
+ "scripts": {
25
+ "test": "tape6 --flags FO"
26
+ }
27
+ }
28
+ ```
29
+
30
+ Optional siblings:
31
+ - `"test:seq": "tape6-seq --flags FO"` — sequential, in-process; easier to debug.
32
+ - `"test:bun": "tape6-bun --flags FO"` / `"test:deno": "tape6-deno --flags FO"` — explicit-runtime sweeps for cross-runtime libraries.
33
+ - `"start": "tape6-server --trace"` — browser-test HTTP harness.
34
+
35
+ 3. **Add the `tape6` config block** to `package.json` for test discovery:
36
+
37
+ ```jsonc
38
+ {
39
+ "tape6": {
40
+ "tests": ["/tests/test-*.js"],
41
+ "importmap": {
42
+ "imports": {
43
+ "my-package": "/index.js",
44
+ "my-package/": "/src/"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ Per-environment globs (`cli`, `node`, `bun`, `deno`, `browser`) are additive on top of `tests`. Use them when a test imports environment-specific APIs (e.g. `node:http` tests under `cli` so they don't run in the browser worker).
52
+
53
+ 4. **Verify**: `npm test`. To run a single file directly without the runner: `node tests/test-foo.js` (tape-six tests are directly executable; this is the fastest path when iterating on one file).
54
+
55
+ ## Default flags
56
+
57
+ `--flags FO` — **F**ailures only, fail **O**nce (quiet on green, stop on the first red). It is a recommended default, not a mandate; some teams prefer the verbose default (no flags) or just `F` without early-exit. Lowercase disables, so `--flags fo` inverts both for one run (shows passes, continues past failures). Override per-invocation with the `TAPE6_FLAGS` environment variable: `TAPE6_FLAGS=FO node tests/test-foo.js`. Other flag letters cover banner / time / data / numbers / color — see [Supported flags](https://github.com/uhop/tape-six/wiki/Supported-flags) for the full list and override precedence.
58
+
59
+ ## Runner family
60
+
61
+ Pick by environment, not preference. Default `tape6` auto-detects the runtime and routes appropriately; explicit pinning is for cross-runtime CI sweeps.
62
+
63
+ - **`tape6`** — default. Auto-detects Node / Bun / Deno.
64
+ - **`tape6-bun` / `tape6-deno` / `tape6-node`** — pin to a specific runtime (typically for cross-runtime CI matrices).
65
+ - **`tape6-seq`** — sequential, in-process. Use for debugging or when tests share state.
66
+ - **`tape6-server`** — browser-test HTTP harness. Run `npx tape6-server --trace`, then open `http://localhost:3000`.
67
+ - **`tape-six-proc`** (separate npm package) — process-per-file isolation when worker threads aren't enough.
68
+
69
+ ## Further reading (wiki)
70
+
71
+ For anything beyond this skill, consult the wiki — it has the long tail:
72
+
73
+ - [Set-up tests](https://github.com/uhop/tape-six/wiki/Set-up-tests) — full `tape6` config-block reference, every per-environment glob.
74
+ - [Supported flags](https://github.com/uhop/tape-six/wiki/Supported-flags) — every flag, every override layer.
75
+ - [Utility tape6](https://github.com/uhop/tape-six/wiki/Utility-‐-tape6) — runner CLI options, env vars (`TAPE6_FLAGS`, `TAPE6_PAR`, `TAPE6_WORKER_START_TIMEOUT`), parallelism control, `--info`.
76
+ - [Utility tape6-server](https://github.com/uhop/tape-six/wiki/Utility-‐-tape6‐server) — browser harness, query parameters, dev-time path mounts.
77
+ - [Environment — Node](https://github.com/uhop/tape-six/wiki/Environment-‐-Node) / [Bun](https://github.com/uhop/tape-six/wiki/Environment-‐-Bun) / [Deno](https://github.com/uhop/tape-six/wiki/Environment-‐-Deno) / [Browsers](https://github.com/uhop/tape-six/wiki/Environment-‐-Browsers) — runtime-specific guidance.
@@ -65,23 +65,17 @@ Write or update tests using the tape-six testing library.
65
65
  - `t.skipTest(msg)` — skip the _current_ test from inside it.
66
66
  - `t.bailOut(msg)` — stop the entire run (catastrophic).
67
67
  - `t.OK(expr, msg)` (aliases `t.TRUE`, `t.ASSERT`) — returns a code string for `eval()` that asserts an expression and dumps top-level variables on failure. Useful for compact arithmetic/state checks: `eval(t.OK('a + b === 3'))`. Do not use in CSP-restricted contexts.
68
- 10. **Testing HTTP code** — for tests that need an ephemeral HTTP server or want to read responses uniformly:
69
- - **`tape-six/server`** (`withServer` / `startServer` / `setupServer`) — wraps the `node:http` lifecycle. `withServer(serverHandler, clientHandler, opts?)` is the per-test scoped resource: starts a server, runs the test body with the bound base URL, tears down in `finally`. `setupServer(serverHandler, opts?)` registers `beforeAll`/`afterAll` and returns a live-getter handle for suite-shared servers (don't destructure at module load — properties read live state). `startServer(server, opts?)` is the procedural primitive for multi-phase tests. Default host is `'127.0.0.1'`. Cross-runtime (Node, Bun, Deno).
70
- - **`tape-six/response`** (`asText` / `asJson` / `asBytes` / `header` / `headers`) — reading helpers that work uniformly on both W3C `Response` (fetch results) and Node `http.IncomingMessage`.
71
- - Per-test mock-server state reset (e.g., clearing a `recorded[]` array) stays user-side: compose your own `beforeEach`. `setupServer` owns the server lifecycle; you own state.
72
- - **Placement:** these tests can't run in the browser worker — `node:http` doesn't exist there. Don't drop them into a folder matched by the universal `tape6.tests` glob; put them in a CLI-only path (e.g. `tests/cli/test-*.js`) and ensure the matching glob is in `tape6.cli` instead.
73
- - See "Testing HTTP code" in `TESTING.md` for examples.
74
- 11. **Browser-specific tests** — if the project uses browser testing with `tape6-server`. See "Browser testing" in `TESTING.md`.
68
+ 10. **Browser-specific tests** — if the project uses browser testing with `tape6-server`. See "Browser testing" in `TESTING.md`.
75
69
  - Browsers run `.js` and `.mjs` only — no TypeScript, no CommonJS.
76
70
  - Browsers can also run `.html` shim files (with inline importmap and `<script type="module">`).
77
71
  - Place browser-only files in `tests/browser/` and add patterns to `"browser"` in the `tape6` config.
78
72
  - Run: `npx tape6-server --trace`, then open `http://localhost:3000`. Use `?q=<glob>` to filter, `?flags=FO` and `?par=N` to control output and parallelism.
79
- 12. **Environment-specific tests** — the `tape6` config in `package.json` supports per-env patterns (`tests`, `cli`, `node`, `bun`, `deno`, `browser`). All are additive. See "Configuring test discovery" in `TESTING.md`.
80
- 13. **Verify (CLI):** run the new test file directly: `node tests/test-<name>.js`
73
+ 11. **Environment-specific tests** — the `tape6` config in `package.json` supports per-env patterns (`tests`, `cli`, `node`, `bun`, `deno`, `browser`). All are additive. See "Configuring test discovery" in `TESTING.md`.
74
+ 12. **Verify (CLI):** run the new test file directly: `node tests/test-<name>.js`
81
75
  - Run the full suite to check for regressions: `npm test`
82
76
  - For debugging, use `npm run test:seq` (sequential, in-process).
83
77
  - To see which files are being run, add `--flags fo` (overrides the default `--flags FO`).
84
78
  - To inspect the resolved config without running, use `npx tape6 --info`.
85
79
  - For tests with heavy `beforeAll` (Docker spawn, big fixture loads), bump the worker startup timer with `TAPE6_WORKER_START_TIMEOUT=60000`. The default is 5 s.
86
- 14. **Verify (browser):** start `npx tape6-server --trace`, then open `http://localhost:3000/?q=/tests/test-<name>.js` to run a specific file. Use multiple `?q=` parameters to run several files. Open `http://localhost:3000/` to run all configured tests.
87
- 15. Report results and any failures.
80
+ 13. **Verify (browser):** start `npx tape6-server --trace`, then open `http://localhost:3000/?q=/tests/test-<name>.js` to run a specific file. Use multiple `?q=` parameters to run several files. Open `http://localhost:3000/` to run all configured tests.
81
+ 14. Report results and any failures.
@@ -7,6 +7,7 @@ export class Reporter {
7
7
  this.state = null;
8
8
  this.depth = 0;
9
9
  this.timer = timer || getTimer();
10
+ this.terminating = false;
10
11
  }
11
12
 
12
13
  get signal() {
@@ -17,6 +18,22 @@ export class Reporter {
17
18
  this.state?.abort();
18
19
  }
19
20
 
21
+ // Control plane: a `terminate` command reached this worker (failOnce / bail
22
+ // drain, or a worker deadline). Arm stopTest and fire the abort signal across
23
+ // the live state chain so a running test unwinds — StopTest at the next
24
+ // assertion, t.signal rejects signal-aware awaits — and remember it so any
25
+ // test that starts afterwards stops at its first assertion too (closing the
26
+ // race where `terminate` lands while the worker is still starting up). The
27
+ // test's cleanup (finally / afterEach / afterAll) still runs. See
28
+ // dev-docs/worker-control-channel.md.
29
+ terminate() {
30
+ this.terminating = true;
31
+ for (let state = this.state; state; state = state.parent) {
32
+ state.stopTest = true;
33
+ state.abort();
34
+ }
35
+ }
36
+
20
37
  onTest(event) {
21
38
  this.state = new State(this.state, {
22
39
  name: event.name,
@@ -28,6 +45,12 @@ export class Reporter {
28
45
  timer: this.timer
29
46
  });
30
47
  ++this.depth;
48
+ // A terminate landed before this test started — stop it at its first
49
+ // assertion rather than letting it run to completion.
50
+ if (this.terminating) {
51
+ this.state.stopTest = true;
52
+ this.state.abort();
53
+ }
31
54
  if (typeof event.time != 'number') {
32
55
  event = {...event, time: this.state.time};
33
56
  }
@@ -10,6 +10,7 @@ export default class TestWorker extends EventServer {
10
10
  super(reporter, numberOfTasks, options);
11
11
  this.counter = 0;
12
12
  this.idToWorker = {};
13
+ this.graceTimers = {};
13
14
  }
14
15
  makeTask(fileName) {
15
16
  const testName = new URL(fileName, baseName),
@@ -69,7 +70,31 @@ export default class TestWorker extends EventServer {
69
70
  });
70
71
  return id;
71
72
  }
72
- destroyTask(id) {
73
+ destroyTask(id, reason = 'done') {
74
+ const worker = this.idToWorker[id];
75
+ if (!worker) return;
76
+ if (reason === 'done') {
77
+ // The test already finished; just tear the worker down.
78
+ this.#kill(id);
79
+ return;
80
+ }
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.
84
+ if (this.graceTimers[id]) return; // already draining
85
+ try {
86
+ worker.postMessage({type: 'terminate', reason});
87
+ } catch (e) {
88
+ void e;
89
+ }
90
+ this.graceTimers[id] = setTimeout(() => this.#kill(id), this.graceTimeout);
91
+ }
92
+ #kill(id) {
93
+ const grace = this.graceTimers[id];
94
+ if (grace) {
95
+ clearTimeout(grace);
96
+ delete this.graceTimers[id];
97
+ }
73
98
  const worker = this.idToWorker[id];
74
99
  if (worker) {
75
100
  worker.terminate();
@@ -33,14 +33,27 @@ const reportToParent = fileName => {
33
33
  };
34
34
  };
35
35
 
36
+ let currentReporter = null,
37
+ pendingTerminate = false;
38
+
36
39
  addEventListener('message', async event => {
37
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.
44
+ if (msg?.type === 'terminate') {
45
+ pendingTerminate = true;
46
+ currentReporter?.terminate();
47
+ return;
48
+ }
38
49
  try {
39
50
  const [{setReporter}, {ProxyReporter}] = await Promise.all([
40
51
  import(new URL('test.js', msg.srcName)),
41
52
  import(new URL('./reporters/ProxyReporter.js', msg.srcName))
42
53
  ]);
43
- setReporter(new ProxyReporter({...msg.options, reportTo: reportToParent(msg.fileName)}));
54
+ currentReporter = new ProxyReporter({...msg.options, reportTo: reportToParent(msg.fileName)});
55
+ setReporter(currentReporter);
56
+ if (pendingTerminate) currentReporter.terminate();
44
57
  await import(msg.testName);
45
58
  } catch (error) {
46
59
  postMessage({type: 'test', test: 0});
@@ -11,6 +11,7 @@ export default class TestWorker extends EventServer {
11
11
  super(reporter, numberOfTasks, options);
12
12
  this.counter = 0;
13
13
  this.idToWorker = {};
14
+ this.graceTimers = {};
14
15
  }
15
16
  makeTask(fileName) {
16
17
  const testName = new URL(fileName, baseName),
@@ -53,7 +54,31 @@ export default class TestWorker extends EventServer {
53
54
  });
54
55
  return id;
55
56
  }
56
- destroyTask(id) {
57
+ destroyTask(id, reason = 'done') {
58
+ const worker = this.idToWorker[id];
59
+ if (!worker) return;
60
+ if (reason === 'done') {
61
+ // The test already finished; just tear the worker down.
62
+ this.#kill(id);
63
+ return;
64
+ }
65
+ // Cooperative drain (abort): ask the child to fire its abort signal and run
66
+ // cleanup hooks, then force-kill as a backstop after graceTimeout in case
67
+ // the test ignores the signal and never settles.
68
+ if (this.graceTimers[id]) return; // already draining
69
+ try {
70
+ worker.postMessage({type: 'terminate', reason});
71
+ } catch (e) {
72
+ void e;
73
+ }
74
+ this.graceTimers[id] = setTimeout(() => this.#kill(id), this.graceTimeout);
75
+ }
76
+ #kill(id) {
77
+ const grace = this.graceTimers[id];
78
+ if (grace) {
79
+ clearTimeout(grace);
80
+ delete this.graceTimers[id];
81
+ }
57
82
  const worker = this.idToWorker[id];
58
83
  if (worker) {
59
84
  worker.terminate();
@@ -33,14 +33,27 @@ const reportToParent = fileName => {
33
33
  };
34
34
  };
35
35
 
36
+ let currentReporter = null,
37
+ pendingTerminate = false;
38
+
36
39
  addEventListener('message', async event => {
37
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.
44
+ if (msg?.type === 'terminate') {
45
+ pendingTerminate = true;
46
+ currentReporter?.terminate();
47
+ return;
48
+ }
38
49
  try {
39
50
  const [{setReporter}, {ProxyReporter}] = await Promise.all([
40
51
  import(new URL('test.js', msg.srcName)),
41
52
  import(new URL('./reporters/ProxyReporter.js', msg.srcName))
42
53
  ]);
43
- setReporter(new ProxyReporter({...msg.options, reportTo: reportToParent(msg.fileName)}));
54
+ currentReporter = new ProxyReporter({...msg.options, reportTo: reportToParent(msg.fileName)});
55
+ setReporter(currentReporter);
56
+ if (pendingTerminate) currentReporter.terminate();
44
57
  await import(msg.testName);
45
58
  } catch (error) {
46
59
  postMessage({type: 'test', test: 0});
@@ -13,6 +13,7 @@ export default class TestWorker extends EventServer {
13
13
  super(reporter, numberOfTasks, options);
14
14
  this.counter = 0;
15
15
  this.idToWorker = {};
16
+ this.graceTimers = {};
16
17
  }
17
18
  makeTask(fileName) {
18
19
  const testName = new URL(fileName, baseName),
@@ -71,7 +72,32 @@ export default class TestWorker extends EventServer {
71
72
  });
72
73
  return id;
73
74
  }
74
- destroyTask(id) {
75
+ destroyTask(id, reason = 'done') {
76
+ const worker = this.idToWorker[id];
77
+ if (!worker) return;
78
+ if (reason === 'done') {
79
+ // The test already finished; worker_threads have no flush race, so just
80
+ // tear the worker down.
81
+ this.#kill(id);
82
+ return;
83
+ }
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.
87
+ if (this.graceTimers[id]) return; // already draining
88
+ try {
89
+ worker.postMessage({type: 'terminate', reason});
90
+ } catch (e) {
91
+ void e;
92
+ }
93
+ this.graceTimers[id] = setTimeout(() => this.#kill(id), this.graceTimeout);
94
+ }
95
+ #kill(id) {
96
+ const grace = this.graceTimers[id];
97
+ if (grace) {
98
+ clearTimeout(grace);
99
+ delete this.graceTimers[id];
100
+ }
75
101
  const worker = this.idToWorker[id];
76
102
  if (worker) {
77
103
  worker.terminate();
@@ -36,13 +36,26 @@ const reportToParent = fileName => {
36
36
  };
37
37
  };
38
38
 
39
+ let currentReporter = null,
40
+ pendingTerminate = false;
41
+
39
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.
46
+ if (msg?.type === 'terminate') {
47
+ pendingTerminate = true;
48
+ currentReporter?.terminate();
49
+ return;
50
+ }
40
51
  try {
41
52
  const [{setReporter}, {ProxyReporter}] = await Promise.all([
42
53
  import(new URL('test.js', msg.srcName)),
43
54
  import(new URL('./reporters/ProxyReporter.js', msg.srcName))
44
55
  ]);
45
- setReporter(new ProxyReporter({...msg.options, reportTo: reportToParent(msg.fileName)}));
56
+ currentReporter = new ProxyReporter({...msg.options, reportTo: reportToParent(msg.fileName)});
57
+ setReporter(currentReporter);
58
+ if (pendingTerminate) currentReporter.terminate();
46
59
  await import(msg.testName);
47
60
  } catch (error) {
48
61
  parentPort.postMessage({type: 'test', test: 0});
@@ -60,7 +60,15 @@ export default class TestWorker extends EventServer {
60
60
  .catch(error => this.#reportError(id, error));
61
61
  return id;
62
62
  }
63
- destroyTask() {
63
+ destroyTask(id, reason = 'done') {
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.
69
+ this.reporter.terminate();
70
+ return;
71
+ }
64
72
  setReporter(this.reporter);
65
73
  setCurrentReporter(null);
66
74
  clearBeforeAll();
@@ -1,18 +1,47 @@
1
1
  import defer from './defer.js';
2
2
 
3
+ // Fallback used when no graceTimeout is supplied through options (e.g. the
4
+ // in-browser web-app worker). CLI runners inject the env-configurable value
5
+ // (TAPE6_GRACE_TIMEOUT) via getOptions(); see src/utils/config.js.
6
+ const DEFAULT_GRACE_TIMEOUT = 5_000;
7
+
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.
3
17
  export default class EventServer {
4
18
  constructor(reporter, numberOfTasks = 1, options = {}) {
5
19
  this.reporter = reporter;
6
20
  this.numberOfTasks = numberOfTasks;
7
21
  this.options = options;
8
22
 
23
+ this.graceTimeout = options.graceTimeout > 0 ? options.graceTimeout : DEFAULT_GRACE_TIMEOUT;
24
+ this.workerTimeout = options.workerTimeout > 0 ? options.workerTimeout : 0;
25
+
9
26
  this.totalTasks = 0;
10
27
  this.fileQueue = [];
11
28
  this.passThroughId = null;
12
29
  this.retained = {};
13
30
  this.readyQueue = [];
31
+
32
+ // control plane bookkeeping
33
+ this.liveTasks = new Set();
34
+ this.deadlineTimers = {};
35
+ this.stopRequested = false;
14
36
  }
15
37
  report(id, event) {
38
+ // Stop / bail-out trigger. React the moment the signal arrives, even if
39
+ // this worker's events are still buffered behind the pass-through worker
40
+ // (its stopTest would otherwise not reach reporter.state until it flushes,
41
+ // which can be many seconds later). Children pre-set event.stopTest; the
42
+ // in-process (seq) path sets it during the forward below, caught by the
43
+ // reporter.state check.
44
+ if (event && (event.stopTest || event.type === 'bail-out')) this.#requestStop();
16
45
  if (this.passThroughId === null) this.passThroughId = id;
17
46
  const events = this.retained[id];
18
47
  if (this.passThroughId === id) {
@@ -22,7 +51,9 @@ export default class EventServer {
22
51
  }
23
52
  delete this.retained[id];
24
53
  }
25
- return this.reporter.report(event, true);
54
+ this.reporter.report(event, true);
55
+ if (this.reporter.state?.stopTest) this.#requestStop();
56
+ return;
26
57
  }
27
58
  if (Array.isArray(events)) {
28
59
  events.push(event);
@@ -31,7 +62,9 @@ export default class EventServer {
31
62
  }
32
63
  }
33
64
  close(id) {
34
- this.destroyTask(id);
65
+ this.#clearDeadline(id);
66
+ this.liveTasks.delete(id);
67
+ this.destroyTask(id, 'done');
35
68
  --this.totalTasks;
36
69
  if (this.fileQueue.length) {
37
70
  if (this.reporter.state?.stopTest) {
@@ -39,7 +72,7 @@ export default class EventServer {
39
72
  } else {
40
73
  ++this.totalTasks;
41
74
  const nextFile = this.fileQueue.shift();
42
- defer(() => this.makeTask(nextFile));
75
+ defer(() => this.#startTask(nextFile));
43
76
  }
44
77
  }
45
78
  if (this.passThroughId === id) {
@@ -75,7 +108,7 @@ export default class EventServer {
75
108
  if (this.reporter.state?.stopTest) return;
76
109
  if (this.totalTasks < this.numberOfTasks) {
77
110
  ++this.totalTasks;
78
- this.makeTask(fileName);
111
+ this.#startTask(fileName);
79
112
  } else {
80
113
  this.fileQueue.push(fileName);
81
114
  }
@@ -83,11 +116,46 @@ export default class EventServer {
83
116
  execute(files) {
84
117
  files.forEach(fileName => this.createTask(fileName));
85
118
  }
119
+ // Spawn one worker and register it for control-plane tracking. Routes both
120
+ // the initial batch (createTask) and queue drains (close) so liveTasks and
121
+ // the optional deadline cover every task, not just the first numberOfTasks.
122
+ #startTask(fileName) {
123
+ const id = this.makeTask(fileName);
124
+ if (id == null) return id;
125
+ this.liveTasks.add(id);
126
+ if (this.workerTimeout > 0) {
127
+ this.deadlineTimers[id] = setTimeout(() => {
128
+ delete this.deadlineTimers[id];
129
+ if (this.liveTasks.has(id)) this.destroyTask(id, 'timeout');
130
+ }, this.workerTimeout);
131
+ }
132
+ return id;
133
+ }
134
+ #clearDeadline(id) {
135
+ const timer = this.deadlineTimers[id];
136
+ if (timer) {
137
+ clearTimeout(timer);
138
+ delete this.deadlineTimers[id];
139
+ }
140
+ }
141
+ // Terminate every in-flight worker (abort) on top of the existing "stop
142
+ // scheduling new files." Fires at most once per run; callers decide when a
143
+ // stop / bail-out signal has been observed.
144
+ #requestStop() {
145
+ if (this.stopRequested) return;
146
+ this.stopRequested = true;
147
+ for (const id of this.liveTasks) {
148
+ this.destroyTask(id, 'failOnce');
149
+ }
150
+ }
86
151
  makeTask(fileName) {
87
152
  // TBD in children
88
153
  // should return a task id as a string
89
154
  }
90
- destroyTask(id) {
91
- // TBD in children
155
+ destroyTask(id, reason) {
156
+ // TBD in children: deliver `terminate` to one worker.
157
+ // reason === 'done' -> task finished; tear the worker down now
158
+ // otherwise (abort) -> drain (run cleanup), then force-kill after
159
+ // graceTimeout where the transport allows
92
160
  }
93
161
  }
@@ -173,16 +173,34 @@ export const getReporterFileName = type => {
173
173
  };
174
174
 
175
175
  export const DEFAULT_START_TIMEOUT = 5_000;
176
-
177
- export const getTimeoutValue = () => {
178
- if (runtime.name === 'browser') return DEFAULT_START_TIMEOUT;
179
- const timeoutValue = runtime.getEnvVar('TAPE6_WORKER_START_TIMEOUT');
180
- if (!timeoutValue) return DEFAULT_START_TIMEOUT;
181
- let timeout = Number(timeoutValue);
182
- if (isNaN(timeout) || timeout <= 0 || timeout === Infinity) timeout = DEFAULT_START_TIMEOUT;
176
+ export const DEFAULT_GRACE_TIMEOUT = 5_000;
177
+
178
+ // Read a positive-millisecond env var, falling back to `fallback` when it is
179
+ // unset, non-numeric, non-positive, or Infinity. Always returns `fallback` in
180
+ // the browser, where these env vars don't exist.
181
+ const readTimeoutEnv = (name, fallback) => {
182
+ if (runtime.name === 'browser') return fallback;
183
+ const value = runtime.getEnvVar(name);
184
+ if (!value) return fallback;
185
+ const timeout = Number(value);
186
+ if (isNaN(timeout) || timeout <= 0 || timeout === Infinity) return fallback;
183
187
  return timeout;
184
188
  };
185
189
 
190
+ // Per-worker startup budget: how long a freshly spawned worker has to emit its
191
+ // first event before it's declared dead on arrival.
192
+ export const getTimeoutValue = () =>
193
+ readTimeoutEnv('TAPE6_WORKER_START_TIMEOUT', DEFAULT_START_TIMEOUT);
194
+
195
+ // Grace period a worker gets to drain (run cleanup hooks, flush) after a
196
+ // `terminate` is issued, before the parent force-kills it where the transport
197
+ // allows. See dev-docs/worker-control-channel.md.
198
+ export const getGraceTimeout = () => readTimeoutEnv('TAPE6_GRACE_TIMEOUT', DEFAULT_GRACE_TIMEOUT);
199
+
200
+ // Optional wall-clock budget per worker/file (Layer 2 termination). 0 disables
201
+ // it — the default — so a worker deadline fires only when explicitly set.
202
+ export const getWorkerTimeout = () => readTimeoutEnv('TAPE6_WORKER_TIMEOUT', 0);
203
+
186
204
  // parsing options
187
205
 
188
206
  export const flagNames = Object.fromEntries(
@@ -312,6 +330,11 @@ export const getOptions = extraOptions => {
312
330
  }
313
331
  options.parallel = parallel;
314
332
 
333
+ // Control-channel budgets ride along in the options bag handed to the
334
+ // TestWorker (EventServer). Children ignore them; only the parent acts.
335
+ options.flags.graceTimeout = getGraceTimeout();
336
+ options.flags.workerTimeout = getWorkerTimeout();
337
+
315
338
  return options;
316
339
  };
317
340
 
@@ -0,0 +1,109 @@
1
+ import {runtime, getGraceTimeout} from './config.js';
2
+
3
+ // Control plane, child side (proc transport). A proc-spawned child is an
4
+ // ordinary test file run by this runtime; tape-six-proc marks it with
5
+ // TAPE6_CONTROL and talks to it over stdin (line-delimited, mirroring the JSONL
6
+ // data plane). The contract — see dev-docs/worker-control-channel.md:
7
+ // - The pending stdin read keeps the event loop open, so the child stays
8
+ // alive after emitting its top-level `end` and the parent decides when it
9
+ // exits. That parent-driven exit is what closes the Bun stdout-flush race
10
+ // (the child no longer self-exits while the parent's Web-Stream view of the
11
+ // pipe is mid-teardown; see topics/tape-six-proc-bun-summary-suppressed).
12
+ // - On a `terminate` line the child drains a running test via
13
+ // reporter.terminate() (arms stopTest + fires the abort signal; cleanup
14
+ // hooks still run).
15
+ // - On control-channel EOF (parent done, died, or pipe broke) it soft-
16
+ // terminates and lets the event loop empty for a natural, fully-flushed
17
+ // exit. An unref'd watchdog is the hard backstop: it fires only if a hung
18
+ // test keeps the loop alive past graceTimeout (e.g. the parent died before
19
+ // it could force-kill).
20
+
21
+ const getStdinStream = async () => {
22
+ switch (runtime.name) {
23
+ case 'deno':
24
+ return Deno.stdin.readable;
25
+ case 'bun':
26
+ return Bun.stdin.stream();
27
+ case 'node': {
28
+ const {Readable} = await import('node:stream');
29
+ return Readable.toWeb(process.stdin);
30
+ }
31
+ }
32
+ return null;
33
+ };
34
+
35
+ const exitProcess = code => {
36
+ if (runtime.name === 'deno') {
37
+ Deno.exit(code || 0);
38
+ } else if (typeof process == 'object' && typeof process.exit == 'function') {
39
+ process.exit(code || 0);
40
+ }
41
+ };
42
+
43
+ const armWatchdog = (graceTimeout, getExitCode) => {
44
+ const watchdog = setTimeout(() => exitProcess(getExitCode()), graceTimeout);
45
+ // The watchdog must never keep the child alive on its own — it only matters
46
+ // when something else (a hung test) does. Unref so a clean drain exits at once.
47
+ if (typeof watchdog?.unref == 'function') {
48
+ watchdog.unref();
49
+ } else if (runtime.name === 'deno' && typeof Deno.unrefTimer == 'function') {
50
+ Deno.unrefTimer(watchdog);
51
+ }
52
+ };
53
+
54
+ export const listenControlChannel = async getReporter => {
55
+ const stream = await getStdinStream();
56
+ if (!stream) return;
57
+
58
+ const graceTimeout = getGraceTimeout(),
59
+ decoder = new TextDecoder(),
60
+ reader = stream.getReader(),
61
+ getExitCode = () =>
62
+ typeof process == 'object' && typeof process.exitCode == 'number' ? process.exitCode : 0;
63
+
64
+ let terminated = false;
65
+ const handleLine = line => {
66
+ const cmd = line.trim();
67
+ if (!cmd) return;
68
+ // Accept a bare `terminate` or a JSON {"cmd":"terminate","reason":...}.
69
+ if (cmd === 'terminate') {
70
+ terminated = true;
71
+ getReporter()?.terminate();
72
+ } else if (cmd[0] === '{') {
73
+ try {
74
+ const msg = JSON.parse(cmd);
75
+ if (msg?.cmd === 'terminate') {
76
+ terminated = true;
77
+ getReporter()?.terminate();
78
+ }
79
+ } catch (e) {
80
+ void e; // ignore a malformed control line
81
+ }
82
+ }
83
+ };
84
+
85
+ let buffer = '';
86
+ try {
87
+ for (;;) {
88
+ const {done, value} = await reader.read();
89
+ if (done) break;
90
+ buffer += decoder.decode(value, {stream: true});
91
+ let nl;
92
+ while ((nl = buffer.indexOf('\n')) >= 0) {
93
+ handleLine(buffer.slice(0, nl));
94
+ buffer = buffer.slice(nl + 1);
95
+ }
96
+ }
97
+ } catch (e) {
98
+ void e; // a broken pipe reads as EOF for our purposes
99
+ }
100
+ if (buffer) handleLine(buffer);
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.
105
+ if (!terminated) getReporter()?.terminate();
106
+ armWatchdog(graceTimeout, getExitCode);
107
+ };
108
+
109
+ export default listenControlChannel;
@@ -7,6 +7,7 @@ export default class TestWorker extends EventServer {
7
7
 
8
8
  this.importmap = options?.importmap;
9
9
  this.counter = 0;
10
+ this.graceTimers = {};
10
11
 
11
12
  window.__tape6_reporter = (id, event) => {
12
13
  this.report(id, event);
@@ -83,8 +84,33 @@ export default class TestWorker extends EventServer {
83
84
  }
84
85
  return id;
85
86
  }
86
- destroyTask(id) {
87
+ destroyTask(id, reason = 'done') {
88
+ if (reason === 'done') {
89
+ this.#removeIframe(id);
90
+ return;
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.
97
+ if (this.graceTimers[id]) return;
98
+ const iframe = document.getElementById('test-iframe-' + id);
99
+ if (!iframe) return;
100
+ try {
101
+ iframe.contentWindow?.postMessage({type: 'tape6-terminate', reason}, '*');
102
+ } catch (e) {
103
+ void e;
104
+ }
105
+ this.graceTimers[id] = setTimeout(() => this.#removeIframe(id), this.graceTimeout);
106
+ }
107
+ #removeIframe(id) {
108
+ const grace = this.graceTimers[id];
109
+ if (grace) {
110
+ clearTimeout(grace);
111
+ delete this.graceTimers[id];
112
+ }
87
113
  const iframe = document.getElementById('test-iframe-' + id);
88
- iframe && iframe.parentElement.removeChild(iframe);
114
+ iframe && iframe.parentElement && iframe.parentElement.removeChild(iframe);
89
115
  }
90
116
  }