testguard-cli 0.3.1 → 0.4.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/CHANGELOG.md +20 -0
- package/README.md +11 -6
- package/package.json +2 -1
- package/src/cli.mjs +6 -0
- package/src/commands/claims.mjs +1 -1
- package/src/commands/probe.mjs +1 -0
- package/src/probe/discover.mjs +1 -1
- package/src/probe/probe.mjs +17 -13
- package/src/probe/runners/index.mjs +20 -0
- package/src/probe/runners/jest.mjs +9 -0
- package/src/probe/{runner-vitest.mjs → runners/shared.mjs} +29 -21
- package/src/probe/runners/vitest.mjs +7 -0
- package/src/status/status.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **jest runner.** `--runner vitest|jest|auto` (default `auto`: the first
|
|
15
|
+
that resolves in the project). jest runs with `--ci --json --runTestsByPath`
|
|
16
|
+
so file arguments are exact paths, not regexes; its `Exceeded timeout`
|
|
17
|
+
wording is recognised as a timeout, never a kill; `__tests__/` files are
|
|
18
|
+
collected for escalation. `run.runner.name` records which runner produced
|
|
19
|
+
the evidence.
|
|
20
|
+
- A CommonJS edition of the known-answer fixture (`fixtures/known-answer-jest`)
|
|
21
|
+
with the same claims and the same expected verdicts; the acceptance suite
|
|
22
|
+
and CI prove jest against it exactly as vitest is proven against the ESM
|
|
23
|
+
one.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Runners are now modules behind one interface (`src/probe/runners/`); the
|
|
28
|
+
vitest path is unchanged and still reproduces its oracle.
|
|
29
|
+
|
|
10
30
|
## [0.3.1] - 2026-09-17
|
|
11
31
|
|
|
12
32
|
Dogfooding `init` and `status` on this repository.
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ tests were written against the survivors, 39/39 were killed.
|
|
|
52
52
|
| npm | `npm i -D testguard-cli` then `npx testguard probe` |
|
|
53
53
|
| pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
|
|
54
54
|
| Homebrew | `brew tap raccioly/tap && brew install testguard` |
|
|
55
|
-
| GitHub Action | `uses: raccioly/testguard@v0.
|
|
55
|
+
| GitHub Action | `uses: raccioly/testguard@v0.4.0` — see [`action.yml`](./action.yml) |
|
|
56
56
|
| pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
|
|
57
57
|
|
|
58
58
|
Projects that set `min-release-age` in `.npmrc` cannot see a version published
|
|
@@ -111,6 +111,11 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
|
|
|
111
111
|
files that import the fault's target, by relative path or resolved alias.
|
|
112
112
|
`NOCOVER` then means exactly "no test file imports this source".
|
|
113
113
|
|
|
114
|
+
Runners: **vitest** and **jest** (`--runner auto` picks the first that
|
|
115
|
+
resolves; both read the same jest-compatible JSON report). Anything else
|
|
116
|
+
goes through `--runner-cmd`. Each runner is proven against its own copy
|
|
117
|
+
of the known-answer fixture.
|
|
118
|
+
|
|
114
119
|
Practical loop: first pass `--no-escalate` (escalation re-runs the whole
|
|
115
120
|
suite N times per survivor); iterate on one claim with `--claim <ID>` and
|
|
116
121
|
either `--include-dirty` or `--in-place` (only fault target files must be
|
|
@@ -215,14 +220,14 @@ through every verdict.
|
|
|
215
220
|
|
|
216
221
|
## Status
|
|
217
222
|
|
|
218
|
-
**v0.
|
|
219
|
-
mechanical scaffold, and an agent operating layer (`status`, `init`). The contract
|
|
223
|
+
**v0.4.** Seven commands, vitest and jest runners, hand-authored faults plus
|
|
224
|
+
a mechanical scaffold, and an agent operating layer (`status`, `init`). The contract
|
|
220
225
|
spine — six JSON Schemas shared with the other Guard tools — is under
|
|
221
226
|
[`spec/`](spec/). One exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20.
|
|
222
227
|
|
|
223
|
-
Not yet: test generation (the two-gate acceptance loop),
|
|
224
|
-
AST-aware producers, and calibration of fault classes
|
|
225
|
-
bugs. Each is designed for; none is claimed.
|
|
228
|
+
Not yet: test generation (the two-gate acceptance loop), runners beyond
|
|
229
|
+
vitest and jest, AST-aware producers, and calibration of fault classes
|
|
230
|
+
against real escaped bugs. Each is designed for; none is claimed.
|
|
226
231
|
|
|
227
232
|
## Licence
|
|
228
233
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testguard-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Proves a test suite defends the claims a project makes: injects the faults those claims forbid and reports every one the tests fail to detect.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"quality-gate"
|
|
47
47
|
],
|
|
48
48
|
"devDependencies": {
|
|
49
|
+
"jest": "^30.5.1",
|
|
49
50
|
"vitest": "^5.0.1"
|
|
50
51
|
},
|
|
51
52
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -38,6 +38,7 @@ probe
|
|
|
38
38
|
--include-dirty probe the working tree (a snapshot commit) instead of HEAD; uncommitted tests count
|
|
39
39
|
--verbose also print each killed fault (default: only unproven ones, plus a count)
|
|
40
40
|
--confirm below 3 is PROVISIONAL: verdicts print with "?", evidence goes to .testguard/evidence-provisional.json
|
|
41
|
+
--runner <name> vitest | jest | auto (default: auto — first of vitest, jest that resolves)
|
|
41
42
|
--runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
|
|
42
43
|
--node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
|
|
43
44
|
--in-place mutate the working tree instead of a scratch worktree
|
|
@@ -82,6 +83,7 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
|
|
|
82
83
|
force: { type: 'boolean', default: false },
|
|
83
84
|
verbose: { type: 'boolean', default: false },
|
|
84
85
|
'runner-cmd': { type: 'string' },
|
|
86
|
+
runner: { type: 'string', default: 'auto' },
|
|
85
87
|
'node-modules': { type: 'string' },
|
|
86
88
|
'in-place': { type: 'boolean', default: false },
|
|
87
89
|
'no-escalate': { type: 'boolean', default: false },
|
|
@@ -114,6 +116,10 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
|
|
|
114
116
|
io.err(USAGE);
|
|
115
117
|
return 3;
|
|
116
118
|
}
|
|
119
|
+
if (!['vitest', 'jest', 'auto'].includes(values.runner)) {
|
|
120
|
+
io.err('--runner must be vitest, jest or auto');
|
|
121
|
+
return 3;
|
|
122
|
+
}
|
|
117
123
|
if (!['critical', 'high', 'medium', 'low'].includes(values.severity)) {
|
|
118
124
|
io.err('--severity must be one of critical, high, medium, low');
|
|
119
125
|
return 3;
|
package/src/commands/claims.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
3
3
|
import { scanAnnotations, reconcile } from '../claims/annotations.mjs';
|
|
4
|
-
import { resolveDefenders } from '../probe/
|
|
4
|
+
import { resolveDefenders } from '../probe/runners/shared.mjs';
|
|
5
5
|
import { discoverDefenders } from '../probe/discover.mjs';
|
|
6
6
|
|
|
7
7
|
export async function claimsCommand({ projectDir, values }, io) {
|
package/src/commands/probe.mjs
CHANGED
|
@@ -42,6 +42,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
42
42
|
mode: values['in-place'] ? 'in-place' : 'worktree',
|
|
43
43
|
ref: values.ref,
|
|
44
44
|
runnerCommand: values['runner-cmd'],
|
|
45
|
+
runnerName: values.runner,
|
|
45
46
|
nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
|
|
46
47
|
only,
|
|
47
48
|
escalate: !values['no-escalate'],
|
package/src/probe/discover.mjs
CHANGED
package/src/probe/probe.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import { repoRoot as gitRoot, headSha, isDirty, snapshotWorkingTree } from '../g
|
|
|
6
6
|
import { discoverDefenders } from './discover.mjs';
|
|
7
7
|
import { createScratch, inPlace, PreconditionError } from './worktree.mjs';
|
|
8
8
|
import { applyFault, locate } from './inject.mjs';
|
|
9
|
-
import
|
|
9
|
+
import { resolveDefenders, parseCommandTemplate } from './runners/shared.mjs';
|
|
10
|
+
import { selectRunner, RUNNERS } from './runners/index.mjs';
|
|
10
11
|
import { classify, shouldStopEarly } from './classify.mjs';
|
|
11
12
|
import { blastRadius, rank } from './rank.mjs';
|
|
12
13
|
import { hashFile, sha256 } from '../util/hash.mjs';
|
|
@@ -14,9 +15,9 @@ import { fingerprint } from '../../spec/lib/fingerprint.mjs';
|
|
|
14
15
|
|
|
15
16
|
const isKill = (r) => r.outcome === 'fail' && r.assertionFailures > 0;
|
|
16
17
|
|
|
17
|
-
function readRunnerVersion(projectDir) {
|
|
18
|
+
function readRunnerVersion(projectDir, name = 'vitest') {
|
|
18
19
|
try {
|
|
19
|
-
return JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve(
|
|
20
|
+
return JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve(`${name}/package.json`), 'utf8')).version;
|
|
20
21
|
} catch {
|
|
21
22
|
return undefined;
|
|
22
23
|
}
|
|
@@ -34,6 +35,7 @@ export async function probe({
|
|
|
34
35
|
ref = 'HEAD',
|
|
35
36
|
budgetMs = 120_000,
|
|
36
37
|
runnerCommand,
|
|
38
|
+
runnerName = 'auto',
|
|
37
39
|
nodeModules,
|
|
38
40
|
only,
|
|
39
41
|
includeDirty = false,
|
|
@@ -75,7 +77,7 @@ export async function probe({
|
|
|
75
77
|
const watched = new Set(targets);
|
|
76
78
|
for (const claim of claims.claims) {
|
|
77
79
|
if (selected && !selected.has(claim.id)) continue;
|
|
78
|
-
const declared = claim.defendedBy?.length ?
|
|
80
|
+
const declared = claim.defendedBy?.length ? resolveDefenders(projectDir, claim.defendedBy) : claim.faults.flatMap((f) => discoverDefenders(projectDir, f.file));
|
|
79
81
|
for (const d of declared) watched.add(relative(root, join(projectDir, d)));
|
|
80
82
|
for (const g of claim.defendedBy ?? []) if (!g.includes('*')) watched.add(relative(root, join(projectDir, g)));
|
|
81
83
|
}
|
|
@@ -89,27 +91,29 @@ export async function probe({
|
|
|
89
91
|
const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref: snapshot ?? ref, scratchBase, nodeModules }) : inPlace({ repoRoot: root, projectDir });
|
|
90
92
|
const records = [];
|
|
91
93
|
let runnerVersion;
|
|
94
|
+
let runner = RUNNERS[runnerName === 'auto' ? 'vitest' : runnerName];
|
|
92
95
|
try {
|
|
93
|
-
const commandTemplate = runnerCommand ?
|
|
96
|
+
const commandTemplate = runnerCommand ? parseCommandTemplate(runnerCommand) : undefined;
|
|
94
97
|
if (!commandTemplate) {
|
|
95
98
|
// An unresolvable runner is a precondition failure, not a flaky defender.
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
throw new PreconditionError(`test runner is not resolvable in the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${
|
|
99
|
+
const sel = await selectRunner({ projectDir: iso.projectDir, name: runnerName });
|
|
100
|
+
if (sel.error) {
|
|
101
|
+
throw new PreconditionError(`test runner is not resolvable in the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${sel.error}). ` +
|
|
99
102
|
(mode === 'worktree' ? 'No usable node_modules was linked: pass --node-modules <path>, or run with --in-place.' : 'Install dependencies first.'));
|
|
100
103
|
}
|
|
101
|
-
|
|
104
|
+
runner = sel.runner;
|
|
105
|
+
runnerVersion = sel.version;
|
|
102
106
|
}
|
|
103
|
-
const allTests =
|
|
107
|
+
const allTests = runner.tests(iso.projectDir);
|
|
104
108
|
const baselineCache = new Map();
|
|
105
109
|
const prior = previous && previous.run.confirmRuns === confirmRuns
|
|
106
110
|
? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
|
|
107
111
|
: new Map();
|
|
108
|
-
const runDefenders = (files) =>
|
|
112
|
+
const runDefenders = (files) => runner.run({ projectDir: iso.projectDir, files, budgetMs, commandTemplate });
|
|
109
113
|
|
|
110
114
|
for (const claim of claims.claims) {
|
|
111
115
|
if (selected && !selected.has(claim.id)) continue;
|
|
112
|
-
const declared = claim.defendedBy?.length ?
|
|
116
|
+
const declared = claim.defendedBy?.length ? resolveDefenders(iso.projectDir, claim.defendedBy) : null;
|
|
113
117
|
for (const fault of claim.faults) {
|
|
114
118
|
const defenders = declared ?? discoverDefenders(iso.projectDir, fault.file);
|
|
115
119
|
const stage = (name, i, n) => onStage({ claimId: claim.id, faultId: fault.id, stage: name, i, n });
|
|
@@ -130,7 +134,7 @@ export async function probe({
|
|
|
130
134
|
startedAt,
|
|
131
135
|
finishedAt: new Date().toISOString(),
|
|
132
136
|
repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}) },
|
|
133
|
-
runner: { name:
|
|
137
|
+
runner: { name: runner.name, ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}) },
|
|
134
138
|
confirmRuns,
|
|
135
139
|
...(confirmRuns < 3 ? { provisional: true } : {}),
|
|
136
140
|
mode,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as vitest from './vitest.mjs';
|
|
2
|
+
import * as jest from './jest.mjs';
|
|
3
|
+
|
|
4
|
+
export const RUNNERS = { vitest, jest };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pick the runner: an explicit name, or the first of vitest, jest that is
|
|
8
|
+
* resolvable from the project. Returns { runner, version } or { error }.
|
|
9
|
+
*/
|
|
10
|
+
export async function selectRunner({ projectDir, name = 'auto' }) {
|
|
11
|
+
const candidates = name === 'auto' ? [vitest, jest] : [RUNNERS[name]];
|
|
12
|
+
if (!candidates[0]) return { error: `unknown runner "${name}"; use vitest, jest or auto` };
|
|
13
|
+
const messages = [];
|
|
14
|
+
for (const r of candidates) {
|
|
15
|
+
const c = await r.check({ projectDir });
|
|
16
|
+
if (c.ok) return { runner: r, version: c.version };
|
|
17
|
+
messages.push(`${r.name}: ${c.message}`);
|
|
18
|
+
}
|
|
19
|
+
return { error: messages.join('; ') };
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { TEST_GLOBS, npx, runProcess, checkBinary, listTestFiles } from './shared.mjs';
|
|
2
|
+
|
|
3
|
+
export const name = 'jest';
|
|
4
|
+
// jest's default testMatch also collects anything under __tests__/.
|
|
5
|
+
export const testGlobs = [...TEST_GLOBS, '**/__tests__/**/*.js', '**/__tests__/**/*.mjs', '**/__tests__/**/*.cjs', '**/__tests__/**/*.ts', '**/__tests__/**/*.tsx', '**/__tests__/**/*.jsx'];
|
|
6
|
+
export const check = (opts) => checkBinary({ ...opts, bin: 'jest' });
|
|
7
|
+
export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
|
|
8
|
+
// --runTestsByPath: positionals are exact paths, not regexes — a path with `+` or `(` would otherwise silently match nothing.
|
|
9
|
+
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [npx, 'jest', '--ci', '--json', `--outputFile=${outFile}`, '--runTestsByPath', ...files] });
|
|
@@ -3,12 +3,11 @@ import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
|
-
import { matchGlobs } from '
|
|
6
|
+
import { matchGlobs } from '../../util/glob.mjs';
|
|
7
7
|
|
|
8
|
-
const TEST_GLOBS = ['**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx',
|
|
8
|
+
export const TEST_GLOBS = ['**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx',
|
|
9
9
|
'**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx'];
|
|
10
|
-
|
|
11
|
-
export const name = 'vitest';
|
|
10
|
+
export const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* Split a runner command template into argv. Supports double and single
|
|
@@ -45,14 +44,13 @@ export function parseCommandTemplate(template) {
|
|
|
45
44
|
export const expandCommand = (words, files, outFile) => words.flatMap((w) => (w === '{files}' ? files : [w.replaceAll('{out}', outFile)]));
|
|
46
45
|
|
|
47
46
|
/**
|
|
48
|
-
* Can
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* Can `bin` be resolved at all from this directory? Run before any verdict
|
|
48
|
+
* is attempted: an unresolvable runner is a precondition failure, never a
|
|
49
|
+
* statement about the tests.
|
|
51
50
|
*/
|
|
52
|
-
export function
|
|
53
|
-
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
51
|
+
export function checkBinary({ projectDir, bin, budgetMs = 60_000, env = process.env }) {
|
|
54
52
|
return new Promise((resolve) => {
|
|
55
|
-
const child = spawn(npx, ['--no-install',
|
|
53
|
+
const child = spawn(npx, ['--no-install', bin, '--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
|
|
56
54
|
let out = '';
|
|
57
55
|
let err = '';
|
|
58
56
|
child.on('error', (e) => resolve({ ok: false, message: e.message }));
|
|
@@ -61,7 +59,7 @@ export function checkRunner({ projectDir, budgetMs = 60_000, env = process.env }
|
|
|
61
59
|
const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
|
|
62
60
|
child.on('close', (code) => {
|
|
63
61
|
clearTimeout(timer);
|
|
64
|
-
const version = out.trim().replace(/^vitest\//, '');
|
|
62
|
+
const version = out.trim().replace(/^vitest\//, '').split('\n').pop();
|
|
65
63
|
resolve(code === 0 && version ? { ok: true, version } : { ok: false, message: (err || out).trim().split('\n').filter(Boolean).slice(-1)[0] ?? `exit ${code}` });
|
|
66
64
|
});
|
|
67
65
|
});
|
|
@@ -70,7 +68,7 @@ export function checkRunner({ projectDir, budgetMs = 60_000, env = process.env }
|
|
|
70
68
|
/** Defender globs → existing files. Empty result is the `nocover` signal. */
|
|
71
69
|
export const resolveDefenders = (projectDir, globs) => matchGlobs(projectDir, globs ?? []);
|
|
72
70
|
|
|
73
|
-
export const listTestFiles = (projectDir) => matchGlobs(projectDir,
|
|
71
|
+
export const listTestFiles = (projectDir, globs = TEST_GLOBS) => matchGlobs(projectDir, globs);
|
|
74
72
|
|
|
75
73
|
/**
|
|
76
74
|
* Turn vitest's JSON report into a spec `testRun`.
|
|
@@ -80,11 +78,16 @@ export const listTestFiles = (projectDir) => matchGlobs(projectDir, TEST_GLOBS);
|
|
|
80
78
|
* can kill a fault. A suite that fails to load, or a test that times out, is
|
|
81
79
|
* not evidence that the suite defends the claim.
|
|
82
80
|
*/
|
|
81
|
+
function firstInformativeLine(message) {
|
|
82
|
+
const lines = message.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean);
|
|
83
|
+
return lines.find((l) => /error|syntax|unexpected|expected .+ but found|cannot find|failed to parse|transform failed/i.test(l) && !/^●/.test(l)) ?? lines[0] ?? '';
|
|
84
|
+
}
|
|
85
|
+
|
|
83
86
|
export function parseReport(report, durationMs) {
|
|
84
87
|
const files = report.testResults ?? [];
|
|
85
88
|
const loadFailed = files.some((f) => f.status === 'failed' && (f.assertionResults?.length ?? 0) === 0);
|
|
86
89
|
const results = files.flatMap((f) => (f.assertionResults ?? []).map((t) => ({ ...t, id: `${f.name}::${t.fullName ?? t.title ?? ''}` }))).filter((t) => t.status === 'failed');
|
|
87
|
-
const timeouts = results.filter((t) => (t.failureMessages ?? []).some((m) => /timed out/i.test(m))).length;
|
|
90
|
+
const timeouts = results.filter((t) => (t.failureMessages ?? []).some((m) => /timed out|exceeded timeout/i.test(m))).length;
|
|
88
91
|
const failedTests = results.map((t) => t.id);
|
|
89
92
|
const assertionFailures = results.length - timeouts;
|
|
90
93
|
const tests = { total: report.numTotalTests ?? 0, passed: report.numPassedTests ?? 0, failed: report.numFailedTests ?? 0 };
|
|
@@ -95,20 +98,25 @@ export function parseReport(report, durationMs) {
|
|
|
95
98
|
else outcome = 'fail';
|
|
96
99
|
|
|
97
100
|
const run = { outcome, tests, assertionFailures, durationMs };
|
|
98
|
-
|
|
101
|
+
// jest opens a load failure with "● Test suite failed to run" and puts the
|
|
102
|
+
// cause lines below; vitest puts it on the first line. Take the first line
|
|
103
|
+
// that names an error, else the first non-empty one.
|
|
104
|
+
const loadMessage = loadFailed ? firstInformativeLine(files.find((f) => f.message)?.message ?? '') : undefined;
|
|
99
105
|
return { run, timeouts, loadMessage, failedTests };
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
/**
|
|
103
|
-
* Run
|
|
104
|
-
*
|
|
105
|
-
*
|
|
109
|
+
* Run a test process on `files` inside `projectDir` with a hard wall-clock
|
|
110
|
+
* budget and read back its jest-compatible JSON report. The budget matters
|
|
111
|
+
* because a synchronous infinite loop is immune to any runner's own test
|
|
112
|
+
* timeout; only killing the process ends it. `argv(files, outFile)` is the
|
|
113
|
+
* runner's command line; `command` (tests) or `commandTemplate` (--runner-cmd)
|
|
114
|
+
* override it.
|
|
106
115
|
*/
|
|
107
|
-
export function
|
|
108
|
-
const outFile = join(tmpdir(), `testguard-
|
|
109
|
-
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
116
|
+
export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv }) {
|
|
117
|
+
const outFile = join(tmpdir(), `testguard-run-${randomBytes(6).toString('hex')}.json`);
|
|
110
118
|
const [cmd, ...args] = command
|
|
111
|
-
?? (commandTemplate ? expandCommand(commandTemplate, files, outFile) :
|
|
119
|
+
?? (commandTemplate ? expandCommand(commandTemplate, files, outFile) : argv(files, outFile));
|
|
112
120
|
const started = Date.now();
|
|
113
121
|
|
|
114
122
|
return new Promise((resolve) => {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { TEST_GLOBS, npx, runProcess, checkBinary, listTestFiles } from './shared.mjs';
|
|
2
|
+
|
|
3
|
+
export const name = 'vitest';
|
|
4
|
+
export const testGlobs = TEST_GLOBS;
|
|
5
|
+
export const check = (opts) => checkBinary({ ...opts, bin: 'vitest' });
|
|
6
|
+
export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
|
|
7
|
+
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [npx, 'vitest', 'run', ...files, '--reporter=json', `--outputFile=${outFile}`] });
|
package/src/status/status.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readSpecDoc } from '../evidence/writer.mjs';
|
|
|
4
4
|
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
5
5
|
import { gate } from '../baseline/baseline.mjs';
|
|
6
6
|
import { hashFile, sha256 } from '../util/hash.mjs';
|
|
7
|
-
import { resolveDefenders } from '../probe/
|
|
7
|
+
import { resolveDefenders } from '../probe/runners/shared.mjs';
|
|
8
8
|
import { discoverDefenders } from '../probe/discover.mjs';
|
|
9
9
|
import { sortForReport } from '../render.mjs';
|
|
10
10
|
|