workkit-core 0.0.0-stage → 0.86.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/LICENSE.md +110 -0
- package/README.md +52 -2
- package/bin/workkit.js +18 -0
- package/package.json +27 -4
- package/test/index.js +4 -0
- package/test/lib/harness.js +130 -0
- package/test/lib/log.js +32 -0
- package/test/lib/node-test.js +88 -0
- package/test/lib/process.js +58 -0
- package/test/lib/scratch.js +25 -0
- package/test/lib/seed.js +27 -0
- package/test/lib/spawn.js +178 -0
- package/test/lib/suites.js +33 -0
- package/test/run.js +301 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Functional Source License, Version 1.1, MIT Future License
|
|
2
|
+
|
|
3
|
+
## Abbreviation
|
|
4
|
+
|
|
5
|
+
FSL-1.1-MIT
|
|
6
|
+
|
|
7
|
+
## Notice
|
|
8
|
+
|
|
9
|
+
Copyright 2026 ITW Creative Works
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
22
|
+
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the MIT license that is effective on the second anniversary of the date we make
|
|
91
|
+
the Software available. On or after that date, you may use the Software under
|
|
92
|
+
the MIT license, in which case the following will apply:
|
|
93
|
+
|
|
94
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
95
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
96
|
+
the Software without restriction, including without limitation the rights to
|
|
97
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
98
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
99
|
+
so, subject to the following conditions:
|
|
100
|
+
|
|
101
|
+
The above copyright notice and this permission notice shall be included in all
|
|
102
|
+
copies or substantial portions of the Software.
|
|
103
|
+
|
|
104
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
105
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
106
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
107
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
108
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
109
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
110
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# workkit-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The test runner and test library of [workkit](https://github.com/itw-creative-works/workkit), as an npm package. It runs every suite in parallel worker processes, prints each suite's output whole and in order, and ends on one totals line.
|
|
4
|
+
|
|
5
|
+
## Use it
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i -D workkit-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Then set the repo's test script to the package's bin:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{ "scripts": { "test": "workkit test" } }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`npm test` runs every `test/**/*.test.js` suite; `npm test -- test/x.test.js` runs only that file.
|
|
18
|
+
With the workkit plugin installed, `workkit test` and `workkit test test/x.test.js` in a terminal do the same: the global command hands its arguments to `npm test`, and inside `npm test` npm puts this package's bin first on PATH.
|
|
19
|
+
The bin runs only `test`; any other verb exits 2 and names the global `workkit` command it belongs to.
|
|
20
|
+
|
|
21
|
+
## The run
|
|
22
|
+
|
|
23
|
+
- Every `test/**/*.test.js` file is a suite, each run in its own process, as many at once as the machine has cores less one; a repo with no `test/` folder is read from `tests/`.
|
|
24
|
+
- A suite named in the repo's `test/lib/serial.js` (`module.exports = { SERIAL: ['x.test.js'] }`) runs alone, after the parallel wave.
|
|
25
|
+
- `test/mods/*.test.ts` suites run after that through `claude plugin test`, skipped by name with no `claude` on PATH.
|
|
26
|
+
- The run ends with `N passed, M failed (Ns, K suites)` and a `slowest:` line, then names every skipped suite and case, then every failure; any failure exits 1.
|
|
27
|
+
- A suite written on `node:test` runs too, through Node's own `run()`, counted the same way.
|
|
28
|
+
|
|
29
|
+
## The library
|
|
30
|
+
|
|
31
|
+
`workkit-core/test` is ES, and `require()` loads it as well:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const { test, assertEq, summary, selfRun } = require('workkit-core/test');
|
|
35
|
+
|
|
36
|
+
const run = async () => {
|
|
37
|
+
await test('adds', () => assertEq(1 + 1, 2));
|
|
38
|
+
return summary();
|
|
39
|
+
};
|
|
40
|
+
module.exports = run;
|
|
41
|
+
if (require.main === module) selfRun(run);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- The cases: `group`, `test`, `assert`, `assertEq`, `skip`, `testUnless`, `skipSuite`, `summary`, `selfRun`, `log` (lines printed under a failing case, and under every case with `TEST_LOG=1`), `stripAnsi`, `hasLaunchd`.
|
|
45
|
+
- Scratch and worlds: `mkTmp(prefix)`, a temp folder removed at exit; `seeded(build)`, a folder built once per process and copied per case.
|
|
46
|
+
- Processes on one deadline (`SPAWN_DEADLINE`): `run`, `start` (with `end()` climbing SIGINT, SIGTERM, SIGKILL), `endTree`, `until`, `elapsedUntil`, `alive`, `readPid`.
|
|
47
|
+
- Suite discovery: `findSuites`, `findModSuites`, `POOL`.
|
|
48
|
+
|
|
49
|
+
What a test must be worth and may cost, and the shape every runner keeps: [docs/tests.md](https://github.com/itw-creative-works/workkit/blob/main/docs/tests.md).
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
[FSL-1.1-MIT](https://github.com/itw-creative-works/workkit/blob/main/LICENSE.md), the same as workkit.
|
package/bin/workkit.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
// The package's `workkit` bin, the one npm puts first on PATH inside `npm test`:
|
|
4
|
+
// `workkit test [paths]` runs the test runner over the current folder. Every
|
|
5
|
+
// other verb belongs to the global `workkit` command, so it exits 2 naming it.
|
|
6
|
+
|
|
7
|
+
import { main } from '../test/run.js';
|
|
8
|
+
|
|
9
|
+
const [verb, ...args] = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
if (verb === 'test') {
|
|
12
|
+
main(args);
|
|
13
|
+
} else {
|
|
14
|
+
// The global command's error shape (wk_error): `✖ <verb>: <message>`.
|
|
15
|
+
const named = verb === undefined ? 'workkit' : `workkit ${verb}`;
|
|
16
|
+
console.error(`\u2716 ${verb ?? 'workkit'}: workkit-core's bin runs only \`workkit test [paths]\`; \`${named}\` belongs to the global workkit command (the workkit plugin's workflow/workkit.sh)`);
|
|
17
|
+
process.exitCode = 2;
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workkit-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.86.0",
|
|
4
|
+
"description": "The workkit test runner and its harness: `workkit test` runs a repo's suites in worker processes and prints one totals line.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "FSL-1.1-MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/itw-creative-works/workkit.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.22"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"test"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"workkit": "bin/workkit.js"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
"./test": "./test/index.js"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"prepack": "node -e \"require('node:fs').copyFileSync('../../LICENSE.md','LICENSE.md')\""
|
|
28
|
+
}
|
|
29
|
+
}
|
package/test/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
// Tiny zero-dependency test harness shared across the suites: `test()` prints
|
|
3
|
+
// a mark per case and each suite reports via `summary()`. AGENTS.md § Tests.
|
|
4
|
+
// It is the library's one entry: every concern file's exports ride on its own.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { openCase, drain, CASE_INDENT } from './log.js';
|
|
9
|
+
|
|
10
|
+
export * from './process.js';
|
|
11
|
+
export * from './scratch.js';
|
|
12
|
+
export * from './seed.js';
|
|
13
|
+
export * from './suites.js';
|
|
14
|
+
export * from './log.js';
|
|
15
|
+
export * from './spawn.js';
|
|
16
|
+
|
|
17
|
+
// The workflow state directory's name for the test layer. The engine and the
|
|
18
|
+
// hooks hold their own copy, so change all three together.
|
|
19
|
+
export const WORKKIT_DIR = '.workkit';
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
let failed = 0;
|
|
23
|
+
const failures = [];
|
|
24
|
+
const skips = [];
|
|
25
|
+
|
|
26
|
+
// A stack line with its file:/// URL (an ESM frame) in path form, so it reads as a plain frame.
|
|
27
|
+
const pathForm = (line) => line.replace(/file:\/\/\S+?(?=:\d+:\d+\)?$)/, (url) => fileURLToPath(url));
|
|
28
|
+
|
|
29
|
+
// The first stack frame outside this library: the line in the suite that threw.
|
|
30
|
+
// The stack opens with the message, so its lines are skipped before frames are read.
|
|
31
|
+
const caseFrame = (err) => {
|
|
32
|
+
const stack = String(err?.stack ?? '');
|
|
33
|
+
const message = String(err?.message ?? '');
|
|
34
|
+
const at = stack.indexOf(message);
|
|
35
|
+
return (at === -1 ? stack : stack.slice(at + message.length)).split('\n')
|
|
36
|
+
.map((line) => pathForm(line.trim()))
|
|
37
|
+
.find((line) => {
|
|
38
|
+
const file = line.match(/^at (?:async )?(?:.* \()?(.+?):\d+:\d+\)?$/)?.[1];
|
|
39
|
+
return file !== undefined && path.isAbsolute(file) && !file.startsWith(`${import.meta.dirname}${path.sep}`);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const group = (name) => {
|
|
44
|
+
console.log(`\n\x1b[1m[${name}]\x1b[0m`);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const test = async (name, fn) => {
|
|
48
|
+
openCase();
|
|
49
|
+
try {
|
|
50
|
+
await fn();
|
|
51
|
+
passed++;
|
|
52
|
+
console.log(` \x1b[32m✓\x1b[0m ${name}`);
|
|
53
|
+
const lines = drain();
|
|
54
|
+
if (process.env.TEST_LOG === '1') for (const line of lines) console.log(`${CASE_INDENT}${line}`);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
failed++;
|
|
57
|
+
failures.push({ name, err });
|
|
58
|
+
console.log(` \x1b[31m✗\x1b[0m ${name}`);
|
|
59
|
+
console.log(`${CASE_INDENT}\x1b[31m${err.message}\x1b[0m`);
|
|
60
|
+
for (const line of drain()) console.log(`${CASE_INDENT}${line}`);
|
|
61
|
+
const frame = caseFrame(err);
|
|
62
|
+
if (frame) console.log(`${CASE_INDENT}\x1b[2m${frame}\x1b[0m`);
|
|
63
|
+
}
|
|
64
|
+
// One event-loop turn per case: a killed runner's disconnect reaches the
|
|
65
|
+
// worker between cases, which an await on a sync case alone never gives it.
|
|
66
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const assert = (cond, msg) => {
|
|
70
|
+
if (!cond) {
|
|
71
|
+
throw new Error(msg || 'assertion failed');
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const assertEq = (actual, expected, msg) => {
|
|
76
|
+
if (actual !== expected) {
|
|
77
|
+
throw new Error(`${msg || 'assertEq failed'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// One case this machine cannot answer, named rather than dropped: it counts as
|
|
82
|
+
// neither pass nor failure. skipSuite() would drop the whole file, cases that
|
|
83
|
+
// could run included.
|
|
84
|
+
export const skip = (name, reason) => {
|
|
85
|
+
skips.push({ name, reason });
|
|
86
|
+
console.log(` \x1b[33m⊘\x1b[0m ${name} \x1b[33m(skipped: ${reason})\x1b[0m`);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// `test` where the case is answerable, a named skip where it is not. Asking the
|
|
90
|
+
// question inside the body instead scores a pass for a case that asserted nothing.
|
|
91
|
+
// const newsTest = testUnless(IS_WINDOWS, 'the gh shim is not startable here');
|
|
92
|
+
export const testUnless = (skipIt, reason) => (skipIt ? (name) => skip(name, reason) : test);
|
|
93
|
+
|
|
94
|
+
// A suite whose preconditions are absent skips itself instead of failing, so
|
|
95
|
+
// `npm test` stays the one command everywhere and the requirement lives in the
|
|
96
|
+
// suite it describes.
|
|
97
|
+
export const skipSuite = (reason) => {
|
|
98
|
+
const err = new Error(reason);
|
|
99
|
+
err.suiteSkipped = true;
|
|
100
|
+
throw err;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Mirrors the engine's `uname -s` question rather than probing PATH for
|
|
104
|
+
// launchctl, so the two never disagree about which branch the code takes.
|
|
105
|
+
export const hasLaunchd = () => process.platform === 'darwin';
|
|
106
|
+
|
|
107
|
+
// Log output with the helpers' color codes removed, so lines compare exactly.
|
|
108
|
+
export const stripAnsi = (text) => text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
109
|
+
|
|
110
|
+
// Snapshot and reset this file's totals; the runner sums them across files.
|
|
111
|
+
export const summary = () => {
|
|
112
|
+
const result = { passed, failed, failures: failures.slice(), skips: skips.slice() };
|
|
113
|
+
passed = 0;
|
|
114
|
+
failed = 0;
|
|
115
|
+
failures.length = 0;
|
|
116
|
+
skips.length = 0;
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Runs one suite file on its own; without it a whole-suite skip would surface
|
|
121
|
+
// as an unhandled crash.
|
|
122
|
+
export const selfRun = (runner) => {
|
|
123
|
+
runner()
|
|
124
|
+
.then(({ failed }) => process.exit(failed > 0 ? 1 : 0))
|
|
125
|
+
.catch((err) => {
|
|
126
|
+
if (!err.suiteSkipped) throw err;
|
|
127
|
+
console.log(`\x1b[33m⊘ skipped:\x1b[0m ${err.message}`);
|
|
128
|
+
process.exit(0);
|
|
129
|
+
});
|
|
130
|
+
};
|
package/test/lib/log.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
// A case's own log: lines a case writes as it runs, held in the one slot the
|
|
3
|
+
// running case owns, which the harness drains as the case ends and prints under
|
|
4
|
+
// a failure (and under a pass with TEST_LOG=1). A line written with no case
|
|
5
|
+
// running prints at once, indented as a case's lines are.
|
|
6
|
+
|
|
7
|
+
import util from 'node:util';
|
|
8
|
+
|
|
9
|
+
// The indent of every line the harness prints under a case's mark.
|
|
10
|
+
export const CASE_INDENT = ' ';
|
|
11
|
+
|
|
12
|
+
// The running case's lines, null while no case runs.
|
|
13
|
+
let current = null;
|
|
14
|
+
|
|
15
|
+
/** Log one line: the parts joined by spaces, a non-string inspected as console.log shows it. */
|
|
16
|
+
export const log = (...parts) => {
|
|
17
|
+
const line = parts.map((part) => (typeof part === 'string' ? part : util.inspect(part))).join(' ');
|
|
18
|
+
if (current) current.push(line);
|
|
19
|
+
else console.log(`${CASE_INDENT}${line}`);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Open the slot of a case starting to run. */
|
|
23
|
+
export const openCase = () => {
|
|
24
|
+
current = [];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The running case's lines, in the order written; the slot is closed after. */
|
|
28
|
+
export const drain = () => {
|
|
29
|
+
const lines = current ?? [];
|
|
30
|
+
current = null;
|
|
31
|
+
return lines;
|
|
32
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The runner's second suite shape (test/run.js): a file written on
|
|
2
|
+
// `node:test`, told apart by its text since requiring it runs its tests, and run
|
|
3
|
+
// through Node's own `run()` into the result object a kit-harness suite sends.
|
|
4
|
+
|
|
5
|
+
import { run } from 'node:test';
|
|
6
|
+
|
|
7
|
+
// Met outside any quote: a `require('node:test')` call, or the specifier of an
|
|
8
|
+
// `import ... from` or bare `import` naming it.
|
|
9
|
+
const IMPORT_AT = /\brequire\(\s*(['"])node:test\1\s*\)|\b(?:from|import)\s*(['"])node:test\2/y;
|
|
10
|
+
const COMMENT = /^(?:\/\/|\/\*|\*)/;
|
|
11
|
+
// The line Node prints for an uncaught error, `Error: msg` or `Error [CODE]: msg`.
|
|
12
|
+
const ERROR_LINE = /^\w*Error(?: \[[^\]]+\])?: (.+)$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a suite's source imports `node:test`. Comment lines, quoted strings
|
|
16
|
+
* and template literals (a fixture another suite writes) never count; only a
|
|
17
|
+
* template literal runs past its line, and a backtick inside a quote opens none.
|
|
18
|
+
* @param {string} text the suite file's source
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export const isNodeTestSuite = (text) => {
|
|
22
|
+
let quote = '';
|
|
23
|
+
for (const line of text.split('\n')) {
|
|
24
|
+
if (!quote && COMMENT.test(line.trim())) continue;
|
|
25
|
+
for (let i = 0; i < line.length; i++) {
|
|
26
|
+
const c = line[i];
|
|
27
|
+
if (c === '\\') {
|
|
28
|
+
i++;
|
|
29
|
+
} else if (quote) {
|
|
30
|
+
if (c === quote) quote = '';
|
|
31
|
+
} else if (c === '/' && line[i + 1] === '/') {
|
|
32
|
+
break;
|
|
33
|
+
} else if (c === "'" || c === '"' || c === '`') {
|
|
34
|
+
quote = c;
|
|
35
|
+
} else {
|
|
36
|
+
IMPORT_AT.lastIndex = i;
|
|
37
|
+
if (IMPORT_AT.test(line)) return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (quote !== '`') quote = '';
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Runs one `node:test` suite and resolves the kit harness's result object.
|
|
47
|
+
* Its stdout and stderr are written through as they arrive; a load failure is
|
|
48
|
+
* the whole-suite failure `load <rel>`.
|
|
49
|
+
* @param {string} suite the suite's absolute path
|
|
50
|
+
* @param {string} rel the suite's path relative to test/, its name in a failure
|
|
51
|
+
* @returns {Promise<{passed: number, failed: number, failures: object[], skips: object[]}>}
|
|
52
|
+
*/
|
|
53
|
+
export const runNodeTest = (suite, rel) => new Promise((resolve, reject) => {
|
|
54
|
+
const result = { passed: 0, failed: 0, failures: [], skips: [] };
|
|
55
|
+
const stderrLines = [];
|
|
56
|
+
const stream = run({ files: [suite], concurrency: 1 });
|
|
57
|
+
stream.on('data', ({ type, data }) => {
|
|
58
|
+
if (type === 'test:stdout') {
|
|
59
|
+
process.stdout.write(data.message);
|
|
60
|
+
} else if (type === 'test:stderr') {
|
|
61
|
+
process.stderr.write(data.message);
|
|
62
|
+
stderrLines.push(data.message.trimEnd());
|
|
63
|
+
} else if (type === 'test:fail' && data.name === suite) {
|
|
64
|
+
// A load failure's event says only `test failed`; the thrown error's
|
|
65
|
+
// message is in the child's stderr, the last line Node printed for it.
|
|
66
|
+
const thrown = stderrLines.map((line) => line.match(ERROR_LINE)).filter(Boolean).pop();
|
|
67
|
+
result.failed++;
|
|
68
|
+
result.failures.push({ name: `load ${rel}`, message: thrown ? thrown[1] : data.details.error.message, whole: true });
|
|
69
|
+
} else if (data.todo && (type === 'test:pass' || type === 'test:fail')) {
|
|
70
|
+
// A todo test may throw; Node never counts it as a failure. A todo
|
|
71
|
+
// describe counts nothing, a body that throws included.
|
|
72
|
+
if (data.details.type === 'test') result.skips.push({ name: data.name, reason: 'todo' });
|
|
73
|
+
} else if (type === 'test:pass' && data.skip) {
|
|
74
|
+
// A skipped describe emits nothing for its tests, so its own skip is
|
|
75
|
+
// their one trace.
|
|
76
|
+
result.skips.push({ name: data.name, reason: data.skip === true ? 'skipped' : data.skip });
|
|
77
|
+
} else if (type === 'test:pass' && data.details.type === 'test') {
|
|
78
|
+
result.passed++;
|
|
79
|
+
} else if (type === 'test:fail' && (data.details.type === 'test' || data.details.error.failureType !== 'subtestsFailed')) {
|
|
80
|
+
// A suite fails on its own only when its body or a hook threw; a failed
|
|
81
|
+
// test inside it is already counted.
|
|
82
|
+
result.failed++;
|
|
83
|
+
result.failures.push({ name: data.name, message: data.details.error.message });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
stream.on('error', reject);
|
|
87
|
+
stream.on('end', () => resolve(result));
|
|
88
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watching a process a suite started without waiting for it: whether a pid is
|
|
3
|
+
* alive, a bounded poll for a condition, a pid read from the file a script
|
|
4
|
+
* left, the one spawn deadline and the pipe grace.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The cap every spawn a suite makes carries (`timeout:` on spawnSync, the
|
|
11
|
+
* `until` bound a case leaves unset): wide enough for a loaded pool, since a
|
|
12
|
+
* case asserts order and never a duration.
|
|
13
|
+
*/
|
|
14
|
+
export const SPAWN_DEADLINE = 60000;
|
|
15
|
+
|
|
16
|
+
// How long a child's pipes stay open past its exit, for a grandchild still
|
|
17
|
+
// holding them; the child's own output is already in the pipe by then.
|
|
18
|
+
export const PIPE_GRACE_MS = 1000;
|
|
19
|
+
|
|
20
|
+
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
21
|
+
|
|
22
|
+
/** Whether <pid> names a live process this user may signal. */
|
|
23
|
+
export const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Poll <predicate> every 100 ms until it holds or <ms> pass; the caller's
|
|
27
|
+
* assert names what never appeared.
|
|
28
|
+
* @param {Function} predicate
|
|
29
|
+
* @param {number} [ms]
|
|
30
|
+
* @returns {Promise<boolean>} the predicate's last answer
|
|
31
|
+
*/
|
|
32
|
+
export const until = async (predicate, ms = SPAWN_DEADLINE) => {
|
|
33
|
+
const end = Date.now() + ms;
|
|
34
|
+
while (Date.now() < end) {
|
|
35
|
+
if (predicate()) return true;
|
|
36
|
+
await sleep(100);
|
|
37
|
+
}
|
|
38
|
+
return predicate();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Poll <predicate> every millisecond until it holds or <ms> pass, for a spawn
|
|
43
|
+
* a case must catch on sight: until's 100 ms step is too coarse.
|
|
44
|
+
* @param {Function} predicate
|
|
45
|
+
* @param {number} [ms]
|
|
46
|
+
* @returns {Promise<number>} the milliseconds it took, or Infinity if it never held
|
|
47
|
+
*/
|
|
48
|
+
export const elapsedUntil = async (predicate, ms = SPAWN_DEADLINE) => {
|
|
49
|
+
const begin = Date.now();
|
|
50
|
+
while (Date.now() - begin < ms) {
|
|
51
|
+
if (predicate()) return Date.now() - begin;
|
|
52
|
+
await new Promise((resolve) => { setTimeout(resolve, 1); });
|
|
53
|
+
}
|
|
54
|
+
return predicate() ? Date.now() - begin : Infinity;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The pid a script wrote to <file>, as a number. */
|
|
58
|
+
export const readPid = (file) => Number(fs.readFileSync(file, 'utf8').trim());
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The one writer of a scratch folder: every suite and helper makes its
|
|
2
|
+
// throwaway directories here, and one exit handler removes them all (forced, so
|
|
3
|
+
// a folder a case already removed is harmless). The path is always resolved:
|
|
4
|
+
// the temp dir is a symlink on macOS, and a child's output names the resolved
|
|
5
|
+
// form.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const made = [];
|
|
12
|
+
|
|
13
|
+
process.on('exit', () => {
|
|
14
|
+
for (const dir of made) {
|
|
15
|
+
// A folder a hard-ended child still holds never stops the removals after it.
|
|
16
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** A fresh folder in the temp dir, named `<prefix>XXXXXX`, removed at exit. */
|
|
21
|
+
export const mkTmp = (prefix) => {
|
|
22
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
23
|
+
made.push(dir);
|
|
24
|
+
return dir;
|
|
25
|
+
};
|
package/test/lib/seed.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// A folder built once per process for each shape a caller asks for, on first
|
|
2
|
+
// use, and copied whole into a case's own folder: a case that writes into its
|
|
3
|
+
// copy writes alone, and no case is handed the seed. mkTmp removes every seed
|
|
4
|
+
// when the process exits.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { mkTmp } from './scratch.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {function(string, *): void} build - fills the empty seed folder, given
|
|
11
|
+
* it and the shape it is for
|
|
12
|
+
* @returns {function(string, *=): void} the copier: `(dir, shape)` copies that
|
|
13
|
+
* shape's seed into `dir`, made if missing and merged into if not
|
|
14
|
+
*/
|
|
15
|
+
export const seeded = (build) => {
|
|
16
|
+
const seeds = new Map();
|
|
17
|
+
return (dir, shape = null) => {
|
|
18
|
+
const key = JSON.stringify(shape);
|
|
19
|
+
if (!seeds.has(key)) {
|
|
20
|
+
const seed = mkTmp('workkit-seed-');
|
|
21
|
+
build(seed, shape);
|
|
22
|
+
seeds.set(key, seed);
|
|
23
|
+
}
|
|
24
|
+
// A relative link in a seed copies as the link it is, the way cp -R copies it.
|
|
25
|
+
fs.cpSync(seeds.get(key), dir, { recursive: true, verbatimSymlinks: true });
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process management for a suite: a child run to its end under the spawn
|
|
3
|
+
* deadline, a child started and watched (its streams, its exit, a watchdog at
|
|
4
|
+
* the deadline), and the end of the process group a child leads.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
8
|
+
import { inspect } from 'node:util';
|
|
9
|
+
import { SPAWN_DEADLINE, PIPE_GRACE_MS, until } from './process.js';
|
|
10
|
+
|
|
11
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
12
|
+
|
|
13
|
+
// The rungs `end()` climbs, INT first so the child can log its own end, then
|
|
14
|
+
// TERM, then KILL, END_GRACE apart: the shell's `wk_end_run` ladder.
|
|
15
|
+
const LADDER = ['SIGINT', 'SIGTERM', 'SIGKILL'];
|
|
16
|
+
const END_GRACE = 1500;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Send <signal> to <target> (a pid, or a group as -pid).
|
|
20
|
+
* @returns {'sent'|'gone'|'refused'} gone on ESRCH, refused on EPERM
|
|
21
|
+
*/
|
|
22
|
+
const trySignal = (target, signal) => {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(target, signal);
|
|
25
|
+
return 'sent';
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (err.code === 'ESRCH') return 'gone';
|
|
28
|
+
if (err.code === 'EPERM') return 'refused';
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* trySignal, with a refusal thrown as `<who>: signal refused for pid <pid> (EPERM)`,
|
|
35
|
+
* so a process not ours is never read as alive and waited on.
|
|
36
|
+
* @returns {'sent'|'gone'}
|
|
37
|
+
*/
|
|
38
|
+
const send = (who, target, pid, signal) => {
|
|
39
|
+
const outcome = trySignal(target, signal);
|
|
40
|
+
if (outcome === 'refused') throw new Error(`${who}: signal refused for pid ${pid} (EPERM)`);
|
|
41
|
+
return outcome;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// The group <pid> leads, else <pid> alone. kill(-1) is every process this user
|
|
45
|
+
// may signal, not a group, so pid 1 is signalled alone. A refused group falls
|
|
46
|
+
// through too: on macOS a leader that exited unreaped refuses its group, so only
|
|
47
|
+
// a refusal of the pid itself throws.
|
|
48
|
+
const signalTree = (who, pid, signal) => {
|
|
49
|
+
if (pid === 1 || trySignal(-pid, signal) !== 'sent') send(who, pid, pid, signal);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* End the tree a raw <pid> heads: the group it leads, else the pid; on Windows
|
|
54
|
+
* `taskkill /T /F` ends its whole tree. A pid that is not a positive integer
|
|
55
|
+
* throws, since 0 or a negative one signals the caller's own group or another;
|
|
56
|
+
* a refused signal throws `endTree: signal refused for pid <N> (EPERM)`.
|
|
57
|
+
* @param {number} pid
|
|
58
|
+
* @param {string} [signal]
|
|
59
|
+
*/
|
|
60
|
+
export const endTree = (pid, signal = 'SIGTERM') => {
|
|
61
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error(`endTree: pid must be a positive integer, got ${inspect(pid)}`);
|
|
62
|
+
if (IS_WINDOWS) {
|
|
63
|
+
spawnSync('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore', timeout: SPAWN_DEADLINE });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
signalTree('endTree', pid, signal);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run <cmd> to its end under <deadline>, every stream piped; the deadline KILLs,
|
|
71
|
+
* so a child that traps TERM cannot outlast it.
|
|
72
|
+
* @param {string} cmd
|
|
73
|
+
* @param {string[]} args
|
|
74
|
+
* @param {{cwd?: string, env?: object, input?: string, deadline?: number}} [opts]
|
|
75
|
+
* @returns {{status: ?number, signal: ?string, stdout: string, stderr: string, output: string, timedOut: boolean}}
|
|
76
|
+
*/
|
|
77
|
+
export const run = (cmd, args, { cwd, env, input, deadline = SPAWN_DEADLINE } = {}) => {
|
|
78
|
+
const result = spawnSync(cmd, args, {
|
|
79
|
+
cwd, env, input, timeout: deadline, killSignal: 'SIGKILL', encoding: 'utf8', stdio: 'pipe',
|
|
80
|
+
});
|
|
81
|
+
const timedOut = result.error?.code === 'ETIMEDOUT';
|
|
82
|
+
if (result.error && !timedOut) throw result.error;
|
|
83
|
+
// spawnSync hands the streams back apart, so the order kept is stdout's then stderr's.
|
|
84
|
+
return {
|
|
85
|
+
status: result.status,
|
|
86
|
+
signal: result.signal,
|
|
87
|
+
stdout: result.stdout,
|
|
88
|
+
stderr: result.stderr,
|
|
89
|
+
output: result.stdout + result.stderr,
|
|
90
|
+
timedOut,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start <cmd> without waiting, stdout and stderr piped. `done` resolves once the child has exited and its streams
|
|
96
|
+
* closed, PIPE_GRACE_MS after the exit at most. A watchdog at <deadline> marks `timedOut` and calls `end()`, whose
|
|
97
|
+
* rejection rejects `done`. `end(signal)` climbs the ladder from that rung while the target lives: a detached child's
|
|
98
|
+
* group, else the child alone; on Windows one `taskkill` tree end, which reaches no grandchild once a detached leader
|
|
99
|
+
* has exited. A signal off the ladder rejects; a child that never spawned ends at once, `done` rejecting with the error.
|
|
100
|
+
* A refused rung rejects `end: signal refused for pid <N> (EPERM)`; alive END_GRACE after SIGKILL, `end: pid <N> survived SIGKILL`.
|
|
101
|
+
* @param {string} cmd
|
|
102
|
+
* @param {string[]} args
|
|
103
|
+
* @param {{cwd?: string, env?: object, deadline?: number, detached?: boolean}} [opts]
|
|
104
|
+
* @returns {{child: object, pid: number, output: Function, stdout: Function, stderr: Function, done: Promise, end: Function}}
|
|
105
|
+
*/
|
|
106
|
+
export const start = (cmd, args, { cwd, env, deadline = SPAWN_DEADLINE, detached = false } = {}) => {
|
|
107
|
+
const child = spawn(cmd, args, { cwd, env, detached, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
108
|
+
let out = '';
|
|
109
|
+
let err = '';
|
|
110
|
+
let all = '';
|
|
111
|
+
child.stdout.setEncoding('utf8');
|
|
112
|
+
child.stderr.setEncoding('utf8');
|
|
113
|
+
child.stdout.on('data', (chunk) => { out += chunk; all += chunk; });
|
|
114
|
+
child.stderr.on('data', (chunk) => { err += chunk; all += chunk; });
|
|
115
|
+
|
|
116
|
+
let timedOut = false;
|
|
117
|
+
let exit = {};
|
|
118
|
+
const gone = () => child.exitCode !== null || child.signalCode !== null;
|
|
119
|
+
const closePipes = () => {
|
|
120
|
+
child.stdout.destroy();
|
|
121
|
+
child.stderr.destroy();
|
|
122
|
+
};
|
|
123
|
+
const exited = new Promise((resolve) => {
|
|
124
|
+
child.once('exit', (status, signal) => {
|
|
125
|
+
exit = { status, signal };
|
|
126
|
+
setTimeout(closePipes, PIPE_GRACE_MS).unref();
|
|
127
|
+
resolve();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
const { promise: done, resolve: settleDone, reject: failDone } = Promise.withResolvers();
|
|
131
|
+
child.once('error', failDone);
|
|
132
|
+
child.once('close', () => settleDone({ ...exit, timedOut }));
|
|
133
|
+
// `done` still rejects for whoever awaits it; a caller that only awaits
|
|
134
|
+
// `end()` must not die of the unhandled rejection.
|
|
135
|
+
done.catch(() => {});
|
|
136
|
+
|
|
137
|
+
// A detached group outlives its leader, so its life is the group's, not the child's.
|
|
138
|
+
// Windows has no process group: its one end is taskkill's tree end.
|
|
139
|
+
const group = detached && !IS_WINDOWS;
|
|
140
|
+
// A group whose members exited but are not yet reaped answers EPERM on macOS,
|
|
141
|
+
// so the zero probe reads it as gone, as `_wk_group_gone` does; a real rung's refusal throws.
|
|
142
|
+
const lives = () => (group ? trySignal(-child.pid, 0) === 'sent' : !gone());
|
|
143
|
+
// Wait until the target is gone or END_GRACE has passed, polled as the shell's
|
|
144
|
+
// `_wk_group_gone` does, since a group has no exit event.
|
|
145
|
+
const settle = () => until(() => !lives(), END_GRACE);
|
|
146
|
+
|
|
147
|
+
const end = async (signal = 'SIGINT') => {
|
|
148
|
+
if (!LADDER.includes(signal)) throw new Error(`end: signal must be one of ${LADDER.join(', ')}, got ${inspect(signal)}`);
|
|
149
|
+
clearTimeout(watchdog);
|
|
150
|
+
if (child.pid === undefined) return;
|
|
151
|
+
if (IS_WINDOWS) {
|
|
152
|
+
if (lives()) endTree(child.pid);
|
|
153
|
+
} else {
|
|
154
|
+
for (const rung of LADDER.slice(LADDER.indexOf(signal))) {
|
|
155
|
+
if (!lives()) break;
|
|
156
|
+
if (group) signalTree('end', child.pid, rung);
|
|
157
|
+
else send('end', child.pid, child.pid, rung);
|
|
158
|
+
await settle();
|
|
159
|
+
}
|
|
160
|
+
if (lives()) throw new Error(`end: pid ${child.pid} survived SIGKILL`);
|
|
161
|
+
}
|
|
162
|
+
await exited;
|
|
163
|
+
// The exit's own grace closes the streams, so output written while ending is read.
|
|
164
|
+
await done;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const watchdog = setTimeout(() => {
|
|
168
|
+
if (gone()) return;
|
|
169
|
+
timedOut = true;
|
|
170
|
+
// No caller awaits the watchdog's end, so its rejection fails `done` instead.
|
|
171
|
+
end().catch(failDone);
|
|
172
|
+
}, deadline);
|
|
173
|
+
watchdog.unref();
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
child, pid: child.pid, output: () => all, stdout: () => out, stderr: () => err, done, end,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Suite discovery for the kit's runner (test/run.js) and a repo's
|
|
2
|
+
// runner suite (test/runner.test.js), so which files count as a suite has one home:
|
|
3
|
+
// the Node suites, and the mod half's `test/mods/*.test.ts`.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
// How many suites the runner runs at once: a core left for everything else.
|
|
10
|
+
export const POOL = Math.max(1, os.availableParallelism() - 1);
|
|
11
|
+
|
|
12
|
+
export const findSuites = (dir) => {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
15
|
+
if (entry.name === 'node_modules' || entry.name === 'lib') continue;
|
|
16
|
+
const full = path.join(dir, entry.name);
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
out.push(...findSuites(full));
|
|
19
|
+
} else if (entry.name.endsWith('.test.js')) {
|
|
20
|
+
out.push(full);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// The mod half's wiring suites: every `*.test.ts` and `*.test.tsx` in the
|
|
27
|
+
// test/mods/ folder, which `claude plugin test` runs; none when there is no folder.
|
|
28
|
+
export const findModSuites = (dir) => {
|
|
29
|
+
if (!fs.existsSync(dir)) return [];
|
|
30
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
31
|
+
.filter((entry) => entry.isFile() && /\.test\.tsx?$/.test(entry.name))
|
|
32
|
+
.map((entry) => path.join(dir, entry.name));
|
|
33
|
+
};
|
package/test/run.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
// The kit's test runner, over the repo at the cwd: every
|
|
4
|
+
// `test/**/*.test.js` in worker processes, a parallel wave then the serial tail
|
|
5
|
+
// the repo's `test/lib/serial.js` names, then the mod half (`claude plugin test`
|
|
6
|
+
// over `test/mods/*.test.ts`), totalled; non-zero on any failure. Suite paths
|
|
7
|
+
// after `--` narrow the run. A repo reaches it through `workkit test`, the
|
|
8
|
+
// package's bin. A repo with no `test/` is read from `tests/`.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
14
|
+
import { fork, spawnSync } from 'node:child_process';
|
|
15
|
+
import { findSuites, findModSuites, POOL } from './lib/suites.js';
|
|
16
|
+
import { isNodeTestSuite, runNodeTest } from './lib/node-test.js';
|
|
17
|
+
import { PIPE_GRACE_MS } from './lib/process.js';
|
|
18
|
+
|
|
19
|
+
// Suites and a repo's serial list are CommonJS, loaded by absolute path.
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
const TAIL_LINES = 3;
|
|
23
|
+
const SLOWEST = 5;
|
|
24
|
+
|
|
25
|
+
// The repo's suite folder: `test/`, Node's convention; `tests/` only when the
|
|
26
|
+
// repo has that folder and no `test/`.
|
|
27
|
+
const suiteDir = (root) => {
|
|
28
|
+
const isDir = (dir) => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
29
|
+
const dir = path.join(root, 'test');
|
|
30
|
+
const legacy = path.join(root, 'tests');
|
|
31
|
+
return !isDir(dir) && isDir(legacy) ? legacy : dir;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// The repo the run covers, the cwd, and the suite paths it was handed: `--`
|
|
35
|
+
// dropped, every other argument a suite path.
|
|
36
|
+
const parseArgs = (argv) => {
|
|
37
|
+
const root = process.cwd();
|
|
38
|
+
const testDir = suiteDir(root);
|
|
39
|
+
return { root, testDir, modDir: path.join(testDir, 'mods'), paths: argv.filter((arg) => arg !== '--') };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// The repo's serial tail, relative to the suite folder; none without a serial.js.
|
|
43
|
+
const serialOf = (testDir) => {
|
|
44
|
+
const file = path.join(testDir, 'lib', 'serial.js');
|
|
45
|
+
return fs.existsSync(file) ? require(file).SERIAL : [];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// The named suites, or every suite when none is named; a name that is no
|
|
49
|
+
// suite ends the run before anything starts. A named `.test.ts` picks the mod
|
|
50
|
+
// half, which `claude plugin test` runs whole, since it takes a folder. A
|
|
51
|
+
// serial entry that is no suite ends the run the same way.
|
|
52
|
+
const pickSuites = ({ root, testDir, modDir, paths: args }, serialRels) => {
|
|
53
|
+
const all = fs.existsSync(testDir) ? findSuites(testDir) : [];
|
|
54
|
+
const mods = findModSuites(modDir);
|
|
55
|
+
for (const rel of serialRels) {
|
|
56
|
+
if (!all.includes(path.join(testDir, rel))) {
|
|
57
|
+
console.error(`No suite matches ${rel} (${path.basename(testDir)}/lib/serial.js)`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (args.length === 0) return { suites: all.sort(), withMods: mods.length > 0 };
|
|
62
|
+
const picked = new Set();
|
|
63
|
+
let withMods = false;
|
|
64
|
+
for (const arg of args) {
|
|
65
|
+
const full = path.resolve(root, arg);
|
|
66
|
+
if (mods.includes(full)) {
|
|
67
|
+
withMods = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!all.includes(full)) {
|
|
71
|
+
console.error(`No suite matches ${arg} under ${path.basename(testDir)}/`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
picked.add(full);
|
|
75
|
+
}
|
|
76
|
+
return { suites: [...picked].sort(), withMods };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// The mod half: the plugin's wiring suites, run by the engine itself on the repo
|
|
80
|
+
// root (`claude plugin test` takes a plugin folder). No claude binary is a skip.
|
|
81
|
+
const runModHalf = (root) => {
|
|
82
|
+
const res = spawnSync('claude', ['plugin', 'test', root], { cwd: root, encoding: 'utf8' });
|
|
83
|
+
if (res.error) {
|
|
84
|
+
return { skip: res.error.code === 'ENOENT' ? 'no claude binary on PATH' : `claude did not start: ${res.error.message}` };
|
|
85
|
+
}
|
|
86
|
+
const out = `${res.stdout || ''}${res.stderr || ''}`;
|
|
87
|
+
process.stdout.write(out);
|
|
88
|
+
const count = (word) => Number((out.match(new RegExp(`^\\s*(\\d+) ${word}$`, 'm')) || [])[1] || 0);
|
|
89
|
+
const failures = [...out.matchAll(/^\(fail\) (.+?)(?: \[[\d.]+ms\])?$/gm)]
|
|
90
|
+
.map((m) => ({ name: m[1], message: 'failed under claude plugin test' }));
|
|
91
|
+
let failed = count('fail');
|
|
92
|
+
if (res.status !== 0 && failed === 0) {
|
|
93
|
+
failed = 1;
|
|
94
|
+
failures.push({ name: 'claude plugin test', message: `exited ${res.status}` });
|
|
95
|
+
}
|
|
96
|
+
return { passed: count('pass'), failed, failures };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Worker mode: runs one suite and sends its result as ONE message, then exits 0
|
|
100
|
+
// once its output is flushed. A whole-suite failure (no load, no runner, a
|
|
101
|
+
// throw) is a `whole` failure, named as the runner always named it. A
|
|
102
|
+
// `node:test` suite is picked before any require, which would run its tests.
|
|
103
|
+
const runOne = async (suite, testDir) => {
|
|
104
|
+
const rel = path.relative(testDir, suite);
|
|
105
|
+
const result = { passed: 0, failed: 0, failures: [], skips: [], skipped: null };
|
|
106
|
+
const send = () => process.send(result, () => {
|
|
107
|
+
process.stdout.write('', () => process.stderr.write('', () => process.exit(0)));
|
|
108
|
+
});
|
|
109
|
+
if (isNodeTestSuite(fs.readFileSync(suite, 'utf8'))) {
|
|
110
|
+
Object.assign(result, await runNodeTest(suite, rel));
|
|
111
|
+
send();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const wholeFailure = (name, message) => {
|
|
115
|
+
result.failed = 1;
|
|
116
|
+
result.failures = [{ name, message, whole: true }];
|
|
117
|
+
};
|
|
118
|
+
let mod;
|
|
119
|
+
let loaded = false;
|
|
120
|
+
try {
|
|
121
|
+
mod = require(suite);
|
|
122
|
+
loaded = true;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error(`\x1b[31mfailed to load ${rel}:\x1b[0m ${err.stack || err}`);
|
|
125
|
+
wholeFailure(`load ${rel}`, err.message);
|
|
126
|
+
}
|
|
127
|
+
if (loaded && typeof mod !== 'function') {
|
|
128
|
+
console.error(`\x1b[31m${rel} does not export an async runner function\x1b[0m`);
|
|
129
|
+
wholeFailure(`export ${rel}`, 'no default export function');
|
|
130
|
+
} else if (loaded) {
|
|
131
|
+
try {
|
|
132
|
+
const res = await mod();
|
|
133
|
+
result.passed = res.passed || 0;
|
|
134
|
+
result.failed = res.failed || 0;
|
|
135
|
+
result.failures = (res.failures || []).map((f) => ({ name: f.name, message: f.err.message }));
|
|
136
|
+
result.skips = (res.skips || []).map((s) => ({ name: s.name, reason: s.reason }));
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// skipSuite() reports a missing precondition, not a defect.
|
|
139
|
+
if (err.suiteSkipped) {
|
|
140
|
+
console.log(`\x1b[33m⊘ skipped:\x1b[0m ${err.message}`);
|
|
141
|
+
result.skipped = err.message;
|
|
142
|
+
} else {
|
|
143
|
+
console.error(`\x1b[31m${rel} threw:\x1b[0m ${err.stack || err}`);
|
|
144
|
+
wholeFailure(`run ${rel}`, err.message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
send();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// One suite in a worker process: everything it wrote, both streams in arrival
|
|
152
|
+
// order, its message (null when it ended without one) and its wall time in ms.
|
|
153
|
+
const runWorker = (suite, testDir) => new Promise((resolve) => {
|
|
154
|
+
const began = Date.now();
|
|
155
|
+
const child = fork(import.meta.filename, ['--worker', suite, testDir], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
|
|
156
|
+
const chunks = [];
|
|
157
|
+
let result = null;
|
|
158
|
+
let settled = false;
|
|
159
|
+
const settle = (outcome) => {
|
|
160
|
+
if (settled) return;
|
|
161
|
+
settled = true;
|
|
162
|
+
resolve({ output: Buffer.concat(chunks).toString(), result, ms: Date.now() - began, ...outcome });
|
|
163
|
+
};
|
|
164
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
165
|
+
child.stderr.on('data', (chunk) => chunks.push(chunk));
|
|
166
|
+
child.on('message', (message) => { result = message; });
|
|
167
|
+
child.on('exit', () => {
|
|
168
|
+
setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); }, PIPE_GRACE_MS).unref();
|
|
169
|
+
});
|
|
170
|
+
child.on('close', (code, signal) => settle({ code, signal }));
|
|
171
|
+
child.on('error', (err) => settle({ code: null, signal: null, error: err.message }));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Why a worker that sent nothing ended: its exit code or signal, then the last
|
|
175
|
+
// lines it wrote, so the failure line alone says where it stopped.
|
|
176
|
+
const crashMessage = ({ output, code, signal, error }) => {
|
|
177
|
+
const how = error ? `did not run: ${error}` : signal ? `killed by ${signal}` : `exited with code ${code}`;
|
|
178
|
+
const tail = stripVTControlCharacters(output).split('\n').map((line) => line.trim()).filter(Boolean).slice(-TAIL_LINES);
|
|
179
|
+
return `${how} without a result${tail.length ? `; last output: ${tail.join(' | ')}` : ''}`;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* One run over a repo: the whole of what `npm test` prints and exits with.
|
|
184
|
+
* @param {string[]} argv `[--] [suite paths...]`, or a worker's
|
|
185
|
+
* `--worker <suite> <suite dir>`
|
|
186
|
+
* @returns {Promise<void>}
|
|
187
|
+
*/
|
|
188
|
+
export const main = async (argv) => {
|
|
189
|
+
if (argv[0] === '--worker') {
|
|
190
|
+
// A parent that is gone takes its workers with it.
|
|
191
|
+
process.on('disconnect', () => process.exit(1));
|
|
192
|
+
await runOne(argv[1], argv[2]);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const start = Date.now();
|
|
197
|
+
const args = parseArgs(argv);
|
|
198
|
+
const { root, testDir } = args;
|
|
199
|
+
const serialRels = serialOf(testDir);
|
|
200
|
+
const { suites, withMods } = pickSuites(args, serialRels);
|
|
201
|
+
|
|
202
|
+
if (suites.length === 0 && !withMods) {
|
|
203
|
+
console.error(`No *.test.js suites found under ${path.basename(testDir)}/.`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let passed = 0;
|
|
208
|
+
let failed = 0;
|
|
209
|
+
const allFailures = [];
|
|
210
|
+
const skippedSuites = [];
|
|
211
|
+
const skippedCases = [];
|
|
212
|
+
const timings = [];
|
|
213
|
+
|
|
214
|
+
// One finished suite: its block printed whole, its counts and time added.
|
|
215
|
+
const report = (suite, outcome) => {
|
|
216
|
+
const rel = path.relative(testDir, suite);
|
|
217
|
+
timings.push({ rel, ms: outcome.ms });
|
|
218
|
+
process.stdout.write(`\n\x1b[1m\x1b[36m▶ ${rel}\x1b[0m\n${outcome.output}`);
|
|
219
|
+
const res = outcome.result;
|
|
220
|
+
if (!res) {
|
|
221
|
+
failed++;
|
|
222
|
+
allFailures.push({ name: `run ${rel}`, message: crashMessage(outcome) });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (res.skipped !== null) {
|
|
226
|
+
skippedSuites.push({ name: rel, reason: res.skipped });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
passed += res.passed;
|
|
230
|
+
failed += res.failed;
|
|
231
|
+
for (const f of res.failures) {
|
|
232
|
+
allFailures.push({ name: f.whole ? f.name : `${rel} › ${f.name}`, message: f.message });
|
|
233
|
+
}
|
|
234
|
+
for (const s of res.skips) {
|
|
235
|
+
skippedCases.push({ name: `${rel} › ${s.name}`, reason: s.reason });
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// The parallel wave: POOL workers at a time, each block printed once it and
|
|
240
|
+
// every suite before it have finished, so the order is the sorted list's.
|
|
241
|
+
const serial = new Set(serialRels.map((rel) => path.join(testDir, rel)));
|
|
242
|
+
const wave = suites.filter((suite) => !serial.has(suite));
|
|
243
|
+
const outcomes = [];
|
|
244
|
+
let next = 0;
|
|
245
|
+
let printed = 0;
|
|
246
|
+
const lane = async () => {
|
|
247
|
+
while (next < wave.length) {
|
|
248
|
+
const i = next++;
|
|
249
|
+
outcomes[i] = await runWorker(wave[i], testDir);
|
|
250
|
+
while (printed < wave.length && outcomes[printed]) {
|
|
251
|
+
report(wave[printed], outcomes[printed]);
|
|
252
|
+
printed++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
await Promise.all(Array.from({ length: Math.min(POOL, wave.length) }, lane));
|
|
257
|
+
|
|
258
|
+
for (const suite of suites.filter((s) => serial.has(s))) {
|
|
259
|
+
report(suite, await runWorker(suite, testDir));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (withMods) {
|
|
263
|
+
console.log(`\n\x1b[1m\x1b[36m▶ mod half: claude plugin test\x1b[0m`);
|
|
264
|
+
const mod = runModHalf(root);
|
|
265
|
+
if (mod.skip) {
|
|
266
|
+
console.log(`\x1b[33m⊘ skipped:\x1b[0m ${mod.skip}`);
|
|
267
|
+
skippedSuites.push({ name: 'mod half (claude plugin test)', reason: mod.skip });
|
|
268
|
+
} else {
|
|
269
|
+
passed += mod.passed;
|
|
270
|
+
failed += mod.failed;
|
|
271
|
+
for (const f of mod.failures) allFailures.push({ name: `mod half › ${f.name}`, message: f.message });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const ran = suites.length + (withMods ? 1 : 0);
|
|
275
|
+
|
|
276
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
277
|
+
const suiteNote = skippedSuites.length ? `, ${skippedSuites.length} suite${skippedSuites.length === 1 ? '' : 's'} skipped` : '';
|
|
278
|
+
const caseNote = skippedCases.length ? `, ${skippedCases.length} case${skippedCases.length === 1 ? '' : 's'} skipped` : '';
|
|
279
|
+
console.log(`\n\x1b[1m${passed} passed, ${failed} failed${suiteNote}${caseNote}\x1b[0m (${elapsed}s, ${ran} suite${ran === 1 ? '' : 's'})`);
|
|
280
|
+
if (timings.length) {
|
|
281
|
+
const slowest = timings.sort((a, b) => b.ms - a.ms).slice(0, SLOWEST);
|
|
282
|
+
console.log(`slowest: ${slowest.map(({ rel, ms }) => `${rel} ${(ms / 1000).toFixed(1)}s`).join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Name every skip, file and case alike: a run must never quietly cover less
|
|
286
|
+
// than the reader assumes.
|
|
287
|
+
for (const s of [...skippedSuites, ...skippedCases]) {
|
|
288
|
+
console.log(` \x1b[33m⊘ ${s.name}: ${s.reason}\x1b[0m`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (failed > 0) {
|
|
292
|
+
console.log('\n\x1b[1mFailures:\x1b[0m');
|
|
293
|
+
for (const f of allFailures) {
|
|
294
|
+
console.log(` - ${f.name}: ${f.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// exitCode, not exit(): a block may still be draining into a slow pipe.
|
|
298
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
if (import.meta.main) main(process.argv.slice(2));
|