failfirst 0.1.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 +21 -0
- package/README.md +173 -0
- package/bin/failfirst.js +10 -0
- package/package.json +44 -0
- package/src/cli.js +197 -0
- package/src/detect.js +78 -0
- package/src/git.js +84 -0
- package/src/report.js +63 -0
- package/src/runners.js +178 -0
- package/src/tap.js +88 -0
- package/src/verdict.js +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ben Malaga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# failfirst
|
|
4
|
+
|
|
5
|
+
**A CI gate that proves new tests actually test the change.**
|
|
6
|
+
|
|
7
|
+
A new test that passes on the base commit would pass without your change.
|
|
8
|
+
It is not testing your change. failfirst catches it.
|
|
9
|
+
|
|
10
|
+
[](https://github.com/BenMalaga/failfirst/releases)
|
|
11
|
+
[](https://github.com/BenMalaga/failfirst/actions/workflows/test.yml)
|
|
12
|
+
[](https://nodejs.org)
|
|
13
|
+
[](package.json)
|
|
14
|
+
[](LICENSE)
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
## The problem
|
|
19
|
+
|
|
20
|
+
A pull request adds a feature and a test. The test passes. CI is green. Reviewer approves.
|
|
21
|
+
|
|
22
|
+
But did the test ever depend on the feature? If you reverted the code change, would the test notice? With AI-generated PRs this failure mode went from occasional to routine: plausible-looking tests that assert things the old code already did. They pass before the change, they pass after the change, they pass after the next regression too. Green forever, guarding nothing.
|
|
23
|
+
|
|
24
|
+
The classic discipline is "watch the test fail first." failfirst turns that discipline into a CI gate: it runs your branch's new tests against the code from before your branch. Any new test that passes there is flagged **vacuous**, and the gate fails.
|
|
25
|
+
|
|
26
|
+
## See it
|
|
27
|
+
|
|
28
|
+
A PR implements `multiply()` and adds two tests. One imports `multiply` and checks it. The other looks multiply-related but only exercises the old `add()` function. Both are green in a normal test run. failfirst tells them apart:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
$ failfirst main
|
|
32
|
+
failfirst v0.1.0
|
|
33
|
+
base main (merge-base 4dbdb3a)
|
|
34
|
+
runner node-test
|
|
35
|
+
changed 2 test file(s)
|
|
36
|
+
|
|
37
|
+
running 2 file(s) against base 4dbdb3a...
|
|
38
|
+
running 2 file(s) against HEAD...
|
|
39
|
+
|
|
40
|
+
TEST BASE HEAD VERDICT
|
|
41
|
+
test/multiply-props.test.js > multiply: adding a number to itself doubles it pass pass VACUOUS
|
|
42
|
+
test/multiply.test.js > multiply multiplies two numbers error pass GOOD
|
|
43
|
+
|
|
44
|
+
1 good, 1 vacuous
|
|
45
|
+
|
|
46
|
+
FAIL: 1 new test passes on the base commit.
|
|
47
|
+
A test that passes without your change is not testing your change.
|
|
48
|
+
|
|
49
|
+
$ echo $?
|
|
50
|
+
1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That transcript is a real run against the fixture repository used in this project's integration tests.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
No install needed:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
npx failfirst
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or add it to the project:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm install --save-dev failfirst
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Requires Node 18 or newer and git. Zero dependencies.
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
failfirst [base-ref] [options]
|
|
75
|
+
|
|
76
|
+
Arguments:
|
|
77
|
+
base-ref Base to compare against (default: origin/main, main,
|
|
78
|
+
origin/master, or master, first one that exists)
|
|
79
|
+
|
|
80
|
+
Options:
|
|
81
|
+
--runner <name> node-test | vitest | jest (default: auto-detect)
|
|
82
|
+
--json Machine-readable JSON output
|
|
83
|
+
--color Force colored output
|
|
84
|
+
--no-color Disable colored output
|
|
85
|
+
-h, --help Show help
|
|
86
|
+
-v, --version Show version
|
|
87
|
+
|
|
88
|
+
Exit codes:
|
|
89
|
+
0 no vacuous tests (or no changed test files)
|
|
90
|
+
1 at least one vacuous test
|
|
91
|
+
2 usage or environment error
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### GitHub Actions
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
jobs:
|
|
98
|
+
failfirst:
|
|
99
|
+
runs-on: ubuntu-latest
|
|
100
|
+
steps:
|
|
101
|
+
- uses: actions/checkout@v4
|
|
102
|
+
with:
|
|
103
|
+
fetch-depth: 0 # failfirst needs the merge-base commit
|
|
104
|
+
- uses: actions/setup-node@v4
|
|
105
|
+
with:
|
|
106
|
+
node-version: 20
|
|
107
|
+
- run: npm ci # only needed for the vitest/jest runners
|
|
108
|
+
- run: npx failfirst origin/${{ github.base_ref }}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## How it works
|
|
112
|
+
|
|
113
|
+
1. **Find the changed tests.** `git diff --name-status <merge-base>..HEAD`, filtered to test files (`*.test.*`, `*.spec.*`, `*_test.*`, `test-*.*`, and files under `test/`, `tests/`, `__tests__/`, `spec/` directories).
|
|
114
|
+
2. **Rebuild the world before your change.** A temporary detached git worktree is created at the merge-base of the base ref and HEAD.
|
|
115
|
+
3. **Overlay only the new tests.** The added and modified test files (plus changed support files from test directories) are copied into that worktree. Production code stays old; tests are new.
|
|
116
|
+
4. **Handle modified test files fairly.** Before the overlay, failfirst runs the base version of each modified test file and records its test names. Those pre-existing tests are reported but never gated; only tests your branch actually added are judged.
|
|
117
|
+
5. **Run twice, compare per test.** The changed test files run against the base worktree and against HEAD. Each new test gets a verdict:
|
|
118
|
+
|
|
119
|
+
| Verdict | Base | HEAD | Meaning |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| `GOOD` | fail / error | pass | The test fails without your change. It proves something. |
|
|
122
|
+
| `VACUOUS` | pass | pass | The test passes without your change. It proves nothing. Gate fails. |
|
|
123
|
+
| `BROKEN` | fail | fail | The test fails everywhere. Your normal CI run will catch it. |
|
|
124
|
+
| `PRE-EXISTING` | any | any | Already existed in the base version of a modified file. Not gated. |
|
|
125
|
+
| `SKIPPED` | any | skip | Skipped on HEAD. Nothing to judge. |
|
|
126
|
+
|
|
127
|
+
6. **Clean up.** The worktree is removed even when a run blows up.
|
|
128
|
+
|
|
129
|
+
A `BASE` of `error` means the test file could not even load on the old code, typically because it imports something your branch introduced. That is strong evidence the test targets the change, so it counts as failing on base.
|
|
130
|
+
|
|
131
|
+
## Runners
|
|
132
|
+
|
|
133
|
+
| Runner | Detection | Notes |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `node-test` | default | Built into Node. Parses TAP from `node --test`, including nested describe/it suites. |
|
|
136
|
+
| `vitest` | test script or dependency mentions vitest | Uses the JSON reporter. |
|
|
137
|
+
| `jest` | test script or dependency mentions jest | Uses `--json` with `--runTestsByPath`. |
|
|
138
|
+
|
|
139
|
+
All three adapters are validated against real fixture repositories (base commit, then a PR adding one good and one vacuous test) as part of this project's development. For vitest and jest, the base worktree borrows the main checkout's `node_modules` via symlink, so run `npm ci` first in CI.
|
|
140
|
+
|
|
141
|
+
## Why not X
|
|
142
|
+
|
|
143
|
+
**Why not [tdd-guard](https://github.com/nizos/tdd-guard) or other live TDD enforcers?** Those hook into an agent's or developer's editing session and enforce red-green discipline while the code is being written. Great when you control the session. failfirst works at the other end: it gates the finished PR in CI, no matter what tool, agent, or human produced it, with nothing installed on the author's side.
|
|
144
|
+
|
|
145
|
+
**Why not mutation testing (Stryker and friends)?** Mutation testing mutates your production code and checks that existing tests notice. It measures the strength of the whole suite, costs minutes to hours, and does not know what a specific PR changed. failfirst answers one narrow question per PR in seconds: do the new tests depend on the new code? The two are complementary; mutation testing for depth, failfirst for the PR loop.
|
|
146
|
+
|
|
147
|
+
**Why not SWE-bench style fail-to-pass checks?** The fail-to-pass concept is exactly right, and SWE-bench uses it to build benchmark datasets with internal tooling tied to their harness and container images. failfirst is that concept packaged as a generic, zero-dependency CLI for any git repo with a supported runner.
|
|
148
|
+
|
|
149
|
+
**Why not just review the tests?** Vacuity is invisible in review. The test imports the right module, asserts plausible things, and passes. The only way to know whether it would pass without the change is to run it without the change, which is tedious by hand and trivial for a tool.
|
|
150
|
+
|
|
151
|
+
## Scope and limitations
|
|
152
|
+
|
|
153
|
+
- Tests are matched between base and HEAD runs by file path plus full test name. A renamed test in a modified file is treated as new.
|
|
154
|
+
- A pre-existing test whose body was edited (same name) is not re-gated. Gating it would re-flag every harmless refactor of an old test.
|
|
155
|
+
- New tests in modified files that pass on base are flagged even if they sit next to legitimate ones; the table tells you exactly which test to fix.
|
|
156
|
+
- Support files outside test directories (e.g. a changed helper in `src/`) are not overlaid onto the base worktree. If a new test needs them, it will fail or error on base, which is the safe direction: failfirst never produces a false VACUOUS from a missing dependency, only a conservative GOOD.
|
|
157
|
+
- For vitest and jest, the base run uses the head checkout's `node_modules`. If your PR also changed dependency versions, the base run sees the new versions.
|
|
158
|
+
- Flaky tests that pass on base by luck will be flagged. They deserve it.
|
|
159
|
+
|
|
160
|
+
## Roadmap
|
|
161
|
+
|
|
162
|
+
- `--require-tests`: fail PRs that change source code but add no tests.
|
|
163
|
+
- Adapters: pytest, mocha, bun test.
|
|
164
|
+
- GitHub Action wrapper with inline PR annotations on vacuous tests.
|
|
165
|
+
- Optional gating of edited pre-existing tests.
|
|
166
|
+
|
|
167
|
+
## Contributing
|
|
168
|
+
|
|
169
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: zero dependencies, pure logic stays pure and unit-tested, and every new behavior needs a test that fails without it.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
[MIT](LICENSE), Ben Malaga.
|
package/bin/failfirst.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "failfirst",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CI gate that proves new tests actually test the change: a new test that passes on the base commit is vacuous.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"failfirst": "bin/failfirst.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"testing",
|
|
23
|
+
"ci",
|
|
24
|
+
"git",
|
|
25
|
+
"tdd",
|
|
26
|
+
"fail-first",
|
|
27
|
+
"vacuous-tests",
|
|
28
|
+
"test-quality",
|
|
29
|
+
"code-review",
|
|
30
|
+
"ai",
|
|
31
|
+
"llm",
|
|
32
|
+
"cli"
|
|
33
|
+
],
|
|
34
|
+
"author": "Ben Malaga",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/BenMalaga/failfirst.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/BenMalaga/failfirst/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/BenMalaga/failfirst#readme"
|
|
44
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// CLI entry: argument parsing and orchestration.
|
|
2
|
+
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
addWorktree,
|
|
7
|
+
diffNameStatus,
|
|
8
|
+
mergeBase,
|
|
9
|
+
removeWorktree,
|
|
10
|
+
repoRoot,
|
|
11
|
+
resolveBaseRef,
|
|
12
|
+
} from './git.js';
|
|
13
|
+
import { selectTestChanges } from './detect.js';
|
|
14
|
+
import {
|
|
15
|
+
detectRunnerFromDir,
|
|
16
|
+
ensureNodeModules,
|
|
17
|
+
normalizeRunnerName,
|
|
18
|
+
runTests,
|
|
19
|
+
} from './runners.js';
|
|
20
|
+
import { computeVerdicts, preExistingKey, VERDICTS } from './verdict.js';
|
|
21
|
+
import { formatSummary, formatTable, makePainter } from './report.js';
|
|
22
|
+
|
|
23
|
+
const HELP = `failfirst: prove that new tests actually test the change
|
|
24
|
+
|
|
25
|
+
Usage: failfirst [base-ref] [options]
|
|
26
|
+
|
|
27
|
+
Runs the test files your branch added or modified against the merge-base
|
|
28
|
+
(the old code). A new test that PASSES there is vacuous: it would pass
|
|
29
|
+
without your change, so it is not testing it.
|
|
30
|
+
|
|
31
|
+
Arguments:
|
|
32
|
+
base-ref Base to compare against (default: origin/main, main,
|
|
33
|
+
origin/master, or master, first one that exists)
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--runner <name> node-test | vitest | jest (default: auto-detect)
|
|
37
|
+
--json Machine-readable JSON output
|
|
38
|
+
--color Force colored output (default: only when stdout is a TTY)
|
|
39
|
+
--no-color Disable colored output
|
|
40
|
+
-h, --help Show this help
|
|
41
|
+
-v, --version Show version
|
|
42
|
+
|
|
43
|
+
Exit codes:
|
|
44
|
+
0 no vacuous tests (or no changed test files)
|
|
45
|
+
1 at least one vacuous test
|
|
46
|
+
2 usage or environment error
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
export function parseArgs(argv) {
|
|
50
|
+
const opts = { base: null, runner: null, json: false, color: null, help: false, version: false };
|
|
51
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === '-h' || a === '--help') opts.help = true;
|
|
54
|
+
else if (a === '-v' || a === '--version') opts.version = true;
|
|
55
|
+
else if (a === '--json') opts.json = true;
|
|
56
|
+
else if (a === '--color') opts.color = true;
|
|
57
|
+
else if (a === '--no-color') opts.color = false;
|
|
58
|
+
else if (a === '--runner') {
|
|
59
|
+
i += 1;
|
|
60
|
+
if (i >= argv.length) throw new Error('--runner requires a value');
|
|
61
|
+
opts.runner = normalizeRunnerName(argv[i]);
|
|
62
|
+
if (!opts.runner) throw new Error(`unknown runner '${argv[i]}' (use node-test, vitest, or jest)`);
|
|
63
|
+
} else if (a.startsWith('--runner=')) {
|
|
64
|
+
const v = a.slice('--runner='.length);
|
|
65
|
+
opts.runner = normalizeRunnerName(v);
|
|
66
|
+
if (!opts.runner) throw new Error(`unknown runner '${v}' (use node-test, vitest, or jest)`);
|
|
67
|
+
} else if (a.startsWith('-')) {
|
|
68
|
+
throw new Error(`unknown option '${a}'`);
|
|
69
|
+
} else if (opts.base === null) {
|
|
70
|
+
opts.base = a;
|
|
71
|
+
} else {
|
|
72
|
+
throw new Error(`unexpected argument '${a}'`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return opts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ownVersion() {
|
|
79
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
80
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function copyInto(files, fromRoot, toRoot) {
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
const dest = join(toRoot, f);
|
|
86
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
87
|
+
copyFileSync(join(fromRoot, f), dest);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function main(argv) {
|
|
92
|
+
let opts;
|
|
93
|
+
try {
|
|
94
|
+
opts = parseArgs(argv);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`failfirst: ${err.message}`);
|
|
97
|
+
console.error(`Run 'failfirst --help' for usage.`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
if (opts.help) {
|
|
101
|
+
process.stdout.write(HELP);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
if (opts.version) {
|
|
105
|
+
console.log(ownVersion());
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const useColor = opts.color ?? (Boolean(process.stdout.isTTY) && !process.env.NO_COLOR);
|
|
110
|
+
const paint = makePainter(useColor);
|
|
111
|
+
const log = (s = '') => {
|
|
112
|
+
if (!opts.json) console.log(s);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const root = repoRoot(process.cwd());
|
|
116
|
+
const baseRef = resolveBaseRef(opts.base, root);
|
|
117
|
+
const mb = mergeBase(baseRef, root);
|
|
118
|
+
const changes = selectTestChanges(diffNameStatus(mb, root));
|
|
119
|
+
const runner = opts.runner || detectRunnerFromDir(root);
|
|
120
|
+
|
|
121
|
+
log(`failfirst v${ownVersion()}`);
|
|
122
|
+
log(` base ${baseRef} (merge-base ${mb.slice(0, 7)})`);
|
|
123
|
+
log(` runner ${runner}`);
|
|
124
|
+
log(` changed ${changes.run.length} test file(s)`);
|
|
125
|
+
log();
|
|
126
|
+
|
|
127
|
+
if (changes.run.length === 0) {
|
|
128
|
+
if (opts.json) {
|
|
129
|
+
console.log(JSON.stringify({ base: baseRef, mergeBase: mb, runner, files: [], tests: [], summary: {}, vacuous: 0 }, null, 2));
|
|
130
|
+
} else {
|
|
131
|
+
log('No added or modified test files in this diff. Nothing to check.');
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const support = changes.copy.filter((f) => !changes.run.includes(f));
|
|
137
|
+
if (support.length > 0) {
|
|
138
|
+
log(` copying ${support.length} changed support file(s) from test dirs: ${support.join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const worktree = addWorktree(mb, root);
|
|
142
|
+
let headResults;
|
|
143
|
+
let baseResults;
|
|
144
|
+
const preExisting = new Set();
|
|
145
|
+
try {
|
|
146
|
+
if (runner !== 'node-test') ensureNodeModules(worktree, root);
|
|
147
|
+
|
|
148
|
+
// 1. For modified test files, enumerate the tests that already exist on
|
|
149
|
+
// base, so we only gate the tests this branch actually added.
|
|
150
|
+
if (changes.modified.length > 0) {
|
|
151
|
+
for (const r of runTests(runner, worktree, changes.modified)) {
|
|
152
|
+
preExisting.add(preExistingKey(r.file, r.name));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2. Overlay the head versions of the changed test files onto old code.
|
|
157
|
+
copyInto(changes.copy, root, worktree);
|
|
158
|
+
|
|
159
|
+
// 3. Run them against the base, then against HEAD.
|
|
160
|
+
log(` running ${changes.run.length} file(s) against base ${mb.slice(0, 7)}...`);
|
|
161
|
+
baseResults = runTests(runner, worktree, changes.run);
|
|
162
|
+
log(` running ${changes.run.length} file(s) against HEAD...`);
|
|
163
|
+
headResults = runTests(runner, root, changes.run);
|
|
164
|
+
} finally {
|
|
165
|
+
removeWorktree(worktree, root);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { rows, summary, vacuousCount } = computeVerdicts(headResults, baseResults, preExisting);
|
|
169
|
+
|
|
170
|
+
if (opts.json) {
|
|
171
|
+
console.log(JSON.stringify(
|
|
172
|
+
{ base: baseRef, mergeBase: mb, runner, files: changes.run, tests: rows, summary, vacuous: vacuousCount },
|
|
173
|
+
null,
|
|
174
|
+
2,
|
|
175
|
+
));
|
|
176
|
+
return vacuousCount > 0 ? 1 : 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
log();
|
|
180
|
+
log(formatTable(rows, useColor));
|
|
181
|
+
log();
|
|
182
|
+
log(` ${formatSummary(summary, useColor)}`);
|
|
183
|
+
log();
|
|
184
|
+
|
|
185
|
+
if (vacuousCount > 0) {
|
|
186
|
+
const noun = vacuousCount === 1 ? 'test passes' : 'tests pass';
|
|
187
|
+
console.log(paint('red', `FAIL: ${vacuousCount} new ${noun} on the base commit.`));
|
|
188
|
+
console.log('A test that passes without your change is not testing your change.');
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
const checked = rows.filter((r) => r.verdict !== VERDICTS.PRE_EXISTING && r.verdict !== VERDICTS.SKIPPED).length;
|
|
192
|
+
const msg = checked === 1
|
|
193
|
+
? 'PASS: the 1 new test fails on the base commit.'
|
|
194
|
+
: `PASS: all ${checked} new tests fail on the base commit.`;
|
|
195
|
+
console.log(paint('green', msg));
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Pure logic: classify changed files as test files, parse `git diff --name-status`.
|
|
2
|
+
|
|
3
|
+
const EXT = String.raw`\.[cm]?[jt]sx?$`;
|
|
4
|
+
|
|
5
|
+
// File NAME patterns that mark a file as a runnable test file.
|
|
6
|
+
const RUNNABLE_RES = [
|
|
7
|
+
new RegExp(String.raw`[._-](test|spec)${EXT}`, 'i'), // foo.test.js, foo_test.ts, foo-spec.mjs
|
|
8
|
+
new RegExp(String.raw`(^|/)(test|spec)[._-][^/]*${EXT}`, 'i'), // test-foo.js, spec_bar.ts
|
|
9
|
+
new RegExp(String.raw`(^|/)(test|spec)${EXT}`, 'i'), // test.js, spec.ts
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const TEST_DIR_RE = /(^|\/)(__tests__|tests?|specs?)\//i;
|
|
13
|
+
const CODE_EXT_RE = new RegExp(EXT, 'i');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A file we should RUN as a test (its name says "I am a test").
|
|
17
|
+
*/
|
|
18
|
+
export function isRunnableTestFile(path) {
|
|
19
|
+
const p = path.replace(/\\/g, '/');
|
|
20
|
+
return RUNNABLE_RES.some((re) => re.test(p));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A file that belongs to the test surface (runnable tests plus support files
|
|
25
|
+
* living in test directories, e.g. helpers/fixtures that new tests import).
|
|
26
|
+
*/
|
|
27
|
+
export function isTestFile(path) {
|
|
28
|
+
const p = path.replace(/\\/g, '/');
|
|
29
|
+
if (isRunnableTestFile(p)) return true;
|
|
30
|
+
return TEST_DIR_RE.test(p) && CODE_EXT_RE.test(p);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse `git diff --name-status -M` output.
|
|
35
|
+
* Returns [{ status: 'A'|'M'|'D'|'R'|'C'|'T', path, oldPath? }]
|
|
36
|
+
* For renames/copies, `path` is the NEW path.
|
|
37
|
+
*/
|
|
38
|
+
export function parseNameStatus(text) {
|
|
39
|
+
const entries = [];
|
|
40
|
+
for (const line of text.split('\n')) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
const parts = line.split('\t');
|
|
43
|
+
const status = parts[0].trim();
|
|
44
|
+
const kind = status[0];
|
|
45
|
+
if (kind === 'R' || kind === 'C') {
|
|
46
|
+
if (parts.length >= 3) entries.push({ status: kind, path: parts[2], oldPath: parts[1] });
|
|
47
|
+
} else if (parts.length >= 2) {
|
|
48
|
+
entries.push({ status: kind, path: parts[1] });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* From a name-status diff, pick the test files added or changed by the PR.
|
|
56
|
+
* Returns { copy, run, added, modified }
|
|
57
|
+
* - copy: test-surface files to overlay onto the base worktree
|
|
58
|
+
* - run: subset of copy that we execute as tests
|
|
59
|
+
* - added: runnable files that did not exist on base
|
|
60
|
+
* - modified: runnable files that existed on base (we enumerate their old tests)
|
|
61
|
+
*/
|
|
62
|
+
export function selectTestChanges(diffText) {
|
|
63
|
+
const copy = [];
|
|
64
|
+
const run = [];
|
|
65
|
+
const added = [];
|
|
66
|
+
const modified = [];
|
|
67
|
+
for (const e of parseNameStatus(diffText)) {
|
|
68
|
+
if (e.status === 'D') continue;
|
|
69
|
+
if (!isTestFile(e.path)) continue;
|
|
70
|
+
copy.push(e.path);
|
|
71
|
+
if (!isRunnableTestFile(e.path)) continue;
|
|
72
|
+
run.push(e.path);
|
|
73
|
+
// A=new file, R/C=new path on base. M/T existed on base.
|
|
74
|
+
if (e.status === 'M' || e.status === 'T') modified.push(e.path);
|
|
75
|
+
else added.push(e.path);
|
|
76
|
+
}
|
|
77
|
+
return { copy, run, added, modified };
|
|
78
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Thin wrappers around the git CLI. No dependencies, no shell.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export class GitError extends Error {}
|
|
8
|
+
|
|
9
|
+
export function git(args, cwd) {
|
|
10
|
+
const res = spawnSync('git', args, {
|
|
11
|
+
cwd,
|
|
12
|
+
encoding: 'utf8',
|
|
13
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
14
|
+
});
|
|
15
|
+
if (res.error) throw new GitError(`git ${args[0]}: ${res.error.message}`);
|
|
16
|
+
if (res.status !== 0) {
|
|
17
|
+
const detail = (res.stderr || res.stdout || '').trim().split('\n')[0];
|
|
18
|
+
throw new GitError(`git ${args.join(' ')} failed: ${detail}`);
|
|
19
|
+
}
|
|
20
|
+
return res.stdout;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function repoRoot(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
return git(['rev-parse', '--show-toplevel'], cwd).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
throw new GitError('not inside a git repository');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function refExists(ref, cwd) {
|
|
32
|
+
const res = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
|
|
33
|
+
cwd,
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
});
|
|
36
|
+
return res.status === 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_BASES = ['origin/main', 'main', 'origin/master', 'master'];
|
|
40
|
+
|
|
41
|
+
export function resolveBaseRef(given, cwd) {
|
|
42
|
+
if (given) {
|
|
43
|
+
if (refExists(given, cwd)) return given;
|
|
44
|
+
throw new GitError(`base ref '${given}' not found (try fetching it first)`);
|
|
45
|
+
}
|
|
46
|
+
for (const ref of DEFAULT_BASES) {
|
|
47
|
+
if (refExists(ref, cwd)) return ref;
|
|
48
|
+
}
|
|
49
|
+
throw new GitError(`no default base ref found (tried ${DEFAULT_BASES.join(', ')}); pass one explicitly`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mergeBase(baseRef, cwd) {
|
|
53
|
+
try {
|
|
54
|
+
return git(['merge-base', baseRef, 'HEAD'], cwd).trim();
|
|
55
|
+
} catch {
|
|
56
|
+
throw new GitError(
|
|
57
|
+
`cannot find merge-base of '${baseRef}' and HEAD (shallow clone? use fetch-depth: 0 in CI)`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function diffNameStatus(mergeBaseSha, cwd) {
|
|
63
|
+
return git(['diff', '--name-status', '-M', `${mergeBaseSha}..HEAD`], cwd);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function addWorktree(sha, cwd) {
|
|
67
|
+
const dir = mkdtempSync(join(tmpdir(), 'failfirst-'));
|
|
68
|
+
git(['worktree', 'add', '--detach', '--quiet', dir, sha], cwd);
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function removeWorktree(dir, cwd) {
|
|
73
|
+
try {
|
|
74
|
+
git(['worktree', 'remove', '--force', dir], cwd);
|
|
75
|
+
} catch {
|
|
76
|
+
// fall through to manual cleanup
|
|
77
|
+
}
|
|
78
|
+
rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
try {
|
|
80
|
+
git(['worktree', 'prune'], cwd);
|
|
81
|
+
} catch {
|
|
82
|
+
// best effort
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Render the verdict table and summary.
|
|
2
|
+
import { VERDICTS } from './verdict.js';
|
|
3
|
+
|
|
4
|
+
const CODES = { red: 31, green: 32, yellow: 33, dim: 2, bold: 1 };
|
|
5
|
+
|
|
6
|
+
export function makePainter(useColor) {
|
|
7
|
+
if (!useColor) return (_style, s) => s;
|
|
8
|
+
return (style, s) => `\u001b[${CODES[style]}m${s}\u001b[0m`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const VERDICT_STYLE = {
|
|
12
|
+
[VERDICTS.VACUOUS]: 'red',
|
|
13
|
+
[VERDICTS.GOOD]: 'green',
|
|
14
|
+
[VERDICTS.BROKEN]: 'yellow',
|
|
15
|
+
[VERDICTS.PRE_EXISTING]: 'dim',
|
|
16
|
+
[VERDICTS.SKIPPED]: 'dim',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function formatTable(rows, useColor) {
|
|
20
|
+
const paint = makePainter(useColor);
|
|
21
|
+
const header = ['TEST', 'BASE', 'HEAD', 'VERDICT'];
|
|
22
|
+
const cells = rows.map((r) => [
|
|
23
|
+
`${r.file} > ${r.name}`,
|
|
24
|
+
r.base,
|
|
25
|
+
r.head,
|
|
26
|
+
r.verdict,
|
|
27
|
+
]);
|
|
28
|
+
const widths = header.map((h, i) =>
|
|
29
|
+
Math.max(h.length, ...cells.map((c) => c[i].length)),
|
|
30
|
+
);
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push(
|
|
33
|
+
' ' + header.map((h, i) => paint('bold', h.padEnd(widths[i]))).join(' '),
|
|
34
|
+
);
|
|
35
|
+
rows.forEach((r, idx) => {
|
|
36
|
+
const c = cells[idx];
|
|
37
|
+
const style = VERDICT_STYLE[r.verdict] || 'dim';
|
|
38
|
+
const baseCell = r.base === 'pass' && r.verdict === VERDICTS.VACUOUS
|
|
39
|
+
? paint('red', c[1].padEnd(widths[1]))
|
|
40
|
+
: c[1].padEnd(widths[1]);
|
|
41
|
+
lines.push(
|
|
42
|
+
' ' +
|
|
43
|
+
[
|
|
44
|
+
c[0].padEnd(widths[0]),
|
|
45
|
+
baseCell,
|
|
46
|
+
c[2].padEnd(widths[2]),
|
|
47
|
+
paint(style, c[3].padEnd(widths[3])),
|
|
48
|
+
].join(' '),
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatSummary(summary, useColor) {
|
|
55
|
+
const paint = makePainter(useColor);
|
|
56
|
+
const parts = [];
|
|
57
|
+
if (summary.good) parts.push(paint('green', `${summary.good} good`));
|
|
58
|
+
if (summary.vacuous) parts.push(paint('red', `${summary.vacuous} vacuous`));
|
|
59
|
+
if (summary.broken) parts.push(paint('yellow', `${summary.broken} broken`));
|
|
60
|
+
if (summary.preExisting) parts.push(paint('dim', `${summary.preExisting} pre-existing`));
|
|
61
|
+
if (summary.skipped) parts.push(paint('dim', `${summary.skipped} skipped`));
|
|
62
|
+
return parts.join(', ');
|
|
63
|
+
}
|
package/src/runners.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Test runner adapters: node:test, vitest, jest.
|
|
2
|
+
// Each adapter runs a set of test files in a directory and returns
|
|
3
|
+
// normalized results: [{ file, name, status: 'pass'|'fail'|'skip' }].
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
8
|
+
import { parseTap } from './tap.js';
|
|
9
|
+
import { FILE_LOAD_ERROR } from './verdict.js';
|
|
10
|
+
|
|
11
|
+
export const RUNNERS = ['node-test', 'vitest', 'jest'];
|
|
12
|
+
|
|
13
|
+
export function normalizeRunnerName(name) {
|
|
14
|
+
const n = String(name).toLowerCase();
|
|
15
|
+
if (n === 'node' || n === 'node:test' || n === 'node-test' || n === 'nodetest') return 'node-test';
|
|
16
|
+
if (n === 'vitest') return 'vitest';
|
|
17
|
+
if (n === 'jest') return 'jest';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pure: pick a runner from a parsed package.json ({} if none).
|
|
23
|
+
*/
|
|
24
|
+
export function detectRunner(pkg) {
|
|
25
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
26
|
+
const script = ((pkg.scripts || {}).test || '').toLowerCase();
|
|
27
|
+
if (script.includes('vitest') || 'vitest' in deps) return 'vitest';
|
|
28
|
+
if (script.includes('jest') || 'jest' in deps) return 'jest';
|
|
29
|
+
return 'node-test';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function detectRunnerFromDir(dir) {
|
|
33
|
+
try {
|
|
34
|
+
return detectRunner(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')));
|
|
35
|
+
} catch {
|
|
36
|
+
return 'node-test';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* vitest/jest need node_modules; a fresh worktree has none.
|
|
42
|
+
* Borrow the main checkout's node_modules via symlink.
|
|
43
|
+
*/
|
|
44
|
+
export function ensureNodeModules(worktreeDir, sourceRoot) {
|
|
45
|
+
const target = join(worktreeDir, 'node_modules');
|
|
46
|
+
const source = join(sourceRoot, 'node_modules');
|
|
47
|
+
if (!existsSync(target) && existsSync(source)) {
|
|
48
|
+
symlinkSync(source, target, 'junction');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// If failfirst itself runs under `node --test` (e.g. in an integration test),
|
|
53
|
+
// children inherit NODE_TEST_CONTEXT and nested runners refuse to run files.
|
|
54
|
+
function cleanEnv() {
|
|
55
|
+
const env = { ...process.env };
|
|
56
|
+
delete env.NODE_TEST_CONTEXT;
|
|
57
|
+
return env;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runNodeTest(cwd, files) {
|
|
61
|
+
const results = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const res = spawnSync(
|
|
64
|
+
process.execPath,
|
|
65
|
+
['--test', '--test-reporter=tap', file],
|
|
66
|
+
{ cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: cleanEnv() },
|
|
67
|
+
);
|
|
68
|
+
if (res.error) throw new Error(`node --test failed to start: ${res.error.message}`);
|
|
69
|
+
const leaves = parseTap(res.stdout || '');
|
|
70
|
+
if (leaves.length === 0) {
|
|
71
|
+
// File produced no test points (load error before any test, or empty file).
|
|
72
|
+
results.push({
|
|
73
|
+
file,
|
|
74
|
+
name: FILE_LOAD_ERROR,
|
|
75
|
+
status: res.status === 0 ? 'skip' : 'fail',
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// When a file crashes before running any test (e.g. it imports something
|
|
80
|
+
// that does not exist on this commit), node --test emits a single failing
|
|
81
|
+
// test point named after the file itself. Normalize that.
|
|
82
|
+
if (leaves.length === 1 && !leaves[0].pass && leaves[0].name === file) {
|
|
83
|
+
results.push({ file, name: FILE_LOAD_ERROR, status: 'fail' });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const t of leaves) {
|
|
87
|
+
results.push({
|
|
88
|
+
file,
|
|
89
|
+
name: t.name,
|
|
90
|
+
status: t.skip || t.todo ? 'skip' : t.pass ? 'pass' : 'fail',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findBin(cwd, name) {
|
|
98
|
+
const p = join(cwd, 'node_modules', '.bin', name);
|
|
99
|
+
return existsSync(p) ? p : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function runJsonReporter(runner, cwd, files) {
|
|
103
|
+
const bin = findBin(cwd, runner);
|
|
104
|
+
if (!bin) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`cannot find ${runner} in ${join(cwd, 'node_modules', '.bin')}; install dependencies first`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const outDir = mkdtempSync(join(tmpdir(), 'failfirst-json-'));
|
|
110
|
+
const outFile = join(outDir, 'results.json');
|
|
111
|
+
const args =
|
|
112
|
+
runner === 'vitest'
|
|
113
|
+
? [bin, 'run', '--reporter=json', `--outputFile=${outFile}`, ...files]
|
|
114
|
+
: [bin, '--runTestsByPath', '--json', `--outputFile=${outFile}`, ...files];
|
|
115
|
+
try {
|
|
116
|
+
const res = spawnSync(process.execPath, args, {
|
|
117
|
+
cwd,
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
120
|
+
env: cleanEnv(),
|
|
121
|
+
});
|
|
122
|
+
if (res.error) throw new Error(`${runner} failed to start: ${res.error.message}`);
|
|
123
|
+
if (!existsSync(outFile)) {
|
|
124
|
+
const detail = (res.stderr || res.stdout || '').trim().slice(0, 400);
|
|
125
|
+
throw new Error(`${runner} produced no JSON report. Output:\n${detail}`);
|
|
126
|
+
}
|
|
127
|
+
return parseJestJson(readFileSync(outFile, 'utf8'), cwd);
|
|
128
|
+
} finally {
|
|
129
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pure: parse jest-style JSON (vitest's json reporter is jest-compatible).
|
|
135
|
+
*/
|
|
136
|
+
export function parseJestJson(text, cwd) {
|
|
137
|
+
const data = JSON.parse(text);
|
|
138
|
+
// Resolve symlinks (e.g. /tmp -> /private/tmp on macOS) so reported absolute
|
|
139
|
+
// paths and our cwd agree before computing relative paths.
|
|
140
|
+
let realCwd = cwd;
|
|
141
|
+
try {
|
|
142
|
+
realCwd = realpathSync(cwd);
|
|
143
|
+
} catch {
|
|
144
|
+
// keep cwd as-is
|
|
145
|
+
}
|
|
146
|
+
const results = [];
|
|
147
|
+
for (const tr of data.testResults || []) {
|
|
148
|
+
const abs = tr.name || tr.testFilePath || '';
|
|
149
|
+
const file = isAbsolute(abs) ? relative(realCwd, abs) : abs;
|
|
150
|
+
const assertions = tr.assertionResults || [];
|
|
151
|
+
if (assertions.length === 0) {
|
|
152
|
+
// Suite-level failure (e.g. the file could not load on this commit).
|
|
153
|
+
const failed = tr.status === 'failed' || Boolean(tr.message);
|
|
154
|
+
results.push({ file, name: FILE_LOAD_ERROR, status: failed ? 'fail' : 'skip' });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
for (const a of assertions) {
|
|
158
|
+
const name =
|
|
159
|
+
a.ancestorTitles && a.ancestorTitles.length > 0
|
|
160
|
+
? [...a.ancestorTitles, a.title].join(' > ')
|
|
161
|
+
: a.fullName || a.title;
|
|
162
|
+
const status =
|
|
163
|
+
a.status === 'passed' ? 'pass' : a.status === 'failed' ? 'fail' : 'skip';
|
|
164
|
+
results.push({ file, name, status });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Run `files` (paths relative to `cwd`) with the chosen runner.
|
|
172
|
+
*/
|
|
173
|
+
export function runTests(runner, cwd, files) {
|
|
174
|
+
if (files.length === 0) return [];
|
|
175
|
+
if (runner === 'node-test') return runNodeTest(cwd, files);
|
|
176
|
+
if (runner === 'vitest' || runner === 'jest') return runJsonReporter(runner, cwd, files);
|
|
177
|
+
throw new Error(`unknown runner '${runner}'`);
|
|
178
|
+
}
|
package/src/tap.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Pure logic: parse TAP output from `node --test` into leaf test results.
|
|
2
|
+
//
|
|
3
|
+
// node:test TAP shape (children precede their parent, 4-space indent per level):
|
|
4
|
+
// # Subtest: suite
|
|
5
|
+
// # Subtest: inner
|
|
6
|
+
// ok 1 - inner
|
|
7
|
+
// 1..1
|
|
8
|
+
// ok 1 - suite
|
|
9
|
+
// YAML diagnostic blocks sit between `---` and `...` lines.
|
|
10
|
+
|
|
11
|
+
const RESULT_RE = /^(\s*)(not )?ok\s+\d+(?:\s+-\s+(.*?))?\s*$/;
|
|
12
|
+
const DIRECTIVE_RE = /\s+#\s+(SKIP|TODO)\b.*$/i;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse TAP text into leaf results: [{ name, pass, skip, todo }]
|
|
16
|
+
* Suite/parent entries are folded into their children's names ("suite > inner")
|
|
17
|
+
* and excluded from the result list.
|
|
18
|
+
*/
|
|
19
|
+
export function parseTap(text) {
|
|
20
|
+
const all = [];
|
|
21
|
+
const byDepth = new Map(); // depth -> entries awaiting a parent
|
|
22
|
+
let inYaml = false;
|
|
23
|
+
let yamlIndent = 0;
|
|
24
|
+
|
|
25
|
+
for (const line of text.split('\n')) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
const indent = line.length - line.trimStart().length;
|
|
28
|
+
|
|
29
|
+
if (inYaml) {
|
|
30
|
+
if (trimmed === '...' && indent === yamlIndent) inYaml = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (trimmed === '---') {
|
|
34
|
+
inYaml = true;
|
|
35
|
+
yamlIndent = indent;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (trimmed.startsWith('#')) continue;
|
|
39
|
+
|
|
40
|
+
const m = RESULT_RE.exec(line);
|
|
41
|
+
if (!m) continue;
|
|
42
|
+
|
|
43
|
+
let name = m[3] ?? '';
|
|
44
|
+
let skip = false;
|
|
45
|
+
let todo = false;
|
|
46
|
+
const d = DIRECTIVE_RE.exec(name);
|
|
47
|
+
if (d) {
|
|
48
|
+
skip = /skip/i.test(d[1]);
|
|
49
|
+
todo = /todo/i.test(d[1]);
|
|
50
|
+
name = name.slice(0, d.index);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const depth = Math.floor((m[1] ? m[1].length : 0) / 4);
|
|
54
|
+
const entry = {
|
|
55
|
+
name: name.trim(),
|
|
56
|
+
fullName: name.trim(),
|
|
57
|
+
pass: !m[2],
|
|
58
|
+
skip,
|
|
59
|
+
todo,
|
|
60
|
+
depth,
|
|
61
|
+
isParent: false,
|
|
62
|
+
subtree: [],
|
|
63
|
+
};
|
|
64
|
+
entry.subtree.push(entry);
|
|
65
|
+
|
|
66
|
+
// Adopt any pending entries one level deeper: they are this entry's children.
|
|
67
|
+
const children = byDepth.get(depth + 1) || [];
|
|
68
|
+
byDepth.set(depth + 1, []);
|
|
69
|
+
if (children.length > 0) {
|
|
70
|
+
entry.isParent = true;
|
|
71
|
+
for (const child of children) {
|
|
72
|
+
for (const node of child.subtree) {
|
|
73
|
+
node.fullName = `${entry.name} > ${node.fullName}`;
|
|
74
|
+
}
|
|
75
|
+
entry.subtree.push(...child.subtree);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const siblings = byDepth.get(depth) || [];
|
|
80
|
+
siblings.push(entry);
|
|
81
|
+
byDepth.set(depth, siblings);
|
|
82
|
+
all.push(entry);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return all
|
|
86
|
+
.filter((e) => !e.isParent)
|
|
87
|
+
.map(({ fullName, pass, skip, todo }) => ({ name: fullName, pass, skip, todo }));
|
|
88
|
+
}
|
package/src/verdict.js
ADDED
|
Binary file
|