tape-six-proc 1.2.8 → 1.3.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 +7 -0
- package/bin/tape6-proc-node.js +1 -1
- package/llms-full.txt +15 -10
- package/llms.txt +3 -0
- package/package.json +6 -4
- package/src/TestWorker.js +64 -14
package/README.md
CHANGED
|
@@ -14,6 +14,11 @@ in its own subprocess instead, providing full process isolation. This prevents s
|
|
|
14
14
|
leaks and is useful when tests need a clean environment. TypeScript test files (`.ts`)
|
|
15
15
|
run natively on modern Node, Deno, and Bun — no transpilation needed.
|
|
16
16
|
|
|
17
|
+
A control channel lets the runner stop workers mid-run: `failOnce` (flag `O`) drains
|
|
18
|
+
the test files still in flight, and `TAPE6_WORKER_TIMEOUT` sets a per-worker time limit.
|
|
19
|
+
Both cancel cooperatively (cleanup hooks run) with a force-kill backstop after
|
|
20
|
+
`TAPE6_GRACE_TIMEOUT`. See [Stopping tests early](https://github.com/uhop/tape-six-proc/wiki/Utility-%E2%80%90-tape6-proc#stopping-tests-early).
|
|
21
|
+
|
|
17
22
|
## Install
|
|
18
23
|
|
|
19
24
|
```bash
|
|
@@ -88,6 +93,8 @@ LLM-friendly documentation is available:
|
|
|
88
93
|
|
|
89
94
|
The most recent releases:
|
|
90
95
|
|
|
96
|
+
- 1.3.0 _Implemented the terminate protocol. Updated dependencies._
|
|
97
|
+
- 1.2.9 _Added `js-check`, Bun + Deno CI, tuned dependabot. Updated dependencies._
|
|
91
98
|
- 1.2.8 _Fixed Bun stdout flush bug. Added GitHub Actions CI. Updated dependencies._
|
|
92
99
|
- 1.2.7 _Added `--help` and `--version` flags. Updated dependencies._
|
|
93
100
|
- 1.2.6 _Updated dependencies. Added AI agent rule files. Improved workflows._
|
package/bin/tape6-proc-node.js
CHANGED
|
@@ -102,7 +102,7 @@ const main = async () => {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
const reporter = getReporter(),
|
|
105
|
-
worker = new TestWorker(reporter, options.parallel, options.flags);
|
|
105
|
+
worker = /** @type {*} */ (new TestWorker(reporter, options.parallel, options.flags));
|
|
106
106
|
|
|
107
107
|
reporter.report({type: 'test', test: 0});
|
|
108
108
|
|
package/llms-full.txt
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
- Same configuration format as `tape-six`
|
|
10
10
|
- Parallel execution with configurable concurrency
|
|
11
11
|
- TAP, TTY (colored), JSONL, and minimal output formats
|
|
12
|
+
- Worker control channel: `failOnce` stops in-flight workers, plus an optional per-worker time limit
|
|
12
13
|
|
|
13
14
|
## Install
|
|
14
15
|
|
|
@@ -115,7 +116,7 @@ Flags are a string of characters. Uppercase = enabled, lowercase = disabled.
|
|
|
115
116
|
- `T` — show Time for each test.
|
|
116
117
|
- `B` — show Banner with summary.
|
|
117
118
|
- `D` — show Data of failed tests.
|
|
118
|
-
- `O` — fail Once: stop at first failure.
|
|
119
|
+
- `O` — fail Once: stop at first failure. Via the control channel this also drains the workers still in flight (cleanup hooks run), not just the file queue.
|
|
119
120
|
- `N` — show assert Number.
|
|
120
121
|
- `M` — Monochrome: no colors.
|
|
121
122
|
- `C` — don't Capture console output.
|
|
@@ -150,9 +151,12 @@ Environment-specific subsections (`node`, `deno`, `bun`) are supported. The runn
|
|
|
150
151
|
- `TAPE6_TAP` — force TAP reporter (any non-empty value).
|
|
151
152
|
- `TAPE6_JSONL` — force JSONL reporter (any non-empty value).
|
|
152
153
|
- `TAPE6_MIN` — force minimal reporter (any non-empty value).
|
|
154
|
+
- `TAPE6_GRACE_TIMEOUT` — milliseconds a worker is given to drain (run cleanup hooks, flush) after a `terminate` before it is force-killed. Default 5000. Shared with `tape-six`.
|
|
155
|
+
- `TAPE6_WORKER_TIMEOUT` — per-worker wall-clock deadline in milliseconds; on expiry the worker is terminated (drain → kill). Default 0 (disabled). Shared with `tape-six`.
|
|
153
156
|
- `TAPE6_TEST` — set by the runner: unique ID for each spawned test process.
|
|
154
157
|
- `TAPE6_TEST_FILE_NAME` — set by the runner: file name of the current test.
|
|
155
158
|
- `TAPE6_JSONL_PREFIX` — set by the runner: UUID prefix for JSONL lines in stdout.
|
|
159
|
+
- `TAPE6_CONTROL` — set by the runner: marks a spawned child as controlled, so the tape-six runtime opens the stdin control channel and stays alive after `end` until told to exit.
|
|
156
160
|
|
|
157
161
|
## Architecture
|
|
158
162
|
|
|
@@ -178,12 +182,12 @@ Delegates argument parsing, reporter setup, and file resolution to `tape-six/uti
|
|
|
178
182
|
Extends `EventServer` from `tape-six`. Manages parallel subprocess execution:
|
|
179
183
|
|
|
180
184
|
- **`constructor(reporter, numberOfTasks, options)`** — initializes with a reporter, concurrency limit, and options. Generates a UUID prefix for JSONL parsing.
|
|
181
|
-
- **`makeTask(fileName)`** — spawns a child process for a test file using `dollar-shell
|
|
182
|
-
- **`destroyTask(id)`** —
|
|
185
|
+
- **`makeTask(fileName)`** — spawns a child process for a test file using `dollar-shell` (`stdin: 'pipe'` so it can be controlled). Sets up stream pipelines for stdout and stderr, marks completion when the child's top-level `end` is read, and reports premature exits. Returns a task ID.
|
|
186
|
+
- **`destroyTask(id, reason)`** — the control-plane terminate. Writes a line-delimited `terminate` command to the child's stdin and EOFs it, then arms a `TAPE6_GRACE_TIMEOUT` deadline after which the child is force-killed (`worker.kill()`, SIGTERM). The base `EventServer` calls it with `reason` `'done'` (the worker finished — read its `end`), `'failOnce'` (a sibling tripped failOnce / bail), or `'timeout'` (the per-worker deadline). Idempotent per task.
|
|
183
187
|
|
|
184
188
|
### Stream pipeline
|
|
185
189
|
|
|
186
|
-
Each spawned process has two stream pipelines:
|
|
190
|
+
Each spawned process has two data-plane stream pipelines (worker → reporter) plus a stdin control plane (reporter → worker, carrying `terminate`):
|
|
187
191
|
|
|
188
192
|
**stdout pipeline:**
|
|
189
193
|
```
|
|
@@ -202,15 +206,16 @@ process.stderr → TextDecoderStream → lines → wrap-lines → report
|
|
|
202
206
|
|
|
203
207
|
### Process lifecycle
|
|
204
208
|
|
|
205
|
-
1. Process is spawned with environment variables: `TAPE6_FLAGS`, `TAPE6_TEST`, `TAPE6_TEST_FILE_NAME`, `TAPE6_JSONL=Y`, `TAPE6_JSONL_PREFIX`.
|
|
206
|
-
2. The test file runs and outputs JSONL-prefixed lines to stdout.
|
|
207
|
-
3.
|
|
208
|
-
4.
|
|
209
|
-
5.
|
|
209
|
+
1. Process is spawned with environment variables: `TAPE6_FLAGS`, `TAPE6_TEST`, `TAPE6_TEST_FILE_NAME`, `TAPE6_JSONL=Y`, `TAPE6_JSONL_PREFIX`, `TAPE6_CONTROL=Y`, `TAPE6_GRACE_TIMEOUT`.
|
|
210
|
+
2. The test file runs and outputs JSONL-prefixed lines to stdout. Marked by `TAPE6_CONTROL`, the tape-six runtime opens a stdin control channel and stays alive after emitting its top-level `end` — it no longer self-exits.
|
|
211
|
+
3. **Normal completion** is keyed off the parent *reading* that top-level `end` (not racing the child's exit). The parent then issues `terminate` (drain + EOF stdin); the child exits and the next queued file starts. Because the child exits only after `end` has been consumed, the Bun stdout-flush bug (a dropped tail on self-exit) cannot occur.
|
|
212
|
+
4. **Abort** (`failOnce` / bail, or a `TAPE6_WORKER_TIMEOUT` deadline) issues `terminate` to every in-flight worker. Each drains its running test through `reporter.terminate()` — `t.signal` fires, `finally` / `afterEach` / `afterAll` run — then exits, emitting its `end` as usual.
|
|
213
|
+
5. **Force-kill backstop.** A worker that does not exit within `TAPE6_GRACE_TIMEOUT` of a `terminate` (a test hung in a non-signal-aware `await`) is killed with `worker.kill()` (SIGTERM).
|
|
214
|
+
6. A premature exit — the child closes stdout without a top-level `end` (crash, bad import, force-kill) — is reported as an error with a `{type: 'terminated'}` event, including the exit code / signal, unless a failure was already reported.
|
|
210
215
|
|
|
211
216
|
## Dependencies
|
|
212
217
|
|
|
213
|
-
- **`tape-six`** — the core test library. Imports: `utils/config.js` (`getOptions`, `initFiles`, `initReporter`, `showInfo`, `printFlagOptions`), `test.js` (`getReporter`, `setReporter`), `utils/timer.js`, `State.js`, `utils/EventServer.js`, `utils/makeDeferred.js`.
|
|
218
|
+
- **`tape-six`** — the core test library. Imports: `utils/config.js` (`getOptions`, `initFiles`, `initReporter`, `showInfo`, `printFlagOptions`), `test.js` (`getReporter`, `setReporter`), `utils/timer.js`, `State.js`, `utils/EventServer.js`, `utils/makeDeferred.js`. The worker control channel additionally depends on tape-six's child-side listener (`src/utils/control-channel.js`, opened via `TAPE6_CONTROL`) and the `getGraceTimeout` / `getWorkerTimeout` config it injects through `getOptions`, so it requires a tape-six version that ships them.
|
|
214
219
|
- **`dollar-shell`** — cross-runtime process spawning. Imports: `spawn`, `currentExecPath`, `runFileArgs`.
|
|
215
220
|
|
|
216
221
|
## Writing tests
|
package/llms.txt
CHANGED
|
@@ -42,6 +42,7 @@ test('my test', async t => {
|
|
|
42
42
|
- **Cross-runtime** — works with Node, Deno, and Bun using the same test files.
|
|
43
43
|
- **TypeScript without transpilation** — runs `.ts` test files natively on modern Node, Deno, and Bun.
|
|
44
44
|
- **Drop-in replacement** — uses the same configuration and test format as `tape6` (worker-thread runner).
|
|
45
|
+
- **Early termination** — a control channel lets `failOnce` (flag `O`) stop in-flight workers and supports a per-worker time limit (`TAPE6_WORKER_TIMEOUT`); both drain cleanly (cleanup hooks run) with a force-kill backstop after `TAPE6_GRACE_TIMEOUT`.
|
|
45
46
|
|
|
46
47
|
## CLI: tape6-proc
|
|
47
48
|
|
|
@@ -102,6 +103,8 @@ Environment-specific subsections: `node`, `deno`, `bun`.
|
|
|
102
103
|
- `TAPE6_TAP` — force TAP reporter.
|
|
103
104
|
- `TAPE6_JSONL` — force JSONL reporter.
|
|
104
105
|
- `TAPE6_MIN` — force minimal reporter.
|
|
106
|
+
- `TAPE6_GRACE_TIMEOUT` — ms a worker may drain (cleanup, flush) after a `terminate` before it is force-killed. Default 5000.
|
|
107
|
+
- `TAPE6_WORKER_TIMEOUT` — per-worker wall-clock deadline in ms; on expiry the worker is terminated. Default 0 (off).
|
|
105
108
|
- `TAPE6_TEST_FILE_NAME` — set by the runner: file name of the current test.
|
|
106
109
|
|
|
107
110
|
## Links
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tape-six-proc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Process-isolated test runner for tape-six. Runs each test file in its own subprocess. Works with Node, Deno, and Bun. Supports TypeScript without transpilation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"lint": "prettier --check .",
|
|
11
11
|
"lint:fix": "prettier --write .",
|
|
12
|
+
"js-check": "tsc --project tsconfig.check.json",
|
|
12
13
|
"test": "node ./bin/tape6-proc.js --flags FO",
|
|
13
14
|
"test:bun": "bun run ./bin/tape6-proc.js --flags FO",
|
|
14
15
|
"test:deno": "deno run -A ./bin/tape6-proc.js -r --allow-read -r --allow-env --flags FO"
|
|
@@ -56,8 +57,8 @@
|
|
|
56
57
|
"llms-full.txt"
|
|
57
58
|
],
|
|
58
59
|
"dependencies": {
|
|
59
|
-
"dollar-shell": "^1.1.
|
|
60
|
-
"tape-six": "^1.
|
|
60
|
+
"dollar-shell": "^1.1.14",
|
|
61
|
+
"tape-six": "^1.10.0"
|
|
61
62
|
},
|
|
62
63
|
"tape6": {
|
|
63
64
|
"tests": [
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
]
|
|
66
67
|
},
|
|
67
68
|
"devDependencies": {
|
|
68
|
-
"
|
|
69
|
+
"@types/node": "^25.9.1",
|
|
70
|
+
"typescript": "^6.0.3"
|
|
69
71
|
}
|
|
70
72
|
}
|
package/src/TestWorker.js
CHANGED
|
@@ -15,6 +15,8 @@ import wrap from './streams/wrap-lines.js';
|
|
|
15
15
|
|
|
16
16
|
const baseName = pathToFileURL(process.cwd() + sep);
|
|
17
17
|
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
|
|
18
20
|
export default class TestWorker extends EventServer {
|
|
19
21
|
constructor(reporter, numberOfTasks, options) {
|
|
20
22
|
super(reporter, numberOfTasks, options);
|
|
@@ -23,34 +25,43 @@ export default class TestWorker extends EventServer {
|
|
|
23
25
|
this.prefix = crypto.randomUUID();
|
|
24
26
|
}
|
|
25
27
|
makeTask(fileName) {
|
|
28
|
+
const self = /** @type {*} */ (this);
|
|
26
29
|
const testName = new URL(fileName, baseName),
|
|
27
|
-
id = String(++
|
|
30
|
+
id = String(++self.counter),
|
|
28
31
|
worker = spawn(
|
|
29
|
-
[currentExecPath(), ...runFileArgs, ...
|
|
32
|
+
[currentExecPath(), ...runFileArgs, ...self.options.runFileArgs, fileURLToPath(testName)],
|
|
30
33
|
{
|
|
31
|
-
stdin:
|
|
34
|
+
// stdin is the control channel: the parent writes a line-delimited
|
|
35
|
+
// `terminate` and ends the stream to drive the child's exit. See
|
|
36
|
+
// dev-docs/worker-control-channel.md (tape-six).
|
|
37
|
+
stdin: 'pipe',
|
|
32
38
|
stdout: 'pipe',
|
|
33
39
|
stderr: 'pipe',
|
|
34
40
|
env: {
|
|
35
41
|
...process.env,
|
|
36
|
-
TAPE6_FLAGS:
|
|
42
|
+
TAPE6_FLAGS: self.options.flags,
|
|
37
43
|
TAPE6_TEST: id,
|
|
38
44
|
TAPE6_TEST_FILE_NAME: fileName,
|
|
39
45
|
TAPE6_JSONL: 'Y',
|
|
40
|
-
TAPE6_JSONL_PREFIX:
|
|
46
|
+
TAPE6_JSONL_PREFIX: self.prefix,
|
|
47
|
+
TAPE6_CONTROL: 'Y',
|
|
48
|
+
TAPE6_GRACE_TIMEOUT: String(self.graceTimeout),
|
|
41
49
|
TAPE6_MIN: '',
|
|
42
50
|
TAPE6_TAP: '',
|
|
43
51
|
TAPE6_TTY: ''
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
54
|
);
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
// Per-task control-plane state. `endSeen` keys completion off *reading* the
|
|
56
|
+
// child's top-level `end` (not racing the child's own exit); `terminating`
|
|
57
|
+
// makes destroyTask idempotent; `graceTimer` is the force-kill backstop.
|
|
58
|
+
const task = {worker, endSeen: false, terminating: false, graceTimer: null};
|
|
59
|
+
self.idToWorker[id] = task;
|
|
49
60
|
const stdoutDeferred = makeDeferred();
|
|
50
61
|
worker.stdout
|
|
51
62
|
.pipeThrough(new TextDecoderStream())
|
|
52
63
|
.pipeThrough(lines())
|
|
53
|
-
.pipeThrough(parse(
|
|
64
|
+
.pipeThrough(parse(self.prefix))
|
|
54
65
|
.pipeTo(
|
|
55
66
|
new WritableStream({
|
|
56
67
|
write(msg) {
|
|
@@ -62,6 +73,13 @@ export default class TestWorker extends EventServer {
|
|
|
62
73
|
throw error;
|
|
63
74
|
}
|
|
64
75
|
}
|
|
76
|
+
// Normal completion: the parent has consumed the top-level `end`.
|
|
77
|
+
// Tell the child to exit (drain + EOF its control channel) — this is
|
|
78
|
+
// the parent-driven exit that closes the Bun flush race.
|
|
79
|
+
if (msg && msg.type === 'end' && msg.test === 0) {
|
|
80
|
+
task.endSeen = true;
|
|
81
|
+
self.destroyTask(id, 'done');
|
|
82
|
+
}
|
|
65
83
|
},
|
|
66
84
|
close() {
|
|
67
85
|
stdoutDeferred.resolve();
|
|
@@ -84,7 +102,13 @@ export default class TestWorker extends EventServer {
|
|
|
84
102
|
})
|
|
85
103
|
);
|
|
86
104
|
Promise.allSettled([worker.exited, stdoutDeferred.promise, stderrDeferred.promise]).then(() => {
|
|
87
|
-
if (
|
|
105
|
+
if (task.graceTimer) {
|
|
106
|
+
clearTimeout(task.graceTimer);
|
|
107
|
+
task.graceTimer = null;
|
|
108
|
+
}
|
|
109
|
+
// A premature exit (no top-level `end` read) is a crash or a force-killed
|
|
110
|
+
// hung child — surface it, unless a failure was already reported.
|
|
111
|
+
if (!task.endSeen && (!self.reporter.state || !self.reporter.state.failed)) {
|
|
88
112
|
const reason = [];
|
|
89
113
|
if (worker.exitCode) {
|
|
90
114
|
reason.push(`exit code: ${worker.exitCode}`);
|
|
@@ -103,15 +127,41 @@ export default class TestWorker extends EventServer {
|
|
|
103
127
|
self.report(id, {type: 'terminated', test: 0, name: 'FILE: /' + fileName});
|
|
104
128
|
}
|
|
105
129
|
}
|
|
130
|
+
delete self.idToWorker[id];
|
|
106
131
|
self.close(id);
|
|
107
132
|
});
|
|
108
133
|
return id;
|
|
109
134
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
135
|
+
// Deliver `terminate` to one child: write the line-delimited command, then
|
|
136
|
+
// EOF the control channel. The child drains a running test (reporter
|
|
137
|
+
// .terminate() — cleanup hooks run) and exits on its own; the graceTimeout
|
|
138
|
+
// backstop force-kills a test that won't drain. Idempotent per task.
|
|
139
|
+
destroyTask(id, reason = 'done') {
|
|
140
|
+
const task = this.idToWorker[id];
|
|
141
|
+
if (!task || task.terminating) return;
|
|
142
|
+
task.terminating = true;
|
|
143
|
+
this.#sendTerminate(task.worker, reason);
|
|
144
|
+
task.graceTimer = setTimeout(() => this.#kill(id), /** @type {*} */ (this).graceTimeout);
|
|
145
|
+
}
|
|
146
|
+
async #sendTerminate(worker, reason) {
|
|
147
|
+
const stdin = worker.stdin;
|
|
148
|
+
if (!stdin) return;
|
|
149
|
+
try {
|
|
150
|
+
const writer = stdin.getWriter();
|
|
151
|
+
await writer.write(encoder.encode(JSON.stringify({cmd: 'terminate', reason}) + '\n'));
|
|
152
|
+
await writer.close();
|
|
153
|
+
} catch (e) {
|
|
154
|
+
void e; // child already gone / pipe closed
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
#kill(id) {
|
|
158
|
+
const task = this.idToWorker[id];
|
|
159
|
+
if (!task) return;
|
|
160
|
+
task.graceTimer = null;
|
|
161
|
+
try {
|
|
162
|
+
task.worker.kill();
|
|
163
|
+
} catch (e) {
|
|
164
|
+
void e;
|
|
115
165
|
}
|
|
116
166
|
}
|
|
117
167
|
}
|