tape-six 1.9.0 → 1.10.1
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 +5 -1
- package/TESTING.md +0 -121
- package/index.js +27 -0
- package/llms-full.txt +2 -102
- package/llms.txt +2 -66
- package/package.json +2 -11
- package/skills/run-tests/SKILL.md +77 -0
- package/skills/write-tests/SKILL.md +5 -11
- package/src/reporters/JSONLReporter.js +1 -1
- package/src/reporters/MinReporter.js +1 -1
- package/src/reporters/Reporter.js +39 -0
- package/src/reporters/TapReporter.js +1 -1
- package/src/runners/bun/TestWorker.js +26 -1
- package/src/runners/bun/worker.js +14 -1
- package/src/runners/deno/TestWorker.js +26 -1
- package/src/runners/deno/worker.js +14 -1
- package/src/runners/node/TestWorker.js +27 -1
- package/src/runners/node/worker.js +14 -1
- package/src/runners/seq/TestWorker.js +9 -1
- package/src/utils/EventServer.js +74 -6
- package/src/utils/config.js +30 -7
- package/src/utils/control-channel.js +109 -0
- package/web-app/TestWorker.js +28 -2
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,9 @@ 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.
|
|
430
|
+
- 1.10.1 _A workaround for a Bun's bug with streams._
|
|
431
|
+
- 1.10.0 _Worker control channel stops in-flight workers, draining cleanup before a force-kill._
|
|
432
|
+
- 1.9.0 _New features: `t.plan(n)` emits a TAP-comment diagnostic on mismatch, `registerTesterMethod(name, fn)` registers tester plugins idempotently._
|
|
429
433
|
- 1.8.0 _New subpath modules: `tape-six/server` (HTTP server harness) and `tape-six/response` (response reading helpers for `Response` and `IncomingMessage`)._
|
|
430
434
|
- 1.7.14 _Updated vendored `deep6` to 1.2.0._
|
|
431
435
|
- 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.
|
|
3
|
+
"version": "1.10.1",
|
|
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.
|
|
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. **
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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.
|
|
@@ -38,7 +38,7 @@ export class JSONLReporter extends Reporter {
|
|
|
38
38
|
break;
|
|
39
39
|
}
|
|
40
40
|
const jsonEvent = JSON.stringify(event);
|
|
41
|
-
this.
|
|
41
|
+
this.writeOut(this.prefix ? this.prefix + jsonEvent : jsonEvent);
|
|
42
42
|
this.state?.postprocess(event, suppressStopTest);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -9,7 +9,7 @@ export class MinReporter extends Reporter {
|
|
|
9
9
|
event = this.state?.preprocess(event) || event;
|
|
10
10
|
const handler = Reporter.EVENT_MAP[event.type];
|
|
11
11
|
typeof handler == 'string' && this[handler]?.(event);
|
|
12
|
-
this.
|
|
12
|
+
this.writeOut(
|
|
13
13
|
'Test:',
|
|
14
14
|
event.test,
|
|
15
15
|
'Type:',
|
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import State from '../State.js';
|
|
2
2
|
import {getTimer} from '../utils/timer.js';
|
|
3
3
|
|
|
4
|
+
// Bun's native console.log to a subprocess-piped stdout drops data / stalls under
|
|
5
|
+
// load (a Bun bug — see vault note tape-six-proc-bun-summary-suppressed). On Bun the
|
|
6
|
+
// reporters write via process.stdout.write instead; Node/Deno keep console.log.
|
|
7
|
+
const isBun = typeof Bun == 'object' && !!Bun?.version;
|
|
8
|
+
|
|
4
9
|
export class Reporter {
|
|
5
10
|
constructor({failOnce = false, timer} = {}) {
|
|
6
11
|
this.failOnce = failOnce;
|
|
7
12
|
this.state = null;
|
|
8
13
|
this.depth = 0;
|
|
9
14
|
this.timer = timer || getTimer();
|
|
15
|
+
this.terminating = false;
|
|
16
|
+
}
|
|
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
|
+
writeOut(...args) {
|
|
22
|
+
if (isBun) {
|
|
23
|
+
process.stdout.write(args.map(a => (typeof a == 'string' ? a : String(a))).join(' ') + '\n');
|
|
24
|
+
} else {
|
|
25
|
+
(this.console || console).log(...args);
|
|
26
|
+
}
|
|
10
27
|
}
|
|
11
28
|
|
|
12
29
|
get signal() {
|
|
@@ -17,6 +34,22 @@ export class Reporter {
|
|
|
17
34
|
this.state?.abort();
|
|
18
35
|
}
|
|
19
36
|
|
|
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.
|
|
45
|
+
terminate() {
|
|
46
|
+
this.terminating = true;
|
|
47
|
+
for (let state = this.state; state; state = state.parent) {
|
|
48
|
+
state.stopTest = true;
|
|
49
|
+
state.abort();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
20
53
|
onTest(event) {
|
|
21
54
|
this.state = new State(this.state, {
|
|
22
55
|
name: event.name,
|
|
@@ -28,6 +61,12 @@ export class Reporter {
|
|
|
28
61
|
timer: this.timer
|
|
29
62
|
});
|
|
30
63
|
++this.depth;
|
|
64
|
+
// A terminate landed before this test started — stop it at its first
|
|
65
|
+
// assertion rather than letting it run to completion.
|
|
66
|
+
if (this.terminating) {
|
|
67
|
+
this.state.stopTest = true;
|
|
68
|
+
this.state.abort();
|
|
69
|
+
}
|
|
31
70
|
if (typeof event.time != 'number') {
|
|
32
71
|
event = {...event, time: this.state.time};
|
|
33
72
|
}
|
|
@@ -41,7 +41,7 @@ export class TapReporter extends Reporter {
|
|
|
41
41
|
} = {}) {
|
|
42
42
|
super({failOnce});
|
|
43
43
|
this.console = originalConsole || console;
|
|
44
|
-
this.write = write || (hasColors ? this.logger : this.
|
|
44
|
+
this.write = write || (hasColors ? this.logger : (...args) => this.writeOut(...args));
|
|
45
45
|
this.renumberAsserts = renumberAsserts;
|
|
46
46
|
this.useJson = useJson;
|
|
47
47
|
this.assertCounter = 0;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/utils/EventServer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/utils/config.js
CHANGED
|
@@ -173,16 +173,34 @@ export const getReporterFileName = type => {
|
|
|
173
173
|
};
|
|
174
174
|
|
|
175
175
|
export const DEFAULT_START_TIMEOUT = 5_000;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
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;
|
package/web-app/TestWorker.js
CHANGED
|
@@ -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
|
}
|