tape-six 1.12.0 → 1.14.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 +5 -1
- package/bin/tape6-deno.js +103 -6
- package/bin/tape6-server.js +1 -1
- package/index.js +1 -3
- package/package.json +1 -3
- package/src/State.d.ts +94 -0
- package/src/State.js +4 -3
- package/src/Tester.js +1 -4
- package/src/reporters/Reporter.js +4 -13
- package/src/runners/bun/TestWorker.js +1 -4
- package/src/runners/bun/worker.js +1 -3
- package/src/runners/deno/TestWorker.js +1 -4
- package/src/runners/deno/worker.js +1 -3
- package/src/runners/node/TestWorker.js +2 -5
- package/src/runners/node/worker.js +1 -3
- package/src/runners/seq/TestWorker.js +2 -4
- package/src/test.d.ts +82 -0
- package/src/test.js +2 -0
- package/src/utils/EventServer.d.ts +114 -0
- package/src/utils/EventServer.js +6 -15
- package/src/utils/config.d.ts +139 -0
- package/src/utils/config.js +4 -7
- package/src/utils/control-channel.js +1 -3
- package/src/utils/controlFetch.d.ts +34 -0
- package/src/utils/controlFetch.js +88 -0
- package/src/utils/makeDeferred.d.ts +13 -0
- package/src/utils/makeDeferred.js +2 -0
- package/src/utils/timer.d.ts +16 -0
- package/src/utils/timer.js +2 -0
- package/web-app/TestWorker.js +3 -5
- package/bin/tape6-deno-main.js +0 -104
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ tape-six/
|
|
|
117
117
|
|
|
118
118
|
## Docs
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
Full documentation is in the **[wiki](https://github.com/uhop/tape-six/wiki)** — browse the [index](https://github.com/uhop/tape-six/wiki/Home), or [search it](https://uhop.github.io/wiki-search/app/?wiki=uhop/tape-six) by name.
|
|
121
121
|
See how it can be used in [tests/](https://github.com/uhop/tape-six/tree/master/tests).
|
|
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.
|
|
@@ -427,6 +427,8 @@ Test output can be controlled by flags. See [Supported flags](https://github.com
|
|
|
427
427
|
|
|
428
428
|
The most recent releases:
|
|
429
429
|
|
|
430
|
+
- 1.14.0 _Internal housekeeping. Removed the Deno 2.9.0 workaround shim._
|
|
431
|
+
- 1.13.0 _TypeScript declarations for the `EventServer`._
|
|
430
432
|
- 1.12.0 _`tape6-server` is now pluggable, embeddable server for hermetic integration tests, runtime plugin registration, and an opt-in HTTP/2 mode._
|
|
431
433
|
- 1.11.0 _Invariant host hooks for the new zero-dependency `tape-six-invariant` companion package. Workaround for a Deno 2.9.0 `node_modules/.bin` symlink regression._
|
|
432
434
|
- 1.10.1 _A workaround for a Bun's bug with streams._
|
|
@@ -452,4 +454,6 @@ The most recent releases:
|
|
|
452
454
|
- 1.5.1 _Better support for stopping parallel tests, better support for "failed to load" errors._
|
|
453
455
|
- 1.5.0 _Internal refactoring (moved state to reporters), added type identification of values in the DOM and TTY reporters, multiple minor fixes._
|
|
454
456
|
|
|
457
|
+
The full release notes are in the wiki: [Release notes](https://github.com/uhop/tape-six/wiki/Release-notes).
|
|
458
|
+
|
|
455
459
|
For more info consult full [release notes](https://github.com/uhop/tape-six/wiki/Release-notes).
|
package/bin/tape6-deno.js
CHANGED
|
@@ -1,9 +1,106 @@
|
|
|
1
1
|
#!/usr/bin/env -S deno run --allow-all --ext=js
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// Realpath this entry, then load the runner (static imports intact) beside it.
|
|
6
|
-
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
import {
|
|
7
|
+
getOptions,
|
|
8
|
+
initFiles,
|
|
9
|
+
initReporter,
|
|
10
|
+
showInfo,
|
|
11
|
+
printVersion,
|
|
12
|
+
printHelp,
|
|
13
|
+
printFlagOptions
|
|
14
|
+
} from '../src/utils/config.js';
|
|
15
|
+
|
|
16
|
+
import {getReporter, setReporter} from '../src/test.js';
|
|
17
|
+
import {selectTimer} from '../src/utils/timer.js';
|
|
18
|
+
|
|
19
|
+
import TestWorker from '../src/runners/deno/TestWorker.js';
|
|
20
|
+
|
|
21
|
+
const rootFolder = Deno.cwd();
|
|
22
|
+
|
|
23
|
+
const showSelf = () => {
|
|
24
|
+
const self = new URL(import.meta.url);
|
|
25
|
+
if (self.protocol === 'file:') {
|
|
26
|
+
console.log(fileURLToPath(self));
|
|
27
|
+
} else {
|
|
28
|
+
console.log(self);
|
|
29
|
+
}
|
|
30
|
+
Deno.exit(0);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const showVersion = () => {
|
|
34
|
+
printVersion('tape6-deno');
|
|
35
|
+
Deno.exit(0);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const showHelp = () => {
|
|
39
|
+
printHelp('tape6-deno', 'Tape6 test runner for Deno', 'tape6-deno [options] [files...]', [
|
|
40
|
+
['--flags, -f <flags>', 'Set reporter flags (env: TAPE6_FLAGS)'],
|
|
41
|
+
['--par, -p <n>', 'Set parallelism level (env: TAPE6_PAR)'],
|
|
42
|
+
['--info', 'Show configuration info and exit'],
|
|
43
|
+
['--self', 'Print the path to this script and exit'],
|
|
44
|
+
['--help, -h', 'Show this help message and exit'],
|
|
45
|
+
['--version, -v', 'Show version and exit']
|
|
46
|
+
]);
|
|
47
|
+
printFlagOptions();
|
|
48
|
+
Deno.exit(0);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const main = async () => {
|
|
52
|
+
const currentOptions = getOptions({
|
|
53
|
+
'--self': {fn: showSelf, isValueRequired: false},
|
|
54
|
+
'--info': {isValueRequired: false},
|
|
55
|
+
'--help': {aliases: ['-h'], fn: showHelp, isValueRequired: false},
|
|
56
|
+
'--version': {aliases: ['-v'], fn: showVersion, isValueRequired: false}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const [files] = await Promise.all([
|
|
60
|
+
initFiles(currentOptions.files, rootFolder),
|
|
61
|
+
initReporter(getReporter, setReporter, currentOptions.flags),
|
|
62
|
+
selectTimer()
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
addEventListener('error', event => {
|
|
66
|
+
console.log('UNHANDLED ERROR:', event.message);
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (currentOptions.optionFlags['--info'] === '') {
|
|
71
|
+
showInfo(currentOptions, files);
|
|
72
|
+
await new Promise(r => process.stdout.write('', r));
|
|
73
|
+
process.exitCode = 0;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!files.length) {
|
|
78
|
+
console.log('No files found.');
|
|
79
|
+
await new Promise(r => process.stdout.write('', r));
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const reporter = getReporter(),
|
|
85
|
+
worker = new TestWorker(reporter, currentOptions.parallel, currentOptions.flags);
|
|
86
|
+
|
|
87
|
+
reporter.report({type: 'test', test: 0});
|
|
88
|
+
|
|
89
|
+
await new Promise(resolve => {
|
|
90
|
+
worker.done = () => resolve();
|
|
91
|
+
worker.execute(files);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const hasFailed = reporter.state && reporter.state.failed > 0;
|
|
95
|
+
|
|
96
|
+
reporter.report({
|
|
97
|
+
type: 'end',
|
|
98
|
+
test: 0,
|
|
99
|
+
fail: hasFailed
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await new Promise(r => process.stdout.write('', r));
|
|
103
|
+
process.exitCode = hasFailed ? 1 : 0;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
main().catch(error => console.error('ERROR:', error));
|
package/bin/tape6-server.js
CHANGED
package/index.js
CHANGED
|
@@ -175,9 +175,7 @@ const init = async () => {
|
|
|
175
175
|
|
|
176
176
|
if (isBrowser && typeof window.addEventListener == 'function') {
|
|
177
177
|
// Control plane: the parent page posts `tape6-terminate` to drain a running
|
|
178
|
-
// test
|
|
179
|
-
// fires the abort signal so the test unwinds. There is no in-page
|
|
180
|
-
// force-kill — see dev-docs/worker-control-channel.md.
|
|
178
|
+
// test; there is no in-page force-kill — see dev-docs/worker-control-channel.md.
|
|
181
179
|
window.addEventListener('message', event => {
|
|
182
180
|
if (event.data?.type === 'tape6-terminate') getReporter()?.terminate();
|
|
183
181
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tape-six",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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",
|
|
@@ -68,8 +68,6 @@
|
|
|
68
68
|
"url": "https://github.com/uhop/tape-six/issues"
|
|
69
69
|
},
|
|
70
70
|
"homepage": "https://github.com/uhop/tape-six#readme",
|
|
71
|
-
"llms": "https://raw.githubusercontent.com/uhop/tape-six/master/llms.txt",
|
|
72
|
-
"llmsFull": "https://raw.githubusercontent.com/uhop/tape-six/master/llms-full.txt",
|
|
73
71
|
"files": [
|
|
74
72
|
"index.*",
|
|
75
73
|
"bin",
|
package/src/State.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type {Timer} from './utils/timer.js';
|
|
2
|
+
|
|
3
|
+
/** Marker property stamped on tape-six-owned errors and serialized specials. */
|
|
4
|
+
export const signature: string;
|
|
5
|
+
|
|
6
|
+
/** Thrown to unwind a test when `failOnce` or a bail-out fires. Detect with `isStopTest`. */
|
|
7
|
+
export class StopTest extends Error {
|
|
8
|
+
constructor(...args: ConstructorParameters<typeof Error>);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `true` for `StopTest` instances, including cross-realm copies revived from
|
|
13
|
+
* serialized events (matched structurally via the `signature` marker).
|
|
14
|
+
*/
|
|
15
|
+
export const isStopTest: (error: unknown) => boolean;
|
|
16
|
+
|
|
17
|
+
/** Structural check for `node:assert`-style `AssertionError` objects (any realm). */
|
|
18
|
+
export const isAssertionError: (error: unknown) => boolean;
|
|
19
|
+
|
|
20
|
+
/** Extract the `at ...` frames of an error stack as trimmed strings. */
|
|
21
|
+
export const getStackList: (error: {stack: string}) => string[];
|
|
22
|
+
|
|
23
|
+
/** A test event routed through the reporter pipeline — a loose bag. See TESTING.md. */
|
|
24
|
+
export type TestEvent = {[key: string]: unknown};
|
|
25
|
+
|
|
26
|
+
export type Hook = () => void | Promise<void>;
|
|
27
|
+
|
|
28
|
+
export interface StateOptions {
|
|
29
|
+
name?: string;
|
|
30
|
+
test?: number;
|
|
31
|
+
time?: number;
|
|
32
|
+
skip?: boolean;
|
|
33
|
+
todo?: boolean;
|
|
34
|
+
failOnce?: boolean;
|
|
35
|
+
timer?: Timer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Per-test bookkeeping node: assert/skip/fail counters, inherited flags
|
|
40
|
+
* (`skip`, `todo`, `failOnce`), hook queues, and the abort controller backing
|
|
41
|
+
* `t.signal`. States chain via `parent`; iterating a state walks that chain
|
|
42
|
+
* from the innermost out.
|
|
43
|
+
*/
|
|
44
|
+
export class State {
|
|
45
|
+
constructor(parent: State | null | undefined, options: StateOptions);
|
|
46
|
+
|
|
47
|
+
parent: State | null | undefined;
|
|
48
|
+
name: string;
|
|
49
|
+
test: number;
|
|
50
|
+
skip: boolean | undefined;
|
|
51
|
+
todo: boolean | undefined;
|
|
52
|
+
failOnce: boolean | undefined;
|
|
53
|
+
/** The parent's assert count at creation — offsets this state's assert ids. */
|
|
54
|
+
offset: number;
|
|
55
|
+
asserts: number;
|
|
56
|
+
skipped: number;
|
|
57
|
+
failed: number;
|
|
58
|
+
/** Direct assertions in this state only — `t.plan()`'s comparison basis. */
|
|
59
|
+
localAsserts: number;
|
|
60
|
+
stopTest: boolean;
|
|
61
|
+
timer: Timer;
|
|
62
|
+
startTime: number;
|
|
63
|
+
time: number;
|
|
64
|
+
abortController: AbortController;
|
|
65
|
+
|
|
66
|
+
beforeAll: Hook[];
|
|
67
|
+
afterAll: Hook[];
|
|
68
|
+
beforeEach: Hook[];
|
|
69
|
+
afterEach: Hook[];
|
|
70
|
+
isBeforeAllUsed: boolean;
|
|
71
|
+
|
|
72
|
+
/** Aborted when the test ends — backs `t.signal`. */
|
|
73
|
+
get signal(): AbortSignal;
|
|
74
|
+
abort(): void;
|
|
75
|
+
dispose(): void;
|
|
76
|
+
|
|
77
|
+
/** Roll this state's counters up into the parent. */
|
|
78
|
+
updateParent(): void;
|
|
79
|
+
|
|
80
|
+
runBeforeAll(): Promise<void>;
|
|
81
|
+
runAfterAll(): Promise<void>;
|
|
82
|
+
runBeforeEach(): Promise<void>;
|
|
83
|
+
runAfterEach(): Promise<void>;
|
|
84
|
+
|
|
85
|
+
/** Normalize an event: counters, ids, timing, stack extraction, stop propagation. */
|
|
86
|
+
preprocess(event: TestEvent): TestEvent;
|
|
87
|
+
/** Throws `StopTest` when the (pre-processed) event demands a stop, unless suppressed. */
|
|
88
|
+
postprocess(event: TestEvent, suppressStopTest?: boolean): void;
|
|
89
|
+
|
|
90
|
+
/** Walk this state and its ancestors, innermost first. */
|
|
91
|
+
[Symbol.iterator](): IterableIterator<State>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default State;
|
package/src/State.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-self-types="./State.d.ts"
|
|
2
|
+
|
|
1
3
|
import {getTimer} from './utils/timer.js';
|
|
2
4
|
|
|
3
5
|
export const signature = 'tape6-!@#$%^&*';
|
|
@@ -184,9 +186,8 @@ export class State {
|
|
|
184
186
|
typeof event.diffTime !== 'number' && (event.diffTime = event.time - this.startTime);
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// inside `if (event.fail)` blocks, so for passing assertions this is wasted work.
|
|
189
|
+
// reporters read event.at / event.stackList only for failures — stack
|
|
190
|
+
// walking on passing assertions is wasted work
|
|
190
191
|
if (isFailed) {
|
|
191
192
|
if (
|
|
192
193
|
event.type === 'assert' &&
|
package/src/Tester.js
CHANGED
|
@@ -460,10 +460,7 @@ export class Tester {
|
|
|
460
460
|
}
|
|
461
461
|
Tester.prototype.any = Tester.prototype._ = any;
|
|
462
462
|
|
|
463
|
-
//
|
|
464
|
-
// function → no-op; same name + different function → throws. Lets plugins
|
|
465
|
-
// extend the tester (e.g., spawnBin, withTempDir, waitFor) without colliding
|
|
466
|
-
// silently when two of them claim the same name.
|
|
463
|
+
// plugin extension point — a same-name collision must fail loudly, not silently override
|
|
467
464
|
export const registerTesterMethod = (name, fn) => {
|
|
468
465
|
if (typeof name !== 'string' || !name)
|
|
469
466
|
throw new TypeError('registerTesterMethod: name must be a non-empty string');
|
|
@@ -15,9 +15,6 @@ export class Reporter {
|
|
|
15
15
|
this.terminating = false;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// Emit one record to stdout, mirroring console.log's arguments. On Bun,
|
|
19
|
-
// console.log to a subprocess-piped stdout is lossy / stalls under load, so route
|
|
20
|
-
// through process.stdout.write there; Node/Deno keep the proven console.log path.
|
|
21
18
|
writeOut(...args) {
|
|
22
19
|
if (isBun) {
|
|
23
20
|
process.stdout.write(args.map(a => (typeof a == 'string' ? a : String(a))).join(' ') + '\n');
|
|
@@ -34,14 +31,9 @@ export class Reporter {
|
|
|
34
31
|
this.state?.abort();
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// assertion, t.signal rejects signal-aware awaits — and remember it so any
|
|
41
|
-
// test that starts afterwards stops at its first assertion too (closing the
|
|
42
|
-
// race where `terminate` lands while the worker is still starting up). The
|
|
43
|
-
// test's cleanup (finally / afterEach / afterAll) still runs. See
|
|
44
|
-
// dev-docs/worker-control-channel.md.
|
|
34
|
+
// `terminating` is remembered so a test that starts after the terminate stops
|
|
35
|
+
// too — closes the race where `terminate` lands mid-startup. Cleanup
|
|
36
|
+
// (finally / afterEach / afterAll) still runs. See dev-docs/worker-control-channel.md.
|
|
45
37
|
terminate() {
|
|
46
38
|
this.terminating = true;
|
|
47
39
|
for (let state = this.state; state; state = state.parent) {
|
|
@@ -61,8 +53,7 @@ export class Reporter {
|
|
|
61
53
|
timer: this.timer
|
|
62
54
|
});
|
|
63
55
|
++this.depth;
|
|
64
|
-
//
|
|
65
|
-
// assertion rather than letting it run to completion.
|
|
56
|
+
// a terminate landed before this test started
|
|
66
57
|
if (this.terminating) {
|
|
67
58
|
this.state.stopTest = true;
|
|
68
59
|
this.state.abort();
|
|
@@ -74,13 +74,10 @@ export class TestWorker extends EventServer {
|
|
|
74
74
|
const worker = this.idToWorker[id];
|
|
75
75
|
if (!worker) return;
|
|
76
76
|
if (reason === 'done') {
|
|
77
|
-
// The test already finished; just tear the worker down.
|
|
78
77
|
this.#kill(id);
|
|
79
78
|
return;
|
|
80
79
|
}
|
|
81
|
-
//
|
|
82
|
-
// cleanup hooks, then force-kill as a backstop after graceTimeout in case
|
|
83
|
-
// the test ignores the signal and never settles.
|
|
80
|
+
// force-kill backstop: the test may ignore the abort signal and never settle
|
|
84
81
|
if (this.graceTimers[id]) return; // already draining
|
|
85
82
|
try {
|
|
86
83
|
worker.postMessage({type: 'terminate', reason});
|
|
@@ -38,9 +38,7 @@ let currentReporter = null,
|
|
|
38
38
|
|
|
39
39
|
addEventListener('message', async event => {
|
|
40
40
|
const msg = event.data;
|
|
41
|
-
//
|
|
42
|
-
// abort signal so a running test unwinds (cleanup runs); a terminate that
|
|
43
|
-
// lands before the reporter exists is remembered and applied at startup.
|
|
41
|
+
// a terminate can land before the reporter exists — remember it, apply at startup
|
|
44
42
|
if (msg?.type === 'terminate') {
|
|
45
43
|
pendingTerminate = true;
|
|
46
44
|
currentReporter?.terminate();
|
|
@@ -57,13 +57,10 @@ export class TestWorker extends EventServer {
|
|
|
57
57
|
const worker = this.idToWorker[id];
|
|
58
58
|
if (!worker) return;
|
|
59
59
|
if (reason === 'done') {
|
|
60
|
-
// The test already finished; just tear the worker down.
|
|
61
60
|
this.#kill(id);
|
|
62
61
|
return;
|
|
63
62
|
}
|
|
64
|
-
//
|
|
65
|
-
// cleanup hooks, then force-kill as a backstop after graceTimeout in case
|
|
66
|
-
// the test ignores the signal and never settles.
|
|
63
|
+
// force-kill backstop: the test may ignore the abort signal and never settle
|
|
67
64
|
if (this.graceTimers[id]) return; // already draining
|
|
68
65
|
try {
|
|
69
66
|
worker.postMessage({type: 'terminate', reason});
|
|
@@ -38,9 +38,7 @@ let currentReporter = null,
|
|
|
38
38
|
|
|
39
39
|
addEventListener('message', async event => {
|
|
40
40
|
const msg = event.data;
|
|
41
|
-
//
|
|
42
|
-
// abort signal so a running test unwinds (cleanup runs); a terminate that
|
|
43
|
-
// lands before the reporter exists is remembered and applied at startup.
|
|
41
|
+
// a terminate can land before the reporter exists — remember it, apply at startup
|
|
44
42
|
if (msg?.type === 'terminate') {
|
|
45
43
|
pendingTerminate = true;
|
|
46
44
|
currentReporter?.terminate();
|
|
@@ -76,14 +76,11 @@ export class TestWorker extends EventServer {
|
|
|
76
76
|
const worker = this.idToWorker[id];
|
|
77
77
|
if (!worker) return;
|
|
78
78
|
if (reason === 'done') {
|
|
79
|
-
//
|
|
80
|
-
// tear the worker down.
|
|
79
|
+
// worker_threads have no flush race
|
|
81
80
|
this.#kill(id);
|
|
82
81
|
return;
|
|
83
82
|
}
|
|
84
|
-
//
|
|
85
|
-
// cleanup hooks, then force-kill as a backstop after graceTimeout in case
|
|
86
|
-
// the test ignores the signal and never settles.
|
|
83
|
+
// force-kill backstop: the test may ignore the abort signal and never settle
|
|
87
84
|
if (this.graceTimers[id]) return; // already draining
|
|
88
85
|
try {
|
|
89
86
|
worker.postMessage({type: 'terminate', reason});
|
|
@@ -40,9 +40,7 @@ let currentReporter = null,
|
|
|
40
40
|
pendingTerminate = false;
|
|
41
41
|
|
|
42
42
|
parentPort.on('message', async msg => {
|
|
43
|
-
//
|
|
44
|
-
// abort signal so a running test unwinds (cleanup runs); a terminate that
|
|
45
|
-
// lands before the reporter exists is remembered and applied at startup.
|
|
43
|
+
// a terminate can land before the reporter exists — remember it, apply at startup
|
|
46
44
|
if (msg?.type === 'terminate') {
|
|
47
45
|
pendingTerminate = true;
|
|
48
46
|
currentReporter?.terminate();
|
|
@@ -62,10 +62,8 @@ export class TestWorker extends EventServer {
|
|
|
62
62
|
}
|
|
63
63
|
destroyTask(id, reason = 'done') {
|
|
64
64
|
if (reason !== 'done') {
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// + fire the abort signal. The run unwinds and closes itself with 'done',
|
|
68
|
-
// where the reset below happens.
|
|
65
|
+
// same process, no force-kill — the aborted run closes itself with 'done',
|
|
66
|
+
// where the reset below happens
|
|
69
67
|
this.reporter.terminate();
|
|
70
68
|
return;
|
|
71
69
|
}
|
package/src/test.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type {Test, Tester, TestOptions} from '../index.js';
|
|
2
|
+
import type {State, TestEvent} from './State.js';
|
|
3
|
+
import type {Deferred} from './utils/makeDeferred.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The output-reporter surface `runTests` relies on: an event sink plus the
|
|
7
|
+
* root `State` carrying suite-level hooks and the stop flag. Every shipped
|
|
8
|
+
* reporter satisfies it.
|
|
9
|
+
*/
|
|
10
|
+
export interface OutputReporter {
|
|
11
|
+
report(event: TestEvent): void;
|
|
12
|
+
state: State;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `true` once a runner claimed this process (test files skip self-running then). */
|
|
17
|
+
export const getConfiguredFlag: () => boolean;
|
|
18
|
+
export const setConfiguredFlag: (value: unknown) => boolean;
|
|
19
|
+
|
|
20
|
+
/** Resolves with the runner module once `setTestRunner` announces it. */
|
|
21
|
+
export const testRunner: Promise<unknown>;
|
|
22
|
+
export const setTestRunner: (runner: unknown) => void;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ask to be called (once, deferred) when the first top-level test registers —
|
|
26
|
+
* how runners learn a test file has content. Fires immediately if tests are
|
|
27
|
+
* already queued.
|
|
28
|
+
*/
|
|
29
|
+
export const registerNotifyCallback: (callback: () => void) => void;
|
|
30
|
+
export const unregisterNotifyCallback: (callback: () => void) => boolean;
|
|
31
|
+
|
|
32
|
+
/** The active tester stack, innermost last. */
|
|
33
|
+
export const getTesters: () => Tester[];
|
|
34
|
+
|
|
35
|
+
/** The innermost currently-running tester, or `null` outside any test. */
|
|
36
|
+
export const getTester: () => Tester | null;
|
|
37
|
+
|
|
38
|
+
/** A queued top-level test: normalized options + the deferred resolved when it finishes. */
|
|
39
|
+
export interface QueuedTest {
|
|
40
|
+
options: TestOptions;
|
|
41
|
+
deferred?: Deferred<unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const getTests: () => QueuedTest[];
|
|
45
|
+
export const clearTests: () => void;
|
|
46
|
+
|
|
47
|
+
export const getReporter: () => OutputReporter | null;
|
|
48
|
+
export const setReporter: (newReporter: OutputReporter) => OutputReporter;
|
|
49
|
+
|
|
50
|
+
export type Hook = () => void | Promise<void>;
|
|
51
|
+
|
|
52
|
+
export const getBeforeAll: () => Hook[];
|
|
53
|
+
export const clearBeforeAll: () => void;
|
|
54
|
+
export const getAfterAll: () => Hook[];
|
|
55
|
+
export const clearAfterAll: () => void;
|
|
56
|
+
export const getBeforeEach: () => Hook[];
|
|
57
|
+
export const clearBeforeEach: () => void;
|
|
58
|
+
export const getAfterEach: () => Hook[];
|
|
59
|
+
export const clearAfterEach: () => void;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run queued tests against the current reporter. Returns `false` when stopped
|
|
63
|
+
* early (`failOnce` / bail-out), `true` otherwise. Runner-facing — test files
|
|
64
|
+
* use `test()`.
|
|
65
|
+
*/
|
|
66
|
+
export const runTests: (tests: QueuedTest[]) => Promise<boolean>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Register a top-level test (delegates to the innermost tester when called
|
|
70
|
+
* inside a running test). Carries `skip` / `todo` / `asPromise` and the hook
|
|
71
|
+
* registrars as properties — see the `Test` interface.
|
|
72
|
+
*/
|
|
73
|
+
export const test: Test;
|
|
74
|
+
|
|
75
|
+
export const beforeAll: (fn: Hook) => void;
|
|
76
|
+
export const afterAll: (fn: Hook) => void;
|
|
77
|
+
export const beforeEach: (fn: Hook) => void;
|
|
78
|
+
export const afterEach: (fn: Hook) => void;
|
|
79
|
+
|
|
80
|
+
export {beforeAll as before, afterAll as after};
|
|
81
|
+
|
|
82
|
+
export default test;
|
package/src/test.js
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teardown reason delivered to `destroyTask`:
|
|
3
|
+
* - `'done'` — the task finished; tear its worker down now.
|
|
4
|
+
* - `'failOnce'` — stop / bail-out: abort an in-flight task.
|
|
5
|
+
* - `'timeout'` — the per-worker deadline (`workerTimeout`) fired.
|
|
6
|
+
*
|
|
7
|
+
* Any reason but `'done'` means: drain cooperatively (let cleanup run),
|
|
8
|
+
* then force-kill after `graceTimeout` where the transport allows.
|
|
9
|
+
*/
|
|
10
|
+
export type DestroyReason = 'done' | 'failOnce' | 'timeout';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A test event routed to the reporter — a loose bag (`type`, `test`,
|
|
14
|
+
* `name`, `marker`, `operator`, `fail`, `data`, ...). See TESTING.md.
|
|
15
|
+
*/
|
|
16
|
+
export type TestEvent = {[key: string]: unknown};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The reporter surface `EventServer` needs. Structural — every tape-six
|
|
20
|
+
* reporter satisfies it.
|
|
21
|
+
*/
|
|
22
|
+
export interface EventServerReporter {
|
|
23
|
+
report(event: TestEvent, suppressStopTest?: boolean): void;
|
|
24
|
+
state?: {stopTest?: boolean; [key: string]: unknown} | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Options consumed by the base class. Runners pass their own keys through
|
|
29
|
+
* (`flags`, `importmap`, `serverUrl`, `browser`, ...) — hence the index
|
|
30
|
+
* signature.
|
|
31
|
+
*/
|
|
32
|
+
export interface EventServerOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Cooperative-drain window in ms before a force-kill (default: 5000).
|
|
35
|
+
* CLI runners inject `TAPE6_GRACE_TIMEOUT` via `getOptions()`.
|
|
36
|
+
*/
|
|
37
|
+
graceTimeout?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Per-worker deadline in ms; unset or 0 disables it. CLI runners inject
|
|
40
|
+
* `TAPE6_WORKER_TIMEOUT` via `getOptions()`.
|
|
41
|
+
*/
|
|
42
|
+
workerTimeout?: number;
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Base class for test-worker drivers (the parallel/seq runners and the
|
|
48
|
+
* `tape-six-proc` / `tape-six-puppeteer` / `tape-six-playwright` siblings).
|
|
49
|
+
*
|
|
50
|
+
* Two planes: DATA (worker → reporter — the `report()`/`close()`
|
|
51
|
+
* event-ordering machinery) and CONTROL (reporter/runner → worker — a
|
|
52
|
+
* single `terminate` delivered per transport by `destroyTask`). See
|
|
53
|
+
* dev-docs/worker-control-channel.md.
|
|
54
|
+
*
|
|
55
|
+
* Subclasses implement the two transport hooks: `makeTask` and
|
|
56
|
+
* `destroyTask`.
|
|
57
|
+
*/
|
|
58
|
+
export class EventServer {
|
|
59
|
+
constructor(reporter: EventServerReporter, numberOfTasks?: number, options?: EventServerOptions);
|
|
60
|
+
|
|
61
|
+
reporter: EventServerReporter;
|
|
62
|
+
numberOfTasks: number;
|
|
63
|
+
options: EventServerOptions;
|
|
64
|
+
|
|
65
|
+
/** Drain window in ms before a force-kill; see `EventServerOptions.graceTimeout`. */
|
|
66
|
+
graceTimeout: number;
|
|
67
|
+
/** Per-worker deadline in ms; 0 = off; see `EventServerOptions.workerTimeout`. */
|
|
68
|
+
workerTimeout: number;
|
|
69
|
+
/**
|
|
70
|
+
* Set on the first stop / bail-out signal; every in-flight task then gets
|
|
71
|
+
* `destroyTask(id, 'failOnce')`. Tasks that finish starting later must
|
|
72
|
+
* check it themselves (a just-created worker can miss the sweep).
|
|
73
|
+
*/
|
|
74
|
+
stopRequested: boolean;
|
|
75
|
+
|
|
76
|
+
/** Completion hook: called once when the last task closes. Assigned by runners. */
|
|
77
|
+
done?: () => void;
|
|
78
|
+
|
|
79
|
+
// DATA-plane bookkeeping — not a consumer surface.
|
|
80
|
+
totalTasks: number;
|
|
81
|
+
fileQueue: string[];
|
|
82
|
+
passThroughId: string | null;
|
|
83
|
+
retained: Record<string, TestEvent[]>;
|
|
84
|
+
readyQueue: TestEvent[][];
|
|
85
|
+
liveTasks: Set<string>;
|
|
86
|
+
deadlineTimers: Record<string, ReturnType<typeof setTimeout>>;
|
|
87
|
+
|
|
88
|
+
/** DATA plane: forward one worker event, preserving per-task ordering. */
|
|
89
|
+
report(id: string, event: TestEvent): void;
|
|
90
|
+
/**
|
|
91
|
+
* DATA plane: a task's stream ended — release its retained events, start
|
|
92
|
+
* the next queued file, and call `destroyTask(id, 'done')`.
|
|
93
|
+
*/
|
|
94
|
+
close(id: string): void;
|
|
95
|
+
|
|
96
|
+
/** Start a worker for `fileName` now if a slot is free, else queue it. */
|
|
97
|
+
createTask(fileName: string): void;
|
|
98
|
+
/** `createTask` every file. */
|
|
99
|
+
execute(files: string[]): void;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Transport hook: start a worker for `fileName` and return its task id.
|
|
103
|
+
* The base implementation is a no-op returning `undefined`.
|
|
104
|
+
*/
|
|
105
|
+
makeTask(fileName: string): string | null | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Transport hook (CONTROL plane): deliver `terminate` to one worker.
|
|
108
|
+
* The base implementation is a no-op; see `DestroyReason` for the
|
|
109
|
+
* expected teardown semantics.
|
|
110
|
+
*/
|
|
111
|
+
destroyTask(id: string, reason?: DestroyReason): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default EventServer;
|
package/src/utils/EventServer.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-self-types="./EventServer.d.ts"
|
|
2
|
+
|
|
1
3
|
import defer from './defer.js';
|
|
2
4
|
|
|
3
5
|
// Fallback used when no graceTimeout is supplied through options (e.g. the
|
|
@@ -5,15 +7,9 @@ import defer from './defer.js';
|
|
|
5
7
|
// (TAPE6_GRACE_TIMEOUT) via getOptions(); see src/utils/config.js.
|
|
6
8
|
const DEFAULT_GRACE_TIMEOUT = 5_000;
|
|
7
9
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// transport by destroyTask(id, reason). Two triggers fire it:
|
|
12
|
-
// - normal completion: close() terminates the just-finished worker ('done');
|
|
13
|
-
// - stop / bail-out: when reporter.state.stopTest is set (failOnce / bail),
|
|
14
|
-
// every in-flight worker is terminated ('failOnce').
|
|
15
|
-
// An optional per-worker wall-clock deadline (workerTimeout, Layer 2) fires the
|
|
16
|
-
// same command on expiry ('timeout'). See dev-docs/worker-control-channel.md.
|
|
10
|
+
// Two planes: DATA (worker -> reporter, the report()/close() event-ordering
|
|
11
|
+
// machinery) and CONTROL (reporter/runner -> worker, a single `terminate`
|
|
12
|
+
// delivered per transport by destroyTask). See dev-docs/worker-control-channel.md.
|
|
17
13
|
export class EventServer {
|
|
18
14
|
constructor(reporter, numberOfTasks = 1, options = {}) {
|
|
19
15
|
this.reporter = reporter;
|
|
@@ -112,9 +108,7 @@ export class EventServer {
|
|
|
112
108
|
execute(files) {
|
|
113
109
|
files.forEach(fileName => this.createTask(fileName));
|
|
114
110
|
}
|
|
115
|
-
//
|
|
116
|
-
// the initial batch (createTask) and queue drains (close) so liveTasks and
|
|
117
|
-
// the optional deadline cover every task, not just the first numberOfTasks.
|
|
111
|
+
// createTask and close() both route here so liveTasks + the deadline cover every task
|
|
118
112
|
#startTask(fileName) {
|
|
119
113
|
const id = this.makeTask(fileName);
|
|
120
114
|
if (id == null) return id;
|
|
@@ -134,9 +128,6 @@ export class EventServer {
|
|
|
134
128
|
delete this.deadlineTimers[id];
|
|
135
129
|
}
|
|
136
130
|
}
|
|
137
|
-
// Terminate every in-flight worker (abort) on top of the existing "stop
|
|
138
|
-
// scheduling new files." Fires at most once per run; callers decide when a
|
|
139
|
-
// stop / bail-out signal has been observed.
|
|
140
131
|
#requestStop() {
|
|
141
132
|
if (this.stopRequested) return;
|
|
142
133
|
this.stopRequested = true;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type {OutputReporter} from '../test.js';
|
|
2
|
+
|
|
3
|
+
/** Print `<commandName> <version>` (version read from package.json). */
|
|
4
|
+
export const printVersion: (commandName: string) => void;
|
|
5
|
+
|
|
6
|
+
/** Print the standard `--help` header, usage line, and an aligned option table. */
|
|
7
|
+
export const printHelp: (
|
|
8
|
+
commandName: string,
|
|
9
|
+
description: string,
|
|
10
|
+
usage: string,
|
|
11
|
+
options?: [flag: string, description: string][]
|
|
12
|
+
) => void;
|
|
13
|
+
|
|
14
|
+
/** Print the flag legend (uppercase = on, lowercase = off). */
|
|
15
|
+
export const printFlagOptions: () => void;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Expand wildcard patterns to files under `rootFolder`. A `!`-prefixed pattern
|
|
19
|
+
* excludes matches accumulated so far — order matters. Returns paths relative
|
|
20
|
+
* to `rootFolder`.
|
|
21
|
+
*/
|
|
22
|
+
export const resolvePatterns: (rootFolder: string, patterns: string[]) => Promise<string[]>;
|
|
23
|
+
|
|
24
|
+
/** The `tape6` config bag: test-set patterns keyed by environment, plus the importmap. */
|
|
25
|
+
export interface Tape6Config {
|
|
26
|
+
tests?: string | string[];
|
|
27
|
+
cli?: string | string[];
|
|
28
|
+
browser?: string | string[];
|
|
29
|
+
importmap?: {imports: Record<string, string>};
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read `tape6.json`, else `package.json#tape6`, else the default patterns. */
|
|
34
|
+
export const getConfig: (
|
|
35
|
+
rootFolder: string,
|
|
36
|
+
traceFn?: (msg: string) => void
|
|
37
|
+
) => Promise<Tape6Config>;
|
|
38
|
+
|
|
39
|
+
/** Resolve the file list for `type` (an environment-named test set + `cli` + `tests`). */
|
|
40
|
+
export const resolveTests: (
|
|
41
|
+
rootFolder: string,
|
|
42
|
+
type: string,
|
|
43
|
+
traceFn?: (msg: string) => void
|
|
44
|
+
) => Promise<string[]>;
|
|
45
|
+
|
|
46
|
+
export type RuntimeName = 'node' | 'bun' | 'deno' | 'browser' | 'unknown';
|
|
47
|
+
|
|
48
|
+
/** The detected runtime and its env-var accessor (returns `undefined` in browsers). */
|
|
49
|
+
export const runtime: {name: RuntimeName; getEnvVar: (name: string) => string | undefined};
|
|
50
|
+
|
|
51
|
+
export type ReporterType = 'jsonl' | 'min' | 'tap' | 'tty';
|
|
52
|
+
|
|
53
|
+
export const reporterFileNames: Record<ReporterType, string>;
|
|
54
|
+
|
|
55
|
+
/** Reporter selected by `TAPE6_JSONL` / `TAPE6_MIN` / `TAPE6_TAP`; `null` in browsers. */
|
|
56
|
+
export const getReporterType: () => ReporterType | null;
|
|
57
|
+
export const getReporterFileName: (type?: ReporterType | null) => string;
|
|
58
|
+
|
|
59
|
+
export const DEFAULT_START_TIMEOUT: number;
|
|
60
|
+
export const DEFAULT_GRACE_TIMEOUT: number;
|
|
61
|
+
|
|
62
|
+
/** Per-worker startup budget (`TAPE6_WORKER_START_TIMEOUT`) in ms. */
|
|
63
|
+
export const getTimeoutValue: () => number;
|
|
64
|
+
/** Cooperative-drain window before force-kill (`TAPE6_GRACE_TIMEOUT`) in ms. */
|
|
65
|
+
export const getGraceTimeout: () => number;
|
|
66
|
+
/** Optional per-worker wall-clock budget (`TAPE6_WORKER_TIMEOUT`) in ms; 0 = off. */
|
|
67
|
+
export const getWorkerTimeout: () => number;
|
|
68
|
+
|
|
69
|
+
/** Single-letter flag → option name (`f` → `failureOnly`, ...). */
|
|
70
|
+
export const flagNames: Record<string, string>;
|
|
71
|
+
|
|
72
|
+
export interface ArgOption {
|
|
73
|
+
aliases?: string[];
|
|
74
|
+
initialValue?: string | null;
|
|
75
|
+
isValueRequired?: boolean;
|
|
76
|
+
/** Custom handler; mutate `flags` directly. */
|
|
77
|
+
fn?: (flags: Record<string, unknown>, name: string, value: string) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A bare function is shorthand for `{fn, isValueRequired: true}`. */
|
|
81
|
+
export type ArgOptions = Record<
|
|
82
|
+
string,
|
|
83
|
+
ArgOption | ((flags: Record<string, unknown>, name: string, value: string) => void)
|
|
84
|
+
>;
|
|
85
|
+
|
|
86
|
+
/** Parse CLI args into files + flag values. CLI-only: in a browser returns an empty array. */
|
|
87
|
+
export const processArgs: (argOptions: ArgOptions) => {
|
|
88
|
+
files: string[];
|
|
89
|
+
flags: Record<string, unknown>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export interface RunnerFlags {
|
|
93
|
+
/** The raw flag string, uppercase = on, lowercase = off. */
|
|
94
|
+
flags: string;
|
|
95
|
+
failureOnly?: boolean;
|
|
96
|
+
showTime?: boolean;
|
|
97
|
+
showBanner?: boolean;
|
|
98
|
+
showData?: boolean;
|
|
99
|
+
failOnce?: boolean;
|
|
100
|
+
showStack?: boolean;
|
|
101
|
+
showLog?: boolean;
|
|
102
|
+
showAssertNumber?: boolean;
|
|
103
|
+
monochrome?: boolean;
|
|
104
|
+
useJsonL?: boolean;
|
|
105
|
+
noConsoleCapture?: boolean;
|
|
106
|
+
hideStreams?: boolean;
|
|
107
|
+
/** Consumed by the parent (`EventServer`) only; children ignore them. */
|
|
108
|
+
graceTimeout?: number;
|
|
109
|
+
workerTimeout?: number;
|
|
110
|
+
[key: string]: unknown;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface RunnerOptions {
|
|
114
|
+
flags: RunnerFlags;
|
|
115
|
+
parallel: number;
|
|
116
|
+
files: string[];
|
|
117
|
+
/** Raw parsed option values, keyed by canonical option name. */
|
|
118
|
+
optionFlags: Record<string, unknown>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse standard runner options (`--flags` / `-f`, `--par` / `-p`, plus
|
|
123
|
+
* `extraOptions`), fold in `TAPE6_*` env defaults, re-export `TAPE6_FLAGS`,
|
|
124
|
+
* and resolve `parallel` to the available hardware when unset.
|
|
125
|
+
*/
|
|
126
|
+
export const getOptions: (extraOptions?: ArgOptions) => RunnerOptions;
|
|
127
|
+
|
|
128
|
+
/** Instantiate and install the environment-appropriate reporter if none is set. */
|
|
129
|
+
export const initReporter: (
|
|
130
|
+
getReporter: () => OutputReporter | null,
|
|
131
|
+
setReporter: (reporter: OutputReporter) => void,
|
|
132
|
+
flags: RunnerFlags
|
|
133
|
+
) => Promise<void>;
|
|
134
|
+
|
|
135
|
+
/** Explicit `files` win; otherwise resolve the configured test sets for `type`. */
|
|
136
|
+
export const initFiles: (files: string[], rootFolder: string, type?: string) => Promise<string[]>;
|
|
137
|
+
|
|
138
|
+
/** Print the runtime / reporter / parallelism / flags / files banner. */
|
|
139
|
+
export const showInfo: (options: RunnerOptions, files?: string[] | null) => void;
|
package/src/utils/config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-self-types="./config.d.ts"
|
|
2
|
+
|
|
1
3
|
import {promises as fsp, readFileSync} from 'node:fs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import os from 'node:os';
|
|
@@ -170,9 +172,6 @@ export const getReporterFileName = type => {
|
|
|
170
172
|
export const DEFAULT_START_TIMEOUT = 5_000;
|
|
171
173
|
export const DEFAULT_GRACE_TIMEOUT = 5_000;
|
|
172
174
|
|
|
173
|
-
// Read a positive-millisecond env var, falling back to `fallback` when it is
|
|
174
|
-
// unset, non-numeric, non-positive, or Infinity. Always returns `fallback` in
|
|
175
|
-
// the browser, where these env vars don't exist.
|
|
176
175
|
const readTimeoutEnv = (name, fallback) => {
|
|
177
176
|
if (runtime.name === 'browser') return fallback;
|
|
178
177
|
const value = runtime.getEnvVar(name);
|
|
@@ -192,8 +191,7 @@ export const getTimeoutValue = () =>
|
|
|
192
191
|
// allows. See dev-docs/worker-control-channel.md.
|
|
193
192
|
export const getGraceTimeout = () => readTimeoutEnv('TAPE6_GRACE_TIMEOUT', DEFAULT_GRACE_TIMEOUT);
|
|
194
193
|
|
|
195
|
-
// Optional wall-clock budget per worker/file (Layer 2 termination)
|
|
196
|
-
// it — the default — so a worker deadline fires only when explicitly set.
|
|
194
|
+
// Optional wall-clock budget per worker/file (Layer 2 termination); 0 (default) disables it.
|
|
197
195
|
export const getWorkerTimeout = () => readTimeoutEnv('TAPE6_WORKER_TIMEOUT', 0);
|
|
198
196
|
|
|
199
197
|
export const flagNames = Object.fromEntries(
|
|
@@ -322,8 +320,7 @@ export const getOptions = extraOptions => {
|
|
|
322
320
|
}
|
|
323
321
|
options.parallel = parallel;
|
|
324
322
|
|
|
325
|
-
//
|
|
326
|
-
// TestWorker (EventServer). Children ignore them; only the parent acts.
|
|
323
|
+
// children ignore these; only the parent (EventServer) acts on them
|
|
327
324
|
options.flags.graceTimeout = getGraceTimeout();
|
|
328
325
|
options.flags.workerTimeout = getWorkerTimeout();
|
|
329
326
|
|
|
@@ -99,9 +99,7 @@ export const listenControlChannel = async getReporter => {
|
|
|
99
99
|
}
|
|
100
100
|
if (buffer) handleLine(buffer);
|
|
101
101
|
|
|
102
|
-
//
|
|
103
|
-
// broke), soft-terminate so a still-running test drains. Then fall through:
|
|
104
|
-
// the loop empties and the runtime exits naturally, flushing stdout.
|
|
102
|
+
// EOF — soft-terminate per the header contract
|
|
105
103
|
if (!terminated) getReporter()?.terminate();
|
|
106
104
|
armWatchdog(graceTimeout, getExitCode);
|
|
107
105
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-plane client for the tape6 test server's endpoints (`/--tests`,
|
|
3
|
+
* `/--patterns`, `/--importmap`). Owned by tape-six core: the endpoints and
|
|
4
|
+
* the cert-cache location (`node_modules/.cache/tape6/cert.pem`, written by
|
|
5
|
+
* `src/test-server/certs.js`) are core's contract — browser providers import
|
|
6
|
+
* this client instead of hand-mirroring it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** `true` when a TLS request read a plaintext answer — an h1 server behind an `https:` URL. */
|
|
10
|
+
export const isProtocolMismatch: (error: unknown) => boolean;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The response surface the control client guarantees — the common subset of
|
|
14
|
+
* global `fetch`'s `Response` and the scoped-TLS path's shim.
|
|
15
|
+
*/
|
|
16
|
+
export interface ControlResponse {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
status: number;
|
|
19
|
+
json(): Promise<any>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a control fetch rooted at `rootFolder` (defaults to `process.cwd()`).
|
|
24
|
+
*
|
|
25
|
+
* Every request carries a 3s hard deadline (a dying listener can accept and
|
|
26
|
+
* never answer); timeouts surface as errors with `code: 'ETIMEDOUT'` and are
|
|
27
|
+
* never retried on a lower trust rung. `http:` URLs go through global `fetch`;
|
|
28
|
+
* `https:` goes through `node:https` so TLS trust stays scoped to control
|
|
29
|
+
* requests — never process-wide — walking the ladder: `TAPE6_CERT` pinned as
|
|
30
|
+
* CA (failures stay loud, no fallback), else the server's cached self-signed
|
|
31
|
+
* cert, else relaxed verification. The first successful rung is cached for
|
|
32
|
+
* the fetch's lifetime.
|
|
33
|
+
*/
|
|
34
|
+
export const createControlFetch: (rootFolder?: string) => (url: string) => Promise<ControlResponse>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @ts-self-types="./controlFetch.d.ts"
|
|
2
|
+
|
|
3
|
+
import {readFile} from 'node:fs/promises';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
|
|
8
|
+
// Control-plane client (/--tests, /--patterns, /--importmap). Global fetch can't
|
|
9
|
+
// relax TLS per request, so https: goes through node:https with trust scoped to
|
|
10
|
+
// these requests only — never process-wide. Ladder: TAPE6_CERT pinned as CA;
|
|
11
|
+
// else the server's cached self-signed cert (tape6 cert-ladder location);
|
|
12
|
+
// else relaxed verification.
|
|
13
|
+
|
|
14
|
+
// hard deadline on every request: a dying listener can accept and never answer
|
|
15
|
+
// (the 2026-07-04 chained-run hang)
|
|
16
|
+
const TIMEOUT = 3000;
|
|
17
|
+
|
|
18
|
+
// a TLS request read a plaintext HTTP answer — an h1 server behind an https: URL
|
|
19
|
+
export const isProtocolMismatch = error =>
|
|
20
|
+
!!error && (error.code === 'EPROTO' || /wrong version number/i.test(error.message || ''));
|
|
21
|
+
|
|
22
|
+
export const createControlFetch = (rootFolder = process.cwd()) => {
|
|
23
|
+
let tlsOptions = null;
|
|
24
|
+
const get = (url, options) =>
|
|
25
|
+
new Promise((resolve, reject) => {
|
|
26
|
+
const request = https.get(url, options, response => {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
response.on('data', chunk => chunks.push(chunk));
|
|
29
|
+
response.on('error', error => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
reject(error);
|
|
32
|
+
});
|
|
33
|
+
response.on('end', () => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
const body = Buffer.concat(chunks);
|
|
36
|
+
resolve({
|
|
37
|
+
ok: response.statusCode >= 200 && response.statusCode < 300,
|
|
38
|
+
status: response.statusCode,
|
|
39
|
+
json: async () => JSON.parse(body.toString())
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
const timer = setTimeout(
|
|
44
|
+
() =>
|
|
45
|
+
request.destroy(
|
|
46
|
+
Object.assign(new Error(`control request timed out after ${TIMEOUT}ms: ${url}`), {
|
|
47
|
+
code: 'ETIMEDOUT'
|
|
48
|
+
})
|
|
49
|
+
),
|
|
50
|
+
TIMEOUT
|
|
51
|
+
);
|
|
52
|
+
request.on('error', error => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(error);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
return async url => {
|
|
58
|
+
if (!/^https:/i.test(url)) return fetch(url, {signal: AbortSignal.timeout(TIMEOUT)});
|
|
59
|
+
if (tlsOptions) return get(url, tlsOptions);
|
|
60
|
+
const certPath = process.env.TAPE6_CERT;
|
|
61
|
+
if (certPath) {
|
|
62
|
+
// explicit pin: failures stay loud, no fallback
|
|
63
|
+
const options = {ca: await readFile(path.resolve(rootFolder, certPath))};
|
|
64
|
+
const response = await get(url, options);
|
|
65
|
+
tlsOptions = options;
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
const cached = await readFile(
|
|
69
|
+
path.join(rootFolder, 'node_modules', '.cache', 'tape6', 'cert.pem')
|
|
70
|
+
).catch(() => null);
|
|
71
|
+
if (cached) {
|
|
72
|
+
try {
|
|
73
|
+
const options = {ca: cached};
|
|
74
|
+
const response = await get(url, options);
|
|
75
|
+
tlsOptions = options;
|
|
76
|
+
return response;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// a relaxed retry after a timeout would just burn a second deadline
|
|
79
|
+
if (error.code === 'ETIMEDOUT') throw error;
|
|
80
|
+
// stale cache or an external server with its own cert — try relaxed
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const options = {rejectUnauthorized: false};
|
|
84
|
+
const response = await get(url, options);
|
|
85
|
+
tlsOptions = options;
|
|
86
|
+
return response;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A promise with its `resolve` / `reject` exposed — `Promise.withResolvers()`
|
|
3
|
+
* where the platform provides it, a hand-rolled equivalent otherwise.
|
|
4
|
+
*/
|
|
5
|
+
export interface Deferred<T = unknown> {
|
|
6
|
+
promise: Promise<T>;
|
|
7
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
8
|
+
reject: (reason?: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const makeDeferred: <T = unknown>() => Deferred<T>;
|
|
12
|
+
|
|
13
|
+
export default makeDeferred;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Minimal millisecond clock. `Date` satisfies it; so does a wrapped `performance`. */
|
|
2
|
+
export interface Timer {
|
|
3
|
+
now(): number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** The current shared timer. Defaults to `Date` until `selectTimer()` upgrades it. */
|
|
7
|
+
export const getTimer: () => Timer;
|
|
8
|
+
|
|
9
|
+
/** Replace the shared timer; returns the new one. */
|
|
10
|
+
export const setTimer: (newTimer: Timer) => Timer;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Select the best clock available: `performance.now() + performance.timeOrigin`
|
|
14
|
+
* (native or via `node:perf_hooks`), falling back to `Date`.
|
|
15
|
+
*/
|
|
16
|
+
export const selectTimer: () => Promise<void>;
|
package/src/utils/timer.js
CHANGED
package/web-app/TestWorker.js
CHANGED
|
@@ -89,11 +89,9 @@ export default class TestWorker extends EventServer {
|
|
|
89
89
|
this.#removeIframe(id);
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// best-effort backstop. A driver-backed run (puppeteer / playwright) can do
|
|
96
|
-
// a real kill from Node — not wired here. See dev-docs/worker-control-channel.md.
|
|
92
|
+
// in-page JS can't force-kill a hung iframe script — iframe removal after
|
|
93
|
+
// graceTimeout is a best-effort backstop; a real kill needs a driver
|
|
94
|
+
// (puppeteer / playwright). See dev-docs/worker-control-channel.md.
|
|
97
95
|
if (this.graceTimers[id]) return;
|
|
98
96
|
const iframe = document.getElementById('test-iframe-' + id);
|
|
99
97
|
if (!iframe) return;
|
package/bin/tape6-deno-main.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import process from 'node:process';
|
|
2
|
-
import {fileURLToPath} from 'node:url';
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
getOptions,
|
|
6
|
-
initFiles,
|
|
7
|
-
initReporter,
|
|
8
|
-
showInfo,
|
|
9
|
-
printVersion,
|
|
10
|
-
printHelp,
|
|
11
|
-
printFlagOptions
|
|
12
|
-
} from '../src/utils/config.js';
|
|
13
|
-
|
|
14
|
-
import {getReporter, setReporter} from '../src/test.js';
|
|
15
|
-
import {selectTimer} from '../src/utils/timer.js';
|
|
16
|
-
|
|
17
|
-
import TestWorker from '../src/runners/deno/TestWorker.js';
|
|
18
|
-
|
|
19
|
-
const rootFolder = Deno.cwd();
|
|
20
|
-
|
|
21
|
-
const showSelf = () => {
|
|
22
|
-
const self = new URL(import.meta.url);
|
|
23
|
-
if (self.protocol === 'file:') {
|
|
24
|
-
console.log(fileURLToPath(self));
|
|
25
|
-
} else {
|
|
26
|
-
console.log(self);
|
|
27
|
-
}
|
|
28
|
-
Deno.exit(0);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const showVersion = () => {
|
|
32
|
-
printVersion('tape6-deno');
|
|
33
|
-
Deno.exit(0);
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const showHelp = () => {
|
|
37
|
-
printHelp('tape6-deno', 'Tape6 test runner for Deno', 'tape6-deno [options] [files...]', [
|
|
38
|
-
['--flags, -f <flags>', 'Set reporter flags (env: TAPE6_FLAGS)'],
|
|
39
|
-
['--par, -p <n>', 'Set parallelism level (env: TAPE6_PAR)'],
|
|
40
|
-
['--info', 'Show configuration info and exit'],
|
|
41
|
-
['--self', 'Print the path to this script and exit'],
|
|
42
|
-
['--help, -h', 'Show this help message and exit'],
|
|
43
|
-
['--version, -v', 'Show version and exit']
|
|
44
|
-
]);
|
|
45
|
-
printFlagOptions();
|
|
46
|
-
Deno.exit(0);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const main = async () => {
|
|
50
|
-
const currentOptions = getOptions({
|
|
51
|
-
'--self': {fn: showSelf, isValueRequired: false},
|
|
52
|
-
'--info': {isValueRequired: false},
|
|
53
|
-
'--help': {aliases: ['-h'], fn: showHelp, isValueRequired: false},
|
|
54
|
-
'--version': {aliases: ['-v'], fn: showVersion, isValueRequired: false}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const [files] = await Promise.all([
|
|
58
|
-
initFiles(currentOptions.files, rootFolder),
|
|
59
|
-
initReporter(getReporter, setReporter, currentOptions.flags),
|
|
60
|
-
selectTimer()
|
|
61
|
-
]);
|
|
62
|
-
|
|
63
|
-
addEventListener('error', event => {
|
|
64
|
-
console.log('UNHANDLED ERROR:', event.message);
|
|
65
|
-
event.preventDefault();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
if (currentOptions.optionFlags['--info'] === '') {
|
|
69
|
-
showInfo(currentOptions, files);
|
|
70
|
-
await new Promise(r => process.stdout.write('', r));
|
|
71
|
-
process.exitCode = 0;
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (!files.length) {
|
|
76
|
-
console.log('No files found.');
|
|
77
|
-
await new Promise(r => process.stdout.write('', r));
|
|
78
|
-
process.exitCode = 1;
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const reporter = getReporter(),
|
|
83
|
-
worker = new TestWorker(reporter, currentOptions.parallel, currentOptions.flags);
|
|
84
|
-
|
|
85
|
-
reporter.report({type: 'test', test: 0});
|
|
86
|
-
|
|
87
|
-
await new Promise(resolve => {
|
|
88
|
-
worker.done = () => resolve();
|
|
89
|
-
worker.execute(files);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
const hasFailed = reporter.state && reporter.state.failed > 0;
|
|
93
|
-
|
|
94
|
-
reporter.report({
|
|
95
|
-
type: 'end',
|
|
96
|
-
test: 0,
|
|
97
|
-
fail: hasFailed
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
await new Promise(r => process.stdout.write('', r));
|
|
101
|
-
process.exitCode = hasFailed ? 1 : 0;
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
main().catch(error => console.error('ERROR:', error));
|