testguard-cli 0.5.0 → 0.6.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 +255 -0
- package/README.md +275 -31
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +58 -1
- package/spec/README.md +1 -0
- package/spec/lib/validate.mjs +57 -1
- package/spec/schemas/baseline.schema.json +8 -0
- package/spec/schemas/common.schema.json +5 -1
- package/spec/schemas/evidence.schema.json +374 -63
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +256 -54
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +71 -7
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +53 -10
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +13 -7
- package/src/commands/claims.mjs +35 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +11 -3
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +3 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/contention.mjs +60 -0
- package/src/probe/discover.mjs +12 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/mocks.mjs +120 -0
- package/src/probe/probe.mjs +126 -18
- package/src/probe/rank.mjs +73 -15
- package/src/probe/runners/index.mjs +49 -5
- package/src/probe/runners/jest.mjs +3 -3
- package/src/probe/runners/playwright.mjs +133 -0
- package/src/probe/runners/shared.mjs +70 -13
- package/src/probe/runners/vitest.mjs +4 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +309 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/scaffold.mjs +9 -3
- package/src/status/status.mjs +47 -6
package/src/probe/probe.mjs
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, realpathSync as fsRealpathSync } from 'node:fs';
|
|
2
2
|
import { git } from '../git.mjs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
-
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { repoRoot as gitRoot, headSha, isDirty, snapshotWorkingTree } from '../git.mjs';
|
|
6
|
-
import { discoverDefenders } from './discover.mjs';
|
|
6
|
+
import { discoverDefenders, discoverDefendersDetailed } from './discover.mjs';
|
|
7
|
+
import { classifyDefenders } from './mocks.mjs';
|
|
8
|
+
import { detectContention, contentionWarning } from './contention.mjs';
|
|
7
9
|
import { createScratch, inPlace, PreconditionError } from './worktree.mjs';
|
|
8
10
|
import { applyFault, locate } from './inject.mjs';
|
|
9
11
|
import { resolveDefenders, parseCommandTemplate } from './runners/shared.mjs';
|
|
10
|
-
import { selectRunner, RUNNERS } from './runners/index.mjs';
|
|
12
|
+
import { selectRunner, RUNNERS, OWNED_RUNNERS, partitionByRunner, mergeRuns } from './runners/index.mjs';
|
|
11
13
|
import { classify, shouldStopEarly } from './classify.mjs';
|
|
12
14
|
import { blastRadius, rank } from './rank.mjs';
|
|
15
|
+
import { classifyIndependence } from './independence.mjs';
|
|
13
16
|
import { hashFile, sha256 } from '../util/hash.mjs';
|
|
14
17
|
import { fingerprint } from '../../spec/lib/fingerprint.mjs';
|
|
15
18
|
|
|
16
19
|
const isKill = (r) => r.outcome === 'fail' && r.assertionFailures > 0;
|
|
17
20
|
|
|
21
|
+
/** realpath when the path still exists; the path itself otherwise. Never throws. */
|
|
22
|
+
function realpathSync(p) {
|
|
23
|
+
try {
|
|
24
|
+
return fsRealpathSync(p);
|
|
25
|
+
} catch {
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
function readRunnerVersion(projectDir, name = 'vitest') {
|
|
19
31
|
try {
|
|
20
32
|
return JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve(`${name}/package.json`), 'utf8')).version;
|
|
@@ -33,6 +45,10 @@ export async function probe({
|
|
|
33
45
|
confirmRuns = 3,
|
|
34
46
|
mode = 'worktree',
|
|
35
47
|
ref = 'HEAD',
|
|
48
|
+
refExplicit = false,
|
|
49
|
+
ignoreDirty = false,
|
|
50
|
+
serial = false,
|
|
51
|
+
onWarn = () => {},
|
|
36
52
|
budgetMs = 120_000,
|
|
37
53
|
runnerCommand,
|
|
38
54
|
runnerName = 'auto',
|
|
@@ -69,11 +85,17 @@ export async function probe({
|
|
|
69
85
|
|
|
70
86
|
// Worktree mode probes a commit, not the working tree. Uncommitted defender
|
|
71
87
|
// or target edits would be silently absent — the same survivors, no hint why.
|
|
88
|
+
// Refused for the IMPLICIT HEAD, which is the silent-mismatch trap. With an
|
|
89
|
+
// explicit --ref the user has named the commit they mean (a pre-fix probe
|
|
90
|
+
// in a post-mortem is the most valuable run there is); with --ignore-dirty
|
|
91
|
+
// they have said they know. Both proceed with a warning that names the
|
|
92
|
+
// files, and the evidence records what was ignored.
|
|
72
93
|
let snapshot;
|
|
94
|
+
let ignoredDirty = [];
|
|
73
95
|
if (mode === 'worktree') {
|
|
74
96
|
if (includeDirty) {
|
|
75
97
|
if (isDirty(root)) snapshot = snapshotWorkingTree(root);
|
|
76
|
-
} else
|
|
98
|
+
} else {
|
|
77
99
|
const watched = new Set(targets);
|
|
78
100
|
for (const claim of claims.claims) {
|
|
79
101
|
if (selected && !selected.has(claim.id)) continue;
|
|
@@ -82,16 +104,28 @@ export async function probe({
|
|
|
82
104
|
for (const g of claim.defendedBy ?? []) if (!g.includes('*')) watched.add(relative(root, join(projectDir, g)));
|
|
83
105
|
}
|
|
84
106
|
const dirty = git(['status', '--porcelain', '--', ...watched], root).split('\n').filter(Boolean).map((l) => l.replace(/^[ MADRCU?!]{1,2}\s+/, '').replace(/^.* -> /, ''));
|
|
107
|
+
if (dirty.length && !refExplicit && !ignoreDirty) {
|
|
108
|
+
throw new PreconditionError(`${dirty.length} defender/target file${dirty.length === 1 ? ' has' : 's have'} uncommitted changes (${dirty.join(', ')}); worktree mode probes HEAD (${head.slice(0, 7)}), so those changes would be silently ignored. Commit them, run with --include-dirty to probe the working tree, use --in-place, or --ignore-dirty if you mean HEAD as committed.`);
|
|
109
|
+
}
|
|
85
110
|
if (dirty.length) {
|
|
86
|
-
|
|
111
|
+
ignoredDirty = dirty.sort();
|
|
112
|
+
onWarn(`${dirty.length} defender/target file${dirty.length === 1 ? ' has' : 's have'} uncommitted changes (${dirty.join(', ')}); probing ${ref === 'HEAD' ? `HEAD (${head.slice(0, 7)})` : `${ref} (${head.slice(0, 7)})`} as committed — the working-tree versions are NOT what is being probed. Recorded in the evidence as repo.ignoredDirty.`);
|
|
87
113
|
}
|
|
88
114
|
}
|
|
89
115
|
}
|
|
116
|
+
// Contention: name it before the first run, and record it, so a later
|
|
117
|
+
// reader of a TIMEOUT or FLAKY-DEFENDER knows the machine was loaded.
|
|
118
|
+
const contention = detectContention();
|
|
119
|
+
if (contention.detected) onWarn(contentionWarning(contention));
|
|
120
|
+
|
|
90
121
|
const startedAt = new Date().toISOString();
|
|
91
122
|
const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref: snapshot ?? ref, scratchBase, nodeModules }) : inPlace({ repoRoot: root, projectDir });
|
|
123
|
+
const isoReal = realpathSync(iso.projectDir);
|
|
92
124
|
const records = [];
|
|
93
125
|
let runnerVersion;
|
|
126
|
+
let runnerSource;
|
|
94
127
|
let runner = RUNNERS[runnerName === 'auto' ? 'vitest' : runnerName];
|
|
128
|
+
const runnersUsed = new Map(); // every runner that ran defenders, beyond the project runner
|
|
95
129
|
try {
|
|
96
130
|
const commandTemplate = runnerCommand ? parseCommandTemplate(runnerCommand) : undefined;
|
|
97
131
|
if (!commandTemplate) {
|
|
@@ -103,21 +137,60 @@ export async function probe({
|
|
|
103
137
|
}
|
|
104
138
|
runner = sel.runner;
|
|
105
139
|
runnerVersion = sel.version;
|
|
140
|
+
runnerSource = sel.source;
|
|
106
141
|
}
|
|
107
|
-
|
|
142
|
+
runnersUsed.set(runner.name, runnerVersion ?? readRunnerVersion(projectDir, runner.name));
|
|
143
|
+
// Files an owning runner (Playwright) claims run under it, whatever the
|
|
144
|
+
// project runner is; it must resolve before the first such defender runs.
|
|
145
|
+
const owned = OWNED_RUNNERS.filter((r) => r !== runner);
|
|
146
|
+
const ownedChecked = new Map();
|
|
147
|
+
const ensureOwned = async (r, file) => {
|
|
148
|
+
if (!ownedChecked.has(r)) ownedChecked.set(r, await r.check({ projectDir: iso.projectDir }));
|
|
149
|
+
const c = ownedChecked.get(r);
|
|
150
|
+
if (!c.ok) throw new PreconditionError(`${file} is a ${r.name} test (it lives under ${r.name}'s testDir) but ${r.name} is not resolvable in the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${c.message}).`);
|
|
151
|
+
runnersUsed.set(r.name, c.version);
|
|
152
|
+
};
|
|
153
|
+
const allTests = [...new Set([...runner.tests(iso.projectDir).filter((t) => !owned.some((r) => r.owns(iso.projectDir, t))), ...owned.flatMap((r) => r.tests(iso.projectDir))])].sort();
|
|
108
154
|
const baselineCache = new Map();
|
|
109
155
|
const prior = previous && previous.run.confirmRuns === confirmRuns
|
|
110
156
|
? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
|
|
111
157
|
: new Map();
|
|
112
|
-
const runDefenders = (files) =>
|
|
158
|
+
const runDefenders = async (files) => {
|
|
159
|
+
if (commandTemplate) return runner.run({ projectDir: iso.projectDir, files, budgetMs, commandTemplate, serial });
|
|
160
|
+
const groups = partitionByRunner(iso.projectDir, files, runner);
|
|
161
|
+
const parts = [];
|
|
162
|
+
for (const [r, group] of groups) {
|
|
163
|
+
if (r !== runner) await ensureOwned(r, group[0]);
|
|
164
|
+
parts.push(await r.run({ projectDir: iso.projectDir, files: group, budgetMs, serial }));
|
|
165
|
+
}
|
|
166
|
+
return mergeRuns(parts);
|
|
167
|
+
};
|
|
168
|
+
/** Which runner each defender runs under, when more than the project runner is involved. */
|
|
169
|
+
const byRunner = (files) => {
|
|
170
|
+
const groups = partitionByRunner(iso.projectDir, files, runner);
|
|
171
|
+
if (groups.size <= 1) return undefined; // one runner ran them all, whichever it was
|
|
172
|
+
return Object.fromEntries([...groups].map(([r, group]) => [r.name, group]));
|
|
173
|
+
};
|
|
113
174
|
|
|
114
175
|
for (const claim of claims.claims) {
|
|
115
176
|
if (selected && !selected.has(claim.id)) continue;
|
|
116
177
|
const declared = claim.defendedBy?.length ? resolveDefenders(iso.projectDir, claim.defendedBy) : null;
|
|
117
178
|
for (const fault of claim.faults) {
|
|
118
|
-
|
|
179
|
+
// Mock-awareness: a discovered file that mocks the target is not a
|
|
180
|
+
// defender; a declared one that mocks it stays (the author named it)
|
|
181
|
+
// but is listed, because a declared defender that mocks the subject
|
|
182
|
+
// is a broken evidence chain the author should see.
|
|
183
|
+
const mockInfo = declared ? classifyDefenders(iso.projectDir, fault.file, declared) : discoverDefendersDetailed(iso.projectDir, fault.file);
|
|
184
|
+
const defenders = declared ?? mockInfo.canDetect;
|
|
119
185
|
const stage = (name, i, n) => onStage({ claimId: claim.id, faultId: fault.id, stage: name, i, n });
|
|
120
|
-
const record = await probeOne({ claim, fault, defenders, discovered: declared === null, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior: prior.get(`${claim.id}/${fault.id}`), priorRunId: previous?.run.id
|
|
186
|
+
const record = await probeOne({ claim, fault, defenders, discovered: declared === null, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior: prior.get(`${claim.id}/${fault.id}`), priorRunId: previous?.run.id, byRunner: byRunner(defenders), mocking: mockInfo.mocking, signals: mockInfo.signals,
|
|
187
|
+
historyRef: snapshot ?? (mode === 'worktree' ? head : 'HEAD'), historyDir: root,
|
|
188
|
+
// A path from the runner is absolute inside the SCRATCH worktree, or
|
|
189
|
+
// project-relative. Either way history is read from the real repository,
|
|
190
|
+
// so both must land on a repo-root-relative path. The runner reports
|
|
191
|
+
// realpaths (/private/var on macOS) while the worktree path may not be
|
|
192
|
+
// one — realpath both sides or every comparison silently misses.
|
|
193
|
+
toRepoPath: (p) => relative(root, join(projectDir, isAbsolute(p) ? relative(isoReal, realpathSync(p)) : p)) });
|
|
121
194
|
records.push(record);
|
|
122
195
|
onProgress(record);
|
|
123
196
|
}
|
|
@@ -133,9 +206,12 @@ export async function probe({
|
|
|
133
206
|
id: `run-${startedAt.replace(/[-:.]/g, '').slice(0, 15)}`,
|
|
134
207
|
startedAt,
|
|
135
208
|
finishedAt: new Date().toISOString(),
|
|
136
|
-
repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}) },
|
|
209
|
+
repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}), ...(ignoredDirty.length ? { ignoredDirty } : {}) },
|
|
137
210
|
runner: { name: runner.name, ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}) },
|
|
211
|
+
...(runnersUsed.size > 1 ? { runners: [...runnersUsed].map(([n, v]) => ({ name: n, ...(v ? { version: v } : {}) })) } : {}),
|
|
138
212
|
confirmRuns,
|
|
213
|
+
...(serial ? { serial: true } : {}),
|
|
214
|
+
...(contention.detected ? { contention } : {}),
|
|
139
215
|
...(confirmRuns < 3 ? { provisional: true } : {}),
|
|
140
216
|
mode,
|
|
141
217
|
},
|
|
@@ -143,7 +219,7 @@ export async function probe({
|
|
|
143
219
|
};
|
|
144
220
|
}
|
|
145
221
|
|
|
146
|
-
async function probeOne({ claim, fault, defenders, discovered, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior, priorRunId }) {
|
|
222
|
+
async function probeOne({ claim, fault, defenders, discovered, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior, priorRunId, byRunner, mocking = [], signals = [], historyRef, historyDir, toRepoPath }) {
|
|
147
223
|
const targetPath = join(iso.projectDir, fault.file);
|
|
148
224
|
const targetExists = existsSync(targetPath);
|
|
149
225
|
const inputs = {
|
|
@@ -177,10 +253,27 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
177
253
|
break;
|
|
178
254
|
}
|
|
179
255
|
}
|
|
256
|
+
// The decision stops at the first non-green run — nothing about this
|
|
257
|
+
// fault can be trusted after it. But "failed once in three" and "fails
|
|
258
|
+
// every time" are different problems for whoever has to fix the
|
|
259
|
+
// defender, and the only way to tell them apart is to finish the runs.
|
|
260
|
+
// Paid once per defender set, and only when it is already broken.
|
|
261
|
+
if (runs.length < confirmRuns && runs[runs.length - 1].outcome === 'fail') {
|
|
262
|
+
for (let i = runs.length; i < confirmRuns; i++) {
|
|
263
|
+
stage('baseline-flake', i + 1, confirmRuns);
|
|
264
|
+
runs.push((await runDefenders(defenders)).run);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
180
267
|
baselineCache.set(key, { runs, loadMessage });
|
|
181
268
|
}
|
|
182
269
|
const baseline = baselineCache.get(key);
|
|
183
270
|
detail.baselineRuns = baseline.runs;
|
|
271
|
+
// Observed instability of the defenders on UNMODIFIED source: `fail` is
|
|
272
|
+
// the tests running and disagreeing with themselves. A load error or a
|
|
273
|
+
// timeout is not flakiness — nothing was measured about stability — so
|
|
274
|
+
// neither is counted, on either side of the ratio.
|
|
275
|
+
const measured = baseline.runs.filter((r) => r.outcome === 'pass' || r.outcome === 'fail');
|
|
276
|
+
if (measured.length) detail.flakeRate = { runs: measured.length, failures: measured.filter((r) => r.outcome === 'fail').length };
|
|
184
277
|
if (baseline.runs.some((r) => r.outcome === 'error')) {
|
|
185
278
|
// The defenders did not load. That is not flakiness and not a verdict
|
|
186
279
|
// about the fault; the claim cannot be probed until they do.
|
|
@@ -193,11 +286,11 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
193
286
|
const probeRuns = rawProbeRuns;
|
|
194
287
|
for (let i = 0; i < confirmRuns; i++) {
|
|
195
288
|
stage('probe', i + 1, confirmRuns);
|
|
196
|
-
const { run, timeouts, loadMessage } = await runDefenders(defenders);
|
|
197
|
-
probeRuns.push({ ...run, timeouts, loadMessage });
|
|
289
|
+
const { run, timeouts, loadMessage, failedTests } = await runDefenders(defenders);
|
|
290
|
+
probeRuns.push({ ...run, timeouts, loadMessage, failedTests });
|
|
198
291
|
if (shouldStopEarly(probeRuns)) break;
|
|
199
292
|
}
|
|
200
|
-
detail.probeRuns = probeRuns.map(({ timeouts, loadMessage, ...run }) => run);
|
|
293
|
+
detail.probeRuns = probeRuns.map(({ timeouts, loadMessage, failedTests, ...run }) => run);
|
|
201
294
|
const provisional = classify({ defenders, anchor, baselineRuns: detail.baselineRuns, probeRuns, confirmRuns });
|
|
202
295
|
|
|
203
296
|
// Escalation: does anything *undeclared* catch it? A single run cannot
|
|
@@ -207,15 +300,21 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
207
300
|
if (provisional.verdict === 'survived' && escalate && broader.length > 0) {
|
|
208
301
|
const runs = [];
|
|
209
302
|
let killers = null;
|
|
303
|
+
let stoppedEarly;
|
|
210
304
|
for (let i = 0; i < confirmRuns; i++) {
|
|
211
305
|
stage('escalation', i + 1, confirmRuns);
|
|
212
306
|
const { run, failedTests } = await runDefenders(allTests);
|
|
213
307
|
runs.push(run);
|
|
214
308
|
killers = killers === null ? new Set(failedTests) : new Set(failedTests.filter((t) => killers.has(t)));
|
|
215
|
-
|
|
309
|
+
// Attribution needs a test that failed in ALL N runs. Once the
|
|
310
|
+
// intersection is empty no further run can name a killer, so the
|
|
311
|
+
// remaining runs are wasted; stopping can only fail to NAME a
|
|
312
|
+
// killer, never upgrade a verdict, which is the pessimistic side.
|
|
313
|
+
if (killers.size === 0) { stoppedEarly = 'no-common-failure'; break; }
|
|
216
314
|
}
|
|
217
315
|
detail.escalated = true;
|
|
218
316
|
detail.escalationRuns = runs;
|
|
317
|
+
if (stoppedEarly) detail.escalationStoppedEarly = stoppedEarly;
|
|
219
318
|
if (killers.size > 0 && runs.length === confirmRuns && runs.every(isKill)) {
|
|
220
319
|
detail.reason = 'killed-by-undeclared-tests';
|
|
221
320
|
detail.undeclaredKillers = [...killers].sort();
|
|
@@ -234,15 +333,24 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
234
333
|
|
|
235
334
|
const blast = targetExists ? blastRadius(iso.projectDir, fault.file) : 0;
|
|
236
335
|
|
|
336
|
+
// L3: a kill by a test written in the same change as the code it guards is
|
|
337
|
+
// weaker evidence than one written separately. Signal only — the verdict is
|
|
338
|
+
// already decided above, and ranking is the only consumer.
|
|
339
|
+
if (verdict === 'killed' && historyRef) {
|
|
340
|
+
// Only the tests that actually failed on the fault are its killers.
|
|
341
|
+
const killers = [...new Set(rawProbeRuns.flatMap((r) => r.failedTests ?? []).map((t) => t.split('::')[0]).filter(Boolean))];
|
|
342
|
+
detail.independence = classifyIndependence({ dir: historyDir, ref: historyRef, targetFile: toRepoPath(fault.file), defenders: (killers.length ? killers : defenders).map(toRepoPath) });
|
|
343
|
+
}
|
|
344
|
+
|
|
237
345
|
return {
|
|
238
346
|
fingerprint: fingerprint({ claimId: claim.id, subjectId: fault.id, file: fault.file, verdict }),
|
|
239
347
|
claim: { id: claim.id, statement: claim.statement, severity: claim.severity, source: claim.source, producedBy: claim.producedBy },
|
|
240
348
|
subject: { kind: 'fault', id: fault.id, description: fault.description, file: fault.file, faultClass: fault.faultClass, producedBy: fault.producedBy, contentHash: sha256(`${fault.find}\n${fault.replace}`) },
|
|
241
349
|
verdict,
|
|
242
350
|
detail,
|
|
243
|
-
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}) },
|
|
351
|
+
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}), ...(byRunner ? { byRunner } : {}), ...(mocking.length ? { mocking } : {}), ...(signals.length ? { signals } : {}) },
|
|
244
352
|
inputs,
|
|
245
|
-
rank: rank({ severity: claim.severity, sourceKind: claim.source.kind, blast }),
|
|
353
|
+
rank: rank({ severity: claim.severity, sourceKind: claim.source.kind, blast, independence: detail.independence?.class }),
|
|
246
354
|
};
|
|
247
355
|
}
|
|
248
356
|
|
package/src/probe/rank.mjs
CHANGED
|
@@ -11,8 +11,10 @@ function parseJsonc(text) {
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Alias rules for a project: tsconfig/jsconfig `compilerOptions.paths` (with
|
|
14
|
-
* `baseUrl`, following a relative `extends` a few levels
|
|
15
|
-
* `
|
|
14
|
+
* `baseUrl`, following a relative `extends` a few levels, and every
|
|
15
|
+
* `references[].path` — Vite projects keep `paths` in `tsconfig.app.json`),
|
|
16
|
+
* vite/vitest `resolve.alias` read as text, and package.json `imports`. Bare
|
|
17
|
+
* specifiers that match no rule are ignored, as documented.
|
|
16
18
|
*/
|
|
17
19
|
export function loadAliases(projectDir) {
|
|
18
20
|
if (aliasCache.has(projectDir)) return aliasCache.get(projectDir);
|
|
@@ -22,25 +24,69 @@ export function loadAliases(projectDir) {
|
|
|
22
24
|
rules.push({ prefix, suffix, wildcard: pattern.includes('*'), targets: targets.map((t) => resolve(base, t)) });
|
|
23
25
|
};
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Every tsconfig in the chain: the root, what it extends, what it references.
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const queue = ['tsconfig.json', 'jsconfig.json'].map((f) => join(projectDir, f)).filter(existsSync).slice(0, 1);
|
|
30
|
+
while (queue.length && seen.size < 12) {
|
|
31
|
+
const cfgPath = queue.shift();
|
|
32
|
+
if (seen.has(cfgPath) || !existsSync(cfgPath)) continue;
|
|
33
|
+
seen.add(cfgPath);
|
|
29
34
|
let cfg;
|
|
30
35
|
try {
|
|
31
36
|
cfg = parseJsonc(readFileSync(cfgPath, 'utf8'));
|
|
32
37
|
} catch {
|
|
33
|
-
|
|
38
|
+
continue;
|
|
34
39
|
}
|
|
35
40
|
const co = cfg.compilerOptions ?? {};
|
|
36
|
-
if (
|
|
37
|
-
|
|
41
|
+
if (co.paths) {
|
|
42
|
+
const base = co.baseUrl !== undefined ? resolve(dirname(cfgPath), co.baseUrl) : dirname(cfgPath);
|
|
43
|
+
for (const [pattern, targets] of Object.entries(co.paths)) if (Array.isArray(targets)) toRule(pattern, targets, base);
|
|
44
|
+
}
|
|
38
45
|
const ext = typeof cfg.extends === 'string' && cfg.extends.startsWith('.') ? cfg.extends : null;
|
|
39
|
-
|
|
46
|
+
if (ext) queue.push(resolve(dirname(cfgPath), ext.endsWith('.json') ? ext : ext + '.json'));
|
|
47
|
+
for (const ref of Array.isArray(cfg.references) ? cfg.references : []) {
|
|
48
|
+
if (typeof ref?.path !== 'string') continue;
|
|
49
|
+
const p = resolve(dirname(cfgPath), ref.path);
|
|
50
|
+
queue.push(p.endsWith('.json') ? p : join(p, 'tsconfig.json'));
|
|
51
|
+
}
|
|
40
52
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
|
|
54
|
+
// vite / vitest config: `resolve.alias` as `{ '@': path.resolve(__dirname, './src') }`,
|
|
55
|
+
// `{ '@': fileURLToPath(new URL('./src', import.meta.url)) }`, `{ '@': './src' }`
|
|
56
|
+
// or `[{ find: '@', replacement: … }]`. Read as text: the config may be TypeScript.
|
|
57
|
+
for (const f of ['vitest.config.ts', 'vitest.config.mts', 'vitest.config.js', 'vitest.config.mjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
|
|
58
|
+
const path = join(projectDir, f);
|
|
59
|
+
if (!existsSync(path)) continue;
|
|
60
|
+
const text = readFileSync(path, 'utf8');
|
|
61
|
+
const start = /\balias\s*:\s*([\[{])/.exec(text);
|
|
62
|
+
if (!start) continue;
|
|
63
|
+
// Take the balanced bracket block that follows `alias:`.
|
|
64
|
+
const open = start.index + start[0].length - 1;
|
|
65
|
+
const pairs = { '{': '}', '[': ']' };
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let end = -1;
|
|
68
|
+
for (let i = open; i < text.length; i++) {
|
|
69
|
+
if (text[i] === '{' || text[i] === '[' || text[i] === '(') depth++;
|
|
70
|
+
else if (text[i] === '}' || text[i] === ']' || text[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
|
71
|
+
}
|
|
72
|
+
if (end === -1) continue;
|
|
73
|
+
const block = text.slice(open, end + 1);
|
|
74
|
+
const PATHLIKE = /(['"])(\.{1,2}\/[^'"]*|src(?:\/[^'"]*)?)\1/;
|
|
75
|
+
let entries;
|
|
76
|
+
if (start[1] === '[') {
|
|
77
|
+
entries = [...block.matchAll(/find\s*:\s*(['"])([^'"]+)\1([\s\S]*?)(?=find\s*:|\]$)/g)].map((m) => [m[2], m[3]]);
|
|
78
|
+
} else {
|
|
79
|
+
// key: <value up to the next top-level key>; the value may contain calls with their own commas
|
|
80
|
+
const keys = [...block.matchAll(/(['"]?)([@#~$\w./-]+)\1\s*:(?!\/)/g)];
|
|
81
|
+
entries = keys.map((k, i) => [k[2], block.slice(k.index + k[0].length, keys[i + 1]?.index ?? block.length)]);
|
|
82
|
+
}
|
|
83
|
+
for (const [key, value] of entries) {
|
|
84
|
+
const target = PATHLIKE.exec(value)?.[2];
|
|
85
|
+
if (!key || !target) continue;
|
|
86
|
+
const abs = resolve(projectDir, target);
|
|
87
|
+
rules.push({ prefix: key, suffix: '', wildcard: false, targets: [abs] });
|
|
88
|
+
rules.push({ prefix: key.endsWith('/') ? key : key + '/', suffix: '', wildcard: true, targets: [join(abs, '*')] });
|
|
89
|
+
}
|
|
44
90
|
}
|
|
45
91
|
|
|
46
92
|
const pkgPath = join(projectDir, 'package.json');
|
|
@@ -94,6 +140,14 @@ function resolvesTo(fromFile, specifier, targetAbs, aliases) {
|
|
|
94
140
|
return bases.some((b) => expandBase(b).includes(targetAbs));
|
|
95
141
|
}
|
|
96
142
|
|
|
143
|
+
/** Does `specifier`, as written in `absFile`, resolve to `targetRel` (relative or alias-resolved)? */
|
|
144
|
+
export function specifierResolvesTo(projectDir, absFile, specifier, targetRel) {
|
|
145
|
+
return resolvesTo(absFile, specifier, resolve(projectDir, targetRel), loadAliases(projectDir));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Clear the alias cache (tests create and delete projects at the same paths). */
|
|
149
|
+
export const resetAliasCache = () => aliasCache.clear();
|
|
150
|
+
|
|
97
151
|
/** Does the file at `absFile` import `targetRel` (relative or alias-resolved)? */
|
|
98
152
|
export function fileImports(projectDir, absFile, targetRel) {
|
|
99
153
|
const targetAbs = resolve(projectDir, targetRel);
|
|
@@ -136,9 +190,13 @@ export function blastRadius(projectDir, targetRel) {
|
|
|
136
190
|
return count;
|
|
137
191
|
}
|
|
138
192
|
|
|
193
|
+
// A kill whose defender was written in the same change as the code carries
|
|
194
|
+
// less independent evidence. Ranking may read the signal; verdicts never do.
|
|
195
|
+
const INDEPENDENCE_WEIGHT = { 'separate-change': 1, unknown: 0.95, 'co-authored': 0.85 };
|
|
196
|
+
|
|
139
197
|
/** Additive ordering only. Never touches the verdict. */
|
|
140
|
-
export function rank({ severity, sourceKind, blast }) {
|
|
141
|
-
const score = SEVERITY_WEIGHT[severity] * (SOURCE_WEIGHT[sourceKind] ?? 0.9) * (1 + Math.log2(1 + blast));
|
|
198
|
+
export function rank({ severity, sourceKind, blast, independence }) {
|
|
199
|
+
const score = SEVERITY_WEIGHT[severity] * (SOURCE_WEIGHT[sourceKind] ?? 0.9) * (1 + Math.log2(1 + blast)) * (INDEPENDENCE_WEIGHT[independence] ?? 1);
|
|
142
200
|
return { score: Number(score.toFixed(3)), blastRadius: blast, tier: severity };
|
|
143
201
|
}
|
|
144
202
|
|
|
@@ -1,20 +1,64 @@
|
|
|
1
1
|
import * as vitest from './vitest.mjs';
|
|
2
2
|
import * as jest from './jest.mjs';
|
|
3
|
+
import * as playwright from './playwright.mjs';
|
|
3
4
|
|
|
4
|
-
export const RUNNERS = { vitest, jest };
|
|
5
|
+
export const RUNNERS = { vitest, jest, playwright };
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Runners that own files rather than projects: a defender under Playwright's
|
|
9
|
+
* testDir runs under Playwright whatever the project runner is. `auto` never
|
|
10
|
+
* picks one of these for the whole project; `--runner playwright` does.
|
|
11
|
+
*/
|
|
12
|
+
export const OWNED_RUNNERS = [playwright];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pick the project runner: an explicit name, or the first of vitest, jest that
|
|
16
|
+
* is resolvable from the project. Returns { runner, version } or { error }.
|
|
9
17
|
*/
|
|
10
18
|
export async function selectRunner({ projectDir, name = 'auto' }) {
|
|
11
19
|
const candidates = name === 'auto' ? [vitest, jest] : [RUNNERS[name]];
|
|
12
|
-
if (!candidates[0]) return { error: `unknown runner "${name}"; use vitest, jest or auto` };
|
|
20
|
+
if (!candidates[0]) return { error: `unknown runner "${name}"; use vitest, jest, playwright or auto` };
|
|
13
21
|
const messages = [];
|
|
14
22
|
for (const r of candidates) {
|
|
15
23
|
const c = await r.check({ projectDir });
|
|
16
|
-
if (c.ok) return { runner: r, version: c.version };
|
|
24
|
+
if (c.ok) return { runner: r, version: c.version, source: c.source };
|
|
17
25
|
messages.push(`${r.name}: ${c.message}`);
|
|
18
26
|
}
|
|
19
27
|
return { error: messages.join('; ') };
|
|
20
28
|
}
|
|
29
|
+
|
|
30
|
+
/** The runner a test file runs under: an owning runner if one claims it, else the project runner. */
|
|
31
|
+
export function runnerFor(projectDir, file, primary) {
|
|
32
|
+
if (primary.owns?.(projectDir, file)) return primary;
|
|
33
|
+
return OWNED_RUNNERS.find((r) => r !== primary && r.owns(projectDir, file)) ?? primary;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Group files by the runner they run under, in a stable order (primary first). */
|
|
37
|
+
export function partitionByRunner(projectDir, files, primary) {
|
|
38
|
+
const groups = new Map([[primary, []]]);
|
|
39
|
+
for (const f of files) {
|
|
40
|
+
const r = runnerFor(projectDir, f, primary);
|
|
41
|
+
if (!groups.has(r)) groups.set(r, []);
|
|
42
|
+
groups.get(r).push(f);
|
|
43
|
+
}
|
|
44
|
+
if (groups.get(primary).length === 0 && groups.size > 1) groups.delete(primary);
|
|
45
|
+
return groups;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Merge the runs of several runners into one testRun, pessimistically: any
|
|
50
|
+
* load error is an error, any timeout is a timeout, any failure is a failure.
|
|
51
|
+
* Counts add; failed test ids concatenate.
|
|
52
|
+
*/
|
|
53
|
+
export function mergeRuns(parts) {
|
|
54
|
+
if (parts.length === 1) return parts[0];
|
|
55
|
+
const runs = parts.map((p) => p.run);
|
|
56
|
+
const outcome = runs.some((r) => r.outcome === 'error') ? 'error' : runs.some((r) => r.outcome === 'timeout') ? 'timeout' : runs.some((r) => r.outcome === 'fail') ? 'fail' : 'pass';
|
|
57
|
+
const sum = (k) => runs.reduce((n, r) => n + (r.tests[k] ?? 0), 0);
|
|
58
|
+
return {
|
|
59
|
+
run: { outcome, tests: { total: sum('total'), passed: sum('passed'), failed: sum('failed') }, assertionFailures: runs.reduce((n, r) => n + r.assertionFailures, 0), durationMs: runs.reduce((n, r) => n + r.durationMs, 0) },
|
|
60
|
+
timeouts: parts.reduce((n, p) => n + (p.timeouts ?? 0), 0),
|
|
61
|
+
loadMessage: parts.find((p) => p.loadMessage)?.loadMessage,
|
|
62
|
+
failedTests: parts.flatMap((p) => p.failedTests ?? []),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { TEST_GLOBS,
|
|
1
|
+
import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
|
|
2
2
|
|
|
3
3
|
export const name = 'jest';
|
|
4
4
|
// jest's default testMatch also collects anything under __tests__/.
|
|
5
5
|
export const testGlobs = [...TEST_GLOBS, '**/__tests__/**/*.js', '**/__tests__/**/*.mjs', '**/__tests__/**/*.cjs', '**/__tests__/**/*.ts', '**/__tests__/**/*.tsx', '**/__tests__/**/*.jsx'];
|
|
6
|
-
export const check = (opts) =>
|
|
6
|
+
export const check = (opts) => checkRunner({ ...opts, pkg: 'jest', bin: 'jest' });
|
|
7
7
|
export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
|
|
8
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) => [
|
|
9
|
+
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [...runnerArgv(opts.projectDir, 'jest', 'jest'), '--ci', '--json', `--outputFile=${outFile}`, ...(opts.serial ? ['--runInBand'] : []), '--runTestsByPath', ...files] });
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { npx, runProcess, listTestFiles, firstInformativeLine } from './shared.mjs';
|
|
5
|
+
import { globToRegExp } from '../../util/glob.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Playwright runner. Selected **per file**, not per project: a defender that
|
|
9
|
+
* lives under Playwright's `testDir` runs here, everything else runs under
|
|
10
|
+
* the project runner (vitest/jest). One claim may list both kinds. Selected
|
|
11
|
+
* for the whole project only with `--runner playwright`.
|
|
12
|
+
*
|
|
13
|
+
* Report protocol: `--reporter=json` with the JSON written to a file we name
|
|
14
|
+
* (PLAYWRIGHT_JSON_OUTPUT_FILE; the older PLAYWRIGHT_JSON_OUTPUT_NAME is set
|
|
15
|
+
* too), never parsed from stdout. Playwright's test statuses map onto the
|
|
16
|
+
* spec's testRun as follows, and the mapping is the point of the adapter:
|
|
17
|
+
*
|
|
18
|
+
* expected → passed
|
|
19
|
+
* unexpected/failed → failed, counts toward assertionFailures (can kill)
|
|
20
|
+
* unexpected/timedOut → failed, counts toward timeouts (never a kill)
|
|
21
|
+
* flaky → failed on an attempt and passed on retry: the run
|
|
22
|
+
* is NOT green. Counted as failed with neither an
|
|
23
|
+
* assertion failure nor a timeout, so a green-looking
|
|
24
|
+
* exit code becomes FLAKY-DEFENDER through the
|
|
25
|
+
* ordinary N-run rules, exactly as GATE-SEMANTICS says.
|
|
26
|
+
* skipped → neither passed nor failed
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const name = 'playwright';
|
|
30
|
+
|
|
31
|
+
const CONFIG_FILES = ['playwright.config.ts', 'playwright.config.mts', 'playwright.config.js', 'playwright.config.mjs', 'playwright.config.cjs'];
|
|
32
|
+
const SPEC_GLOBS = ['**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx',
|
|
33
|
+
'**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx'];
|
|
34
|
+
|
|
35
|
+
const configCache = new Map();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The project's Playwright config, read as text (it may be TypeScript, so it
|
|
39
|
+
* is never imported): `{ path, testDir }` or null when there is none.
|
|
40
|
+
* `testDir` defaults to the config's own directory, as Playwright does.
|
|
41
|
+
*/
|
|
42
|
+
export function loadConfig(projectDir) {
|
|
43
|
+
if (configCache.has(projectDir)) return configCache.get(projectDir);
|
|
44
|
+
let out = null;
|
|
45
|
+
for (const f of CONFIG_FILES) {
|
|
46
|
+
const path = join(projectDir, f);
|
|
47
|
+
if (!existsSync(path)) continue;
|
|
48
|
+
const text = readFileSync(path, 'utf8');
|
|
49
|
+
const m = /\btestDir\s*:\s*(['"`])([^'"`]+)\1/.exec(text);
|
|
50
|
+
const testDir = relative(projectDir, resolve(projectDir, m ? m[2] : '.')).split(sep).join('/') || '.';
|
|
51
|
+
out = { path: f, testDir };
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
configCache.set(projectDir, out);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Clear the config cache (tests create and delete projects at the same paths). */
|
|
59
|
+
export const resetConfigCache = () => configCache.clear();
|
|
60
|
+
|
|
61
|
+
export const testGlobs = SPEC_GLOBS;
|
|
62
|
+
const SPEC_RES = SPEC_GLOBS.map(globToRegExp);
|
|
63
|
+
|
|
64
|
+
/** Does this test file belong to Playwright: is there a config, and does the file live under its testDir? */
|
|
65
|
+
export function owns(projectDir, file) {
|
|
66
|
+
const cfg = loadConfig(projectDir);
|
|
67
|
+
if (!cfg) return false;
|
|
68
|
+
if (!SPEC_RES.some((re) => re.test(file))) return false;
|
|
69
|
+
return cfg.testDir === '.' ? true : file.startsWith(cfg.testDir + '/');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolvable = a config exists AND `@playwright/test` resolves from the
|
|
74
|
+
* project. Deliberately not `npx --no-install playwright --version`: the npx
|
|
75
|
+
* cache and a Python `playwright` on PATH both answer that from a project
|
|
76
|
+
* that has no Playwright at all, and `playwright test` would then run
|
|
77
|
+
* against nothing. The package `playwright test` needs is the precondition.
|
|
78
|
+
*/
|
|
79
|
+
export async function check({ projectDir }) {
|
|
80
|
+
const cfg = loadConfig(projectDir);
|
|
81
|
+
if (!cfg) return { ok: false, message: 'no playwright.config.* in the project' };
|
|
82
|
+
try {
|
|
83
|
+
const pkg = JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve('@playwright/test/package.json'), 'utf8'));
|
|
84
|
+
return { ok: true, version: pkg.version };
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, message: '@playwright/test is not installed in the project (npm i -D @playwright/test)' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const tests = (projectDir) => listTestFiles(projectDir, SPEC_GLOBS).filter((f) => owns(projectDir, f));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Playwright's JSON report → the spec's testRun. Pure; exported for the
|
|
94
|
+
* sample-driven tests. `durationMs` is the wall clock measured by the runner.
|
|
95
|
+
*/
|
|
96
|
+
export function parseReport(report, durationMs) {
|
|
97
|
+
const tests = [];
|
|
98
|
+
const walk = (suite) => {
|
|
99
|
+
for (const spec of suite.specs ?? []) {
|
|
100
|
+
for (const t of spec.tests ?? []) {
|
|
101
|
+
const results = t.results ?? [];
|
|
102
|
+
const last = results[results.length - 1];
|
|
103
|
+
tests.push({ id: `${suite.file ?? spec.file ?? ''}::${spec.title}`, status: t.status, finalResult: last?.status, error: results.find((r) => r.error)?.error?.message });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const s of suite.suites ?? []) walk(s);
|
|
107
|
+
};
|
|
108
|
+
for (const s of report.suites ?? []) walk(s);
|
|
109
|
+
|
|
110
|
+
const expected = tests.filter((t) => t.status === 'expected').length;
|
|
111
|
+
const unexpected = tests.filter((t) => t.status === 'unexpected');
|
|
112
|
+
const flaky = tests.filter((t) => t.status === 'flaky').length;
|
|
113
|
+
const timeouts = unexpected.filter((t) => t.finalResult === 'timedOut' || t.finalResult === 'interrupted').length;
|
|
114
|
+
const assertionFailures = unexpected.length - timeouts;
|
|
115
|
+
const failedTests = [...unexpected, ...tests.filter((t) => t.status === 'flaky')].map((t) => t.id);
|
|
116
|
+
const total = tests.length;
|
|
117
|
+
const summary = { total, passed: expected, failed: unexpected.length + flaky };
|
|
118
|
+
|
|
119
|
+
const globalErrors = report.errors ?? [];
|
|
120
|
+
let outcome;
|
|
121
|
+
if (total === 0) outcome = 'error';
|
|
122
|
+
else if (unexpected.length > 0 || flaky > 0) outcome = 'fail';
|
|
123
|
+
else outcome = 'pass';
|
|
124
|
+
const loadMessage = outcome === 'error'
|
|
125
|
+
? (globalErrors.length ? firstInformativeLine(globalErrors.map((e) => e.message ?? '').join('\n')) : 'playwright found no tests')
|
|
126
|
+
: undefined;
|
|
127
|
+
return { run: { outcome, tests: summary, assertionFailures, durationMs }, timeouts, loadMessage, failedTests, flaky };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const run = (opts) => {
|
|
131
|
+
const env = { PLAYWRIGHT_JSON_OUTPUT_FILE: '{out}', PLAYWRIGHT_JSON_OUTPUT_NAME: '{out}' };
|
|
132
|
+
return runProcess({ ...opts, env, parse: parseReport, argv: (files) => [npx, 'playwright', 'test', '--reporter=json', ...(opts.serial ? ['--workers=1'] : []), ...files] });
|
|
133
|
+
};
|