ucn 5.5.3 → 5.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/.claude/skills/ucn/SKILL.md +35 -12
- package/.claude/skills/ucn/references/commands.md +18 -14
- package/.claude/skills/ucn/references/resolution-rules.md +4 -0
- package/.claude/skills/ucn/references/trust-contract.md +4 -4
- package/CHANGELOG.md +474 -0
- package/README.md +69 -29
- package/cli/flags.js +184 -0
- package/cli/index.js +137 -324
- package/core/account.js +44 -87
- package/core/analysis.js +273 -107
- package/core/ast-analysis.js +2 -0
- package/core/bridge.js +14 -0
- package/core/cache.js +101 -16
- package/core/callers.js +352 -70
- package/core/capture.js +331 -0
- package/core/change-kinds.js +888 -0
- package/core/check.js +1117 -139
- package/core/command-contracts.js +7 -6
- package/core/constructed-class.js +1195 -0
- package/core/contract-membership.js +142 -0
- package/core/deadcode.js +362 -84
- package/core/deletion.js +401 -0
- package/core/discovery.js +104 -9
- package/core/entrypoints.js +12 -6
- package/core/evidence-bands.js +68 -0
- package/core/evidence-bands.json +44 -0
- package/core/execute.js +172 -161
- package/core/hot-summary.js +241 -0
- package/core/index-ir.js +3 -1
- package/core/ir.js +5 -1
- package/core/member-signatures.js +789 -0
- package/core/output/analysis-ext.js +20 -10
- package/core/output/analysis.js +98 -60
- package/core/output/check.js +98 -37
- package/core/output/doctor.js +34 -1
- package/core/output/find.js +2 -1
- package/core/output/lines.js +63 -3
- package/core/output/public.js +29 -11
- package/core/output/refactoring.js +32 -0
- package/core/output/reporting.js +15 -2
- package/core/output/search.js +11 -2
- package/core/output/tracing.js +35 -7
- package/core/output-budget.js +124 -57
- package/core/parallel-build.js +3 -3
- package/core/project.js +106 -23
- package/core/provenance-type.js +12 -5
- package/core/public-command.js +35 -9
- package/core/python-modules.js +6 -0
- package/core/reflection.js +151 -23
- package/core/registry.js +623 -72
- package/core/reporting.js +69 -34
- package/core/review-items.js +75 -0
- package/core/search.js +89 -128
- package/core/shared.js +0 -45
- package/core/snapshot.js +350 -0
- package/core/source-universe.js +707 -0
- package/core/test-units.js +3624 -0
- package/core/tracing.js +150 -96
- package/core/verify.js +27 -11
- package/languages/adapter.js +8 -3
- package/languages/c-family.js +12 -0
- package/languages/csharp.js +634 -0
- package/languages/go.js +120 -0
- package/languages/index.js +422 -4
- package/languages/java.js +712 -0
- package/languages/javascript.js +1460 -2
- package/languages/lexical-scope.js +68 -0
- package/languages/python.js +1672 -1
- package/languages/rust.js +91 -0
- package/mcp/server.js +44 -110
- package/package.json +10 -5
|
@@ -0,0 +1,3624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/test-units.js — what a test is, where it is, and how to run it.
|
|
3
|
+
*
|
|
4
|
+
* One identity for a test, shared by `tests`, the affected-test selection
|
|
5
|
+
* and `check`: a UNIT is (file, suite chain, name, line), read from the
|
|
6
|
+
* declaration the language's runner collects (the language module's
|
|
7
|
+
* `testUnits`). A line of test code belongs to the innermost unit whose
|
|
8
|
+
* source range holds it; code outside every unit belongs to the file.
|
|
9
|
+
*
|
|
10
|
+
* A RUNNER is named only on evidence: the test file imports the runner's
|
|
11
|
+
* own module, or the nearest build manifest names it. A SELECTOR (the
|
|
12
|
+
* command that runs one unit, or one file) is built only for a runner whose
|
|
13
|
+
* selection syntax is modeled here and only when everything the command
|
|
14
|
+
* depends on is known; otherwise the unit is reported without a command and
|
|
15
|
+
* with the reason. The runner's own configuration is part of that: what
|
|
16
|
+
* is data is read (pytest's options and naming rules, jest's roots and
|
|
17
|
+
* path patterns, vitest's directory, the options a mocha or node script
|
|
18
|
+
* passes), a configuration written as code only where it is one object
|
|
19
|
+
* written out that nothing else in the file can change, and a
|
|
20
|
+
* configuration that can change what a command selects and is not read
|
|
21
|
+
* withholds the command. Nothing here executes anything.
|
|
22
|
+
*
|
|
23
|
+
* A COUNT of tests is of the units listed and not skipped. It is exact
|
|
24
|
+
* only where every test the runner would report is listed: a file that
|
|
25
|
+
* holds tests the reader does not list, or a declaration that stands for
|
|
26
|
+
* several of the runner's tests, makes it a lower bound (`moreTests`),
|
|
27
|
+
* said wherever the count is printed.
|
|
28
|
+
*
|
|
29
|
+
* A test-path file is SUPPORT when it declares no unit, declares no class
|
|
30
|
+
* that could inherit one, and is not named the way the runner names test
|
|
31
|
+
* files: helpers, fixtures, shared setup. It is listed, never counted as a
|
|
32
|
+
* test and never offered as something to run.
|
|
33
|
+
*
|
|
34
|
+
* State lives in the model object, created per operation: manifests can
|
|
35
|
+
* change between two questions asked of one long-lived index.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
const { langTraits, getParser, safeParse, getLanguageAdapter } = require('../languages');
|
|
43
|
+
const { codeUnitCompare, escapeRegExp } = require('./shared');
|
|
44
|
+
const { createSignatures } = require('./member-signatures');
|
|
45
|
+
const { csharpUsings } = require('./type-denotation');
|
|
46
|
+
|
|
47
|
+
const CONTAINER_KINDS = new Set(['class', 'struct', 'module', 'namespace', 'interface', 'impl',
|
|
48
|
+
'record', 'enum', 'trait', 'object']);
|
|
49
|
+
const CALLABLE_KINDS = new Set(['function', 'method', 'static', 'constructor', 'classmethod',
|
|
50
|
+
'getter', 'setter', 'arrow', 'generator']);
|
|
51
|
+
|
|
52
|
+
/** A shell word: bare when it needs no quoting, single-quoted otherwise. */
|
|
53
|
+
function shellWord(value) {
|
|
54
|
+
const text = String(value);
|
|
55
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
|
|
56
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function regexLiteral(text) {
|
|
60
|
+
return String(text).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function posixDir(relativePath) {
|
|
64
|
+
const dir = path.posix.dirname(String(relativePath).replace(/\\/g, '/'));
|
|
65
|
+
return dir === '.' ? '' : dir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function relativeTo(dir, relativePath) {
|
|
69
|
+
const rel = String(relativePath).replace(/\\/g, '/');
|
|
70
|
+
if (!dir) return rel;
|
|
71
|
+
return rel.startsWith(dir + '/') ? rel.slice(dir.length + 1) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Directories from the file's own up to the project root, nearest first. */
|
|
75
|
+
function ancestorDirs(relativePath) {
|
|
76
|
+
const out = [];
|
|
77
|
+
let dir = posixDir(relativePath);
|
|
78
|
+
for (;;) {
|
|
79
|
+
out.push(dir);
|
|
80
|
+
if (!dir) break;
|
|
81
|
+
dir = posixDir(dir);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Manifests
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
function createManifests(index) {
|
|
91
|
+
const texts = new Map();
|
|
92
|
+
const listings = new Map();
|
|
93
|
+
const read = (dir, name) => {
|
|
94
|
+
const key = `${dir}\0${name}`;
|
|
95
|
+
if (texts.has(key)) return texts.get(key);
|
|
96
|
+
let text;
|
|
97
|
+
try {
|
|
98
|
+
text = fs.readFileSync(path.join(index.root, dir, name), 'utf8');
|
|
99
|
+
} catch (e) {
|
|
100
|
+
text = null;
|
|
101
|
+
}
|
|
102
|
+
texts.set(key, text);
|
|
103
|
+
return text;
|
|
104
|
+
};
|
|
105
|
+
const list = dir => {
|
|
106
|
+
if (listings.has(dir)) return listings.get(dir);
|
|
107
|
+
let names;
|
|
108
|
+
try {
|
|
109
|
+
names = fs.readdirSync(path.join(index.root, dir)).sort(codeUnitCompare);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
names = [];
|
|
112
|
+
}
|
|
113
|
+
listings.set(dir, names);
|
|
114
|
+
return names;
|
|
115
|
+
};
|
|
116
|
+
const json = (dir, name) => {
|
|
117
|
+
const text = read(dir, name);
|
|
118
|
+
if (text == null) return null;
|
|
119
|
+
try {
|
|
120
|
+
const value = JSON.parse(text);
|
|
121
|
+
return value && typeof value === 'object' ? value : null;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
return { read, list, json };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The table `[name]` of a TOML or INI text, up to the next table header. */
|
|
130
|
+
function tableText(text, header) {
|
|
131
|
+
const lines = String(text).split('\n');
|
|
132
|
+
const out = [];
|
|
133
|
+
let inside = false;
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const head = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:[#;].*)?$/.exec(line);
|
|
136
|
+
if (head) {
|
|
137
|
+
inside = head[1] === header;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (inside) out.push(line);
|
|
141
|
+
}
|
|
142
|
+
return inside || out.length > 0 ? out.join('\n') : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasTable(text, header) {
|
|
146
|
+
if (text == null) return false;
|
|
147
|
+
return String(text).split('\n').some(line => {
|
|
148
|
+
const head = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:[#;].*)?$/.exec(line);
|
|
149
|
+
return !!head && head[1] === header;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The values of `key` in a pytest configuration table: a TOML array or
|
|
155
|
+
* string, or INI words continued on indented lines. Null when not set.
|
|
156
|
+
*/
|
|
157
|
+
function configWords(table, key) {
|
|
158
|
+
if (table == null) return null;
|
|
159
|
+
const lines = table.split('\n');
|
|
160
|
+
for (let i = 0; i < lines.length; i++) {
|
|
161
|
+
const match = new RegExp(`^${key}\\s*[=:]\\s*(.*)$`).exec(lines[i]);
|
|
162
|
+
if (!match) continue;
|
|
163
|
+
let value = match[1];
|
|
164
|
+
if (value.trimStart().startsWith('[')) {
|
|
165
|
+
// A TOML array, possibly over several lines.
|
|
166
|
+
let j = i;
|
|
167
|
+
while (!value.includes(']') && j + 1 < lines.length) value += '\n' + lines[++j];
|
|
168
|
+
} else {
|
|
169
|
+
for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j++) value += '\n' + lines[j];
|
|
170
|
+
}
|
|
171
|
+
const quoted = [...value.matchAll(/"([^"]*)"|'([^']*)'/g)].map(m => m[1] != null ? m[1] : m[2]);
|
|
172
|
+
const words = quoted.length > 0
|
|
173
|
+
? quoted.flatMap(word => word.split(/\s+/))
|
|
174
|
+
: value.replace(/[#;].*$/gm, '').split(/[\s,[\]]+/);
|
|
175
|
+
return words.filter(Boolean);
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Runners
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
// Package and command names of JavaScript runners; `true` marks the ones
|
|
185
|
+
// whose selection syntax is modeled.
|
|
186
|
+
const JS_RUNNER_PACKAGES = new Map([
|
|
187
|
+
['jest', 'jest'], ['vitest', 'vitest'], ['mocha', 'mocha'],
|
|
188
|
+
['ava', 'ava'], ['tap', 'tap'], ['jasmine', 'jasmine'], ['karma', 'karma'], ['uvu', 'uvu'],
|
|
189
|
+
['@playwright/test', 'playwright'], ['playwright', 'playwright'], ['cypress', 'cypress'],
|
|
190
|
+
['@japa/runner', 'japa'], ['tape', 'tape'], ['qunit', 'qunit'], ['lab', 'lab'], ['@hapi/lab', 'lab'],
|
|
191
|
+
['bun', 'bun'], ['deno', 'deno'], ['wdio', 'wdio'], ['@web/test-runner', 'wtr'], ['wtr', 'wtr'],
|
|
192
|
+
['react-scripts', 'react-scripts'], ['ng', 'angular'], ['nx', 'nx'], ['turbo', 'turbo'], ['lerna', 'lerna'],
|
|
193
|
+
]);
|
|
194
|
+
const MODELED_JS = new Set(['node-test', 'jest', 'vitest', 'mocha']);
|
|
195
|
+
/**
|
|
196
|
+
* The tests of a file that a focus (`.only`) keeps from running, by each
|
|
197
|
+
* runner's own rule (read from the runners and measured). `units` are the
|
|
198
|
+
* reader's, each with `parent` (the index of the suite it stands in, -1
|
|
199
|
+
* for the file) and `only` as written.
|
|
200
|
+
* - vitest: a test runs when it or a suite around it is marked.
|
|
201
|
+
* - jest: a suite passes its mark down to suites inside it, and to its own
|
|
202
|
+
* tests unless one of those is marked itself.
|
|
203
|
+
* - mocha: a suite with marked tests of its own runs those and nothing
|
|
204
|
+
* else of it; otherwise it runs its marked suites (narrowed further when
|
|
205
|
+
* they hold marks) and the unmarked ones that hold a mark.
|
|
206
|
+
* node:test honours the mark only under --test-only: nothing is left out.
|
|
207
|
+
* @returns {Set<object>} the units that do not run
|
|
208
|
+
*/
|
|
209
|
+
function focusedOut(units, runner) {
|
|
210
|
+
if (!units.some(unit => unit.only) || !['jest', 'vitest', 'mocha'].includes(runner)) return new Set();
|
|
211
|
+
const children = new Map();
|
|
212
|
+
units.forEach((unit, i) => {
|
|
213
|
+
const parent = unit.parent == null ? -1 : unit.parent;
|
|
214
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
215
|
+
children.get(parent).push(i);
|
|
216
|
+
});
|
|
217
|
+
const kids = i => children.get(i) || [];
|
|
218
|
+
const isSuite = i => units[i].kind === 'suite';
|
|
219
|
+
const runs = new Set();
|
|
220
|
+
if (runner === 'vitest') {
|
|
221
|
+
const visit = (parent, included) => {
|
|
222
|
+
for (const i of kids(parent)) {
|
|
223
|
+
const include = included || !!units[i].only;
|
|
224
|
+
if (isSuite(i)) visit(i, include);
|
|
225
|
+
else if (include) runs.add(i);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
visit(-1, false);
|
|
229
|
+
} else if (runner === 'jest') {
|
|
230
|
+
const modes = new Map();
|
|
231
|
+
const modeOf = i => {
|
|
232
|
+
if (i === -1) return null;
|
|
233
|
+
if (!modes.has(i)) modes.set(i, units[i].only ? 'only' : modeOf(units[i].parent == null ? -1 : units[i].parent));
|
|
234
|
+
return modes.get(i);
|
|
235
|
+
};
|
|
236
|
+
units.forEach((unit, i) => {
|
|
237
|
+
if (unit.kind !== 'test') return;
|
|
238
|
+
const parent = unit.parent == null ? -1 : unit.parent;
|
|
239
|
+
const narrowed = modeOf(parent) === 'only' && kids(parent).some(k => !isSuite(k) && units[k].only);
|
|
240
|
+
if (unit.only || (!narrowed && modeOf(parent) === 'only')) runs.add(i);
|
|
241
|
+
});
|
|
242
|
+
// No test ends up focused (an empty marked suite): nothing is left out.
|
|
243
|
+
if (runs.size === 0) return new Set();
|
|
244
|
+
} else {
|
|
245
|
+
const holdsMark = i => kids(i).some(k => units[k].only || (isSuite(k) && holdsMark(k)));
|
|
246
|
+
const all = i => {
|
|
247
|
+
for (const k of kids(i)) {
|
|
248
|
+
if (isSuite(k)) all(k);
|
|
249
|
+
else runs.add(k);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const narrow = i => {
|
|
253
|
+
const own = kids(i).filter(k => !isSuite(k) && units[k].only);
|
|
254
|
+
if (own.length > 0) {
|
|
255
|
+
for (const k of own) runs.add(k);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
for (const k of kids(i)) {
|
|
259
|
+
if (!isSuite(k)) continue;
|
|
260
|
+
if (units[k].only && !holdsMark(k)) all(k);
|
|
261
|
+
else if (units[k].only || holdsMark(k)) narrow(k);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
narrow(-1);
|
|
265
|
+
}
|
|
266
|
+
return new Set(units.filter((unit, i) => unit.kind === 'test' && !runs.has(i)));
|
|
267
|
+
}
|
|
268
|
+
// Node options that take the next word as their value.
|
|
269
|
+
const NODE_VALUE_OPTIONS = new Set(['--import', '--require', '-r', '--loader', '--experimental-loader',
|
|
270
|
+
'--conditions', '-C', '--env-file', '--test-reporter', '--test-reporter-destination',
|
|
271
|
+
'--test-concurrency', '--test-timeout', '--test-shard', '--test-name-pattern', '--test-skip-pattern',
|
|
272
|
+
'--test-coverage-include', '--test-coverage-exclude', '--watch-path', '--input-type', '--title']);
|
|
273
|
+
// Node options that decide how a test file loads; everything else the
|
|
274
|
+
// script passes (reporters, coverage, concurrency) is not needed to run one.
|
|
275
|
+
const NODE_LOADING_OPTIONS = /^(--import|--require|-r|--loader|--experimental-loader|--conditions|-C|--env-file|--experimental-[\w-]+|--no-experimental-[\w-]+|--enable-source-maps|--preserve-symlinks|--no-warnings)(=|$)/;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Runner commands named by a package.json script: the first word of each
|
|
279
|
+
* command of the script, and `--test` after `node`.
|
|
280
|
+
*/
|
|
281
|
+
function scriptRunners(script) {
|
|
282
|
+
const runners = [];
|
|
283
|
+
if (typeof script !== 'string') return runners;
|
|
284
|
+
for (const command of script.split(/&&|\|\||;|\|/)) {
|
|
285
|
+
const words = command.trim().split(/\s+/).filter(Boolean);
|
|
286
|
+
// Leading environment assignments and wrappers name no runner.
|
|
287
|
+
let i = 0;
|
|
288
|
+
while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) ||
|
|
289
|
+
['npx', 'cross-env', 'env', 'c8', 'nyc', 'dotenv', '--', 'exec', 'pnpm', 'yarn', 'npm', 'run', 'bunx', 'time'].includes(words[i]))) i++;
|
|
290
|
+
const head = words[i];
|
|
291
|
+
if (!head) continue;
|
|
292
|
+
const base = head.split('/').pop();
|
|
293
|
+
if ((base === 'node' || base === 'tsx' || base === 'ts-node') &&
|
|
294
|
+
words.slice(i + 1).some(word => word === '--test' || word.startsWith('--test='))) {
|
|
295
|
+
runners.push('node-test');
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (base === 'bun' && words[i + 1] === 'test') { runners.push('bun'); continue; }
|
|
299
|
+
if (base === 'deno' && words[i + 1] === 'test') { runners.push('deno'); continue; }
|
|
300
|
+
if (JS_RUNNER_PACKAGES.has(base)) runners.push(JS_RUNNER_PACKAGES.get(base));
|
|
301
|
+
else runners.push(`?${base}`);
|
|
302
|
+
}
|
|
303
|
+
return runners;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The `node` options of a script that is one `node ... --test ...` command,
|
|
308
|
+
* kept when they decide how a file loads. Null when the script is anything
|
|
309
|
+
* else: then nothing says how the project's tests are loaded.
|
|
310
|
+
*/
|
|
311
|
+
function nodeTestOptions(script) {
|
|
312
|
+
if (typeof script !== 'string' || /[&|;$`<>(){}*]/.test(script.replace(/\*/g, '')) && /[&|;$`<>(){}]/.test(script)) return null;
|
|
313
|
+
const words = script.trim().split(/\s+/).filter(Boolean);
|
|
314
|
+
if (words[0] !== 'node' || !words.includes('--test')) return null;
|
|
315
|
+
const kept = [];
|
|
316
|
+
for (let i = 1; i < words.length; i++) {
|
|
317
|
+
const word = words[i];
|
|
318
|
+
if (!word.startsWith('-')) continue;
|
|
319
|
+
const takesValue = NODE_VALUE_OPTIONS.has(word);
|
|
320
|
+
if (NODE_LOADING_OPTIONS.test(word)) {
|
|
321
|
+
kept.push(word);
|
|
322
|
+
if (takesValue && i + 1 < words.length) kept.push(words[i + 1]);
|
|
323
|
+
}
|
|
324
|
+
if (takesValue) i++;
|
|
325
|
+
}
|
|
326
|
+
return kept;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function jsRunner(model, fileEntry) {
|
|
330
|
+
// 1. The file's own imports.
|
|
331
|
+
const modules = langTraits(fileEntry.language)?.testRunnerModules?.() || new Map();
|
|
332
|
+
const imported = new Set();
|
|
333
|
+
for (const mod of fileEntry.imports || []) {
|
|
334
|
+
const runner = modules.get(mod);
|
|
335
|
+
if (runner) imported.add(runner);
|
|
336
|
+
}
|
|
337
|
+
if (imported.size > 1) return { id: null, reason: 'ambiguous-runner' };
|
|
338
|
+
let fromFile = imported.size === 1 ? [...imported][0] : null;
|
|
339
|
+
if (fromFile && !MODELED_JS.has(fromFile)) return { id: null, reason: 'runner-not-modeled', runner: fromFile };
|
|
340
|
+
|
|
341
|
+
// 2. The nearest package manifest.
|
|
342
|
+
let dir = null;
|
|
343
|
+
let manifest = null;
|
|
344
|
+
for (const candidate of ancestorDirs(fileEntry.relativePath)) {
|
|
345
|
+
manifest = model.manifests.json(candidate, 'package.json');
|
|
346
|
+
if (manifest) { dir = candidate; break; }
|
|
347
|
+
}
|
|
348
|
+
const script = manifest && manifest.scripts && typeof manifest.scripts.test === 'string'
|
|
349
|
+
? manifest.scripts.test : null;
|
|
350
|
+
if (fromFile) {
|
|
351
|
+
return { id: fromFile, dir: dir || '', evidence: 'test-file-import', script, scripts: manifest ? manifest.scripts : null };
|
|
352
|
+
}
|
|
353
|
+
if (!manifest) return { id: null, reason: 'no-runner-evidence' };
|
|
354
|
+
const named = [...new Set(scriptRunners(script).filter(name => !name.startsWith('?')))];
|
|
355
|
+
if (named.length === 1) {
|
|
356
|
+
if (!MODELED_JS.has(named[0])) return { id: null, reason: 'runner-not-modeled', runner: named[0] };
|
|
357
|
+
return { id: named[0], dir, evidence: 'package-script', script, scripts: manifest.scripts };
|
|
358
|
+
}
|
|
359
|
+
if (named.length > 1) return { id: null, reason: 'ambiguous-runner' };
|
|
360
|
+
const declared = new Set();
|
|
361
|
+
for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
362
|
+
for (const name of Object.keys(manifest[field] || {})) {
|
|
363
|
+
if (JS_RUNNER_PACKAGES.has(name)) declared.add(JS_RUNNER_PACKAGES.get(name));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (declared.size === 1) {
|
|
367
|
+
const only = [...declared][0];
|
|
368
|
+
if (!MODELED_JS.has(only)) return { id: null, reason: 'runner-not-modeled', runner: only };
|
|
369
|
+
return { id: only, dir, evidence: 'package-dependency', script, scripts: manifest.scripts };
|
|
370
|
+
}
|
|
371
|
+
return { id: null, reason: declared.size > 1 ? 'ambiguous-runner' : 'no-runner-evidence' };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** pytest's configuration file for a directory, by pytest's own order. */
|
|
375
|
+
function pytestConfigIn(model, dir) {
|
|
376
|
+
const ini = model.manifests.read(dir, 'pytest.ini');
|
|
377
|
+
if (ini != null) return { file: 'pytest.ini', table: tableText(ini, 'pytest') || '' };
|
|
378
|
+
const project = model.manifests.read(dir, 'pyproject.toml');
|
|
379
|
+
if (project != null) {
|
|
380
|
+
for (const header of ['tool.pytest.ini_options', 'tool.pytest']) {
|
|
381
|
+
if (hasTable(project, header)) return { file: 'pyproject.toml', table: tableText(project, header) || '' };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const tox = model.manifests.read(dir, 'tox.ini');
|
|
385
|
+
if (tox != null && hasTable(tox, 'pytest')) return { file: 'tox.ini', table: tableText(tox, 'pytest') || '' };
|
|
386
|
+
const setup = model.manifests.read(dir, 'setup.cfg');
|
|
387
|
+
if (setup != null && hasTable(setup, 'tool:pytest')) {
|
|
388
|
+
return { file: 'setup.cfg', table: tableText(setup, 'tool:pytest') || '' };
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Does a requirements file or a manifest name a package as a requirement:
|
|
395
|
+
* the name where a requirement starts (a line, a list item, a quoted
|
|
396
|
+
* string, a key), up to a version or the end. A word in a comment or in
|
|
397
|
+
* another package's name is not one.
|
|
398
|
+
*/
|
|
399
|
+
function namesRequirement(text, name) {
|
|
400
|
+
const shape = new RegExp(`(^|["'\\s,\\[])${name}(\\[[^\\]]*\\])?\\s*([<>=!~;,"'\\]]|$)`);
|
|
401
|
+
return String(text).split('\n').some(raw => shape.test(raw.replace(/(^|\s)[#;].*$/, '').trim()));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function pythonRunner(model, fileEntry) {
|
|
405
|
+
const dirs = ancestorDirs(fileEntry.relativePath);
|
|
406
|
+
for (const dir of dirs) {
|
|
407
|
+
const config = pytestConfigIn(model, dir);
|
|
408
|
+
if (config) {
|
|
409
|
+
return {
|
|
410
|
+
id: 'pytest', dir, evidence: config.file,
|
|
411
|
+
collection: {
|
|
412
|
+
files: configWords(config.table, 'python_files'),
|
|
413
|
+
classes: configWords(config.table, 'python_classes'),
|
|
414
|
+
functions: configWords(config.table, 'python_functions'),
|
|
415
|
+
options: pytestOptions(configArguments(config, 'addopts')),
|
|
416
|
+
// Fixtures every test is given, and whether a test a
|
|
417
|
+
// module imports is collected there too.
|
|
418
|
+
usefixtures: configWords(config.table, 'usefixtures') || [],
|
|
419
|
+
importedTests: !/^(false|no|off|0)$/i.test((configWords(config.table, 'collect_imported_tests') || [])[0] || ''),
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if ((fileEntry.imports || []).some(mod => mod === 'pytest' || String(mod).startsWith('pytest.'))) {
|
|
425
|
+
return { id: 'pytest', dir: '', evidence: 'test-file-import', collection: {} };
|
|
426
|
+
}
|
|
427
|
+
for (const dir of dirs) {
|
|
428
|
+
const conftest = dir ? `${dir}/conftest.py` : 'conftest.py';
|
|
429
|
+
if (model.hasFile(conftest)) return { id: 'pytest', dir: '', evidence: 'conftest.py', collection: {} };
|
|
430
|
+
}
|
|
431
|
+
for (const dir of dirs) {
|
|
432
|
+
for (const name of ['pyproject.toml', 'requirements.txt', 'setup.cfg', 'tox.ini']) {
|
|
433
|
+
const text = model.manifests.read(dir, name);
|
|
434
|
+
if (text != null && namesRequirement(text, 'pytest')) {
|
|
435
|
+
return { id: 'pytest', dir, evidence: name, collection: {} };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { id: null, reason: 'no-runner-evidence' };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function goRunner(model, fileEntry) {
|
|
443
|
+
for (const dir of ancestorDirs(fileEntry.relativePath)) {
|
|
444
|
+
if (model.manifests.read(dir, 'go.mod') != null) return { id: 'go-test', dir, evidence: 'go.mod' };
|
|
445
|
+
}
|
|
446
|
+
return { id: null, reason: 'no-runner-evidence' };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function rustRunner(model, fileEntry) {
|
|
450
|
+
for (const dir of ancestorDirs(fileEntry.relativePath)) {
|
|
451
|
+
const text = model.manifests.read(dir, 'Cargo.toml');
|
|
452
|
+
if (text == null) continue;
|
|
453
|
+
if (!hasTable(text, 'package')) return { id: null, reason: 'no-package-manifest' };
|
|
454
|
+
const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(tableText(text, 'package') || '');
|
|
455
|
+
// Targets the manifest lays out itself are not where cargo's
|
|
456
|
+
// conventions put them.
|
|
457
|
+
const customTargets = ['lib', 'bin', 'test'].some(header => {
|
|
458
|
+
const table = tableText(text, header);
|
|
459
|
+
return table != null && /^\s*(path|name|harness)\s*=/m.test(table);
|
|
460
|
+
});
|
|
461
|
+
return {
|
|
462
|
+
id: 'cargo-test', dir, evidence: 'Cargo.toml',
|
|
463
|
+
packageName: name ? name[1] : null,
|
|
464
|
+
customTargets,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return { id: null, reason: 'no-runner-evidence' };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function javaRunner(model, fileEntry) {
|
|
471
|
+
for (const dir of ancestorDirs(fileEntry.relativePath)) {
|
|
472
|
+
if (model.manifests.read(dir, 'pom.xml') != null) return { id: 'maven', dir, evidence: 'pom.xml' };
|
|
473
|
+
for (const name of ['build.gradle', 'build.gradle.kts']) {
|
|
474
|
+
if (model.manifests.read(dir, name) != null) return { id: null, reason: 'runner-not-modeled', runner: 'gradle' };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return { id: null, reason: 'no-runner-evidence' };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function dotnetRunner(model, fileEntry) {
|
|
481
|
+
for (const dir of ancestorDirs(fileEntry.relativePath)) {
|
|
482
|
+
const projects = model.manifests.list(dir).filter(name => name.endsWith('.csproj'));
|
|
483
|
+
if (projects.length === 0) continue;
|
|
484
|
+
if (projects.length > 1) return { id: null, reason: 'ambiguous-runner' };
|
|
485
|
+
const text = model.manifests.read(dir, projects[0]) || '';
|
|
486
|
+
if (!/Microsoft\.NET\.Test\.Sdk|xunit|nunit|MSTest/i.test(text)) return { id: null, reason: 'no-runner-evidence' };
|
|
487
|
+
return { id: 'dotnet-test', dir, evidence: projects[0] };
|
|
488
|
+
}
|
|
489
|
+
return { id: null, reason: 'no-runner-evidence' };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// What the runner's own configuration changes
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
/** Words of a shell-like option string: quotes group, a backslash escapes. */
|
|
497
|
+
function shellSplit(text) {
|
|
498
|
+
const words = [];
|
|
499
|
+
let current = null;
|
|
500
|
+
let quote = null;
|
|
501
|
+
for (let i = 0; i < text.length; i++) {
|
|
502
|
+
const ch = text[i];
|
|
503
|
+
if (quote) {
|
|
504
|
+
if (ch === quote) quote = null;
|
|
505
|
+
else if (ch === '\\' && quote === '"' && i + 1 < text.length) current += text[++i];
|
|
506
|
+
else current += ch;
|
|
507
|
+
} else if (ch === '"' || ch === "'") {
|
|
508
|
+
quote = ch;
|
|
509
|
+
if (current == null) current = '';
|
|
510
|
+
} else if (/\s/.test(ch)) {
|
|
511
|
+
if (current != null) words.push(current);
|
|
512
|
+
current = null;
|
|
513
|
+
} else if (ch === '\\' && i + 1 < text.length) {
|
|
514
|
+
current = (current == null ? '' : current) + text[++i];
|
|
515
|
+
} else {
|
|
516
|
+
current = (current == null ? '' : current) + ch;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (current != null) words.push(current);
|
|
520
|
+
return words;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** A TOML line without its comment. */
|
|
524
|
+
function withoutTomlComment(line) {
|
|
525
|
+
let quote = null;
|
|
526
|
+
for (let i = 0; i < line.length; i++) {
|
|
527
|
+
const ch = line[i];
|
|
528
|
+
if (quote) {
|
|
529
|
+
if (ch === '\\' && quote === '"') i++;
|
|
530
|
+
else if (ch === quote) quote = null;
|
|
531
|
+
} else if (ch === '"' || ch === "'") {
|
|
532
|
+
quote = ch;
|
|
533
|
+
} else if (ch === '#') {
|
|
534
|
+
return line.slice(0, i);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return line;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The arguments a pytest configuration gives for `key` (addopts): a TOML
|
|
542
|
+
* array holds one argument per string; a string, and an INI value with its
|
|
543
|
+
* continuation lines, is split the way a shell would. [] when the key is
|
|
544
|
+
* not set, null when its value is not read.
|
|
545
|
+
*/
|
|
546
|
+
function configArguments(config, key) {
|
|
547
|
+
if (!config || config.table == null) return [];
|
|
548
|
+
const lines = config.table.split('\n');
|
|
549
|
+
const toml = config.file === 'pyproject.toml';
|
|
550
|
+
for (let i = 0; i < lines.length; i++) {
|
|
551
|
+
const match = new RegExp(`^${key}\\s*[=:]\\s*(.*)$`).exec(lines[i]);
|
|
552
|
+
if (!match) continue;
|
|
553
|
+
if (!toml) {
|
|
554
|
+
let value = match[1];
|
|
555
|
+
for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j++) value += ' ' + lines[j].trim();
|
|
556
|
+
return shellSplit(value);
|
|
557
|
+
}
|
|
558
|
+
let value = withoutTomlComment(match[1]);
|
|
559
|
+
if (/"""|'''/.test(value)) return null;
|
|
560
|
+
const strings = text => [...text.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)]
|
|
561
|
+
.map(found => found[1] != null ? found[1].replace(/\\(.)/g, '$1') : found[2]);
|
|
562
|
+
if (value.trimStart().startsWith('[')) {
|
|
563
|
+
for (let j = i + 1; !/\]\s*$/.test(value) && j < lines.length; j++) value += '\n' + withoutTomlComment(lines[j]);
|
|
564
|
+
return /\]\s*$/.test(value) ? strings(value) : null;
|
|
565
|
+
}
|
|
566
|
+
const whole = strings(value);
|
|
567
|
+
return whole.length === 1 ? shellSplit(whole[0]) : null;
|
|
568
|
+
}
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// pytest options that change neither what is collected nor what a command
|
|
573
|
+
// that names a test runs. --ignore leaves a path out of a whole run only:
|
|
574
|
+
// a path given to the command is still collected (measured).
|
|
575
|
+
const PYTEST_INERT_FLAGS = new Set(['-v', '-vv', '-vvv', '--verbose', '-q', '-qq', '--quiet', '-s', '-l',
|
|
576
|
+
'--showlocals', '-x', '--exitfirst', '--strict-markers', '--strict-config', '--strict', '--no-header',
|
|
577
|
+
'--no-summary', '--disable-warnings', '--disable-pytest-warnings', '--ff', '--failed-first', '--nf',
|
|
578
|
+
'--new-first', '--cache-clear', '--full-trace', '--runxfail', '--no-cov-on-fail', '--cov-branch',
|
|
579
|
+
'--cov-append', '--no-cov', '--cov']);
|
|
580
|
+
const PYTEST_INERT_VALUED = new Set(['--tb', '--color', '--durations', '--durations-min', '--maxfail',
|
|
581
|
+
'--capture', '-W', '--pythonwarnings', '--cov', '--cov-report', '--cov-config', '--cov-fail-under',
|
|
582
|
+
'--cov-context', '--junitxml', '--junit-xml', '--junit-prefix', '--log-level', '--log-cli-level',
|
|
583
|
+
'--log-format', '--log-date-format', '--log-file', '--log-file-level', '--basetemp', '--import-mode',
|
|
584
|
+
'--timeout', '--timeout-method', '-n', '--numprocesses', '--dist', '--maxprocesses', '--reruns',
|
|
585
|
+
'--reruns-delay', '--show-capture', '--code-highlight', '--verbosity', '--asyncio-mode', '--ignore',
|
|
586
|
+
'--ignore-glob', '-r']);
|
|
587
|
+
// pytest's own plugins that decide what is collected, selected or skipped:
|
|
588
|
+
// switching one of these off changes which tests run. Any other plugin
|
|
589
|
+
// switched off (-p no:name) changes none of that; one switched ON is code
|
|
590
|
+
// that is not read.
|
|
591
|
+
const PYTEST_SELECTING_PLUGINS = new Set(['python', 'unittest', 'mark', 'skipping', 'main', 'runner', 'fixtures',
|
|
592
|
+
'stepwise', 'setuponly', 'setupplan', 'nose']);
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* What pytest's configured options do to selection: `markExpr` (-m) and
|
|
596
|
+
* `deselect` (--deselect) are applied to the units; `unread` says an
|
|
597
|
+
* option is there that may select, add or leave out tests and is not
|
|
598
|
+
* modeled (-k, a path, a plugin, an override): no command is exact then.
|
|
599
|
+
*/
|
|
600
|
+
function pytestOptions(words) {
|
|
601
|
+
const out = { markExpr: null, deselect: [], unread: false };
|
|
602
|
+
if (words == null) return { ...out, unread: true };
|
|
603
|
+
for (let i = 0; i < words.length; i++) {
|
|
604
|
+
const word = words[i];
|
|
605
|
+
const cut = word.startsWith('--') ? word.indexOf('=') : -1;
|
|
606
|
+
const name = cut > 0 ? word.slice(0, cut) : word;
|
|
607
|
+
const inline = cut > 0 ? word.slice(cut + 1) : null;
|
|
608
|
+
const value = () => (inline != null ? inline : words[++i]);
|
|
609
|
+
if (name === '-m') {
|
|
610
|
+
const expression = value();
|
|
611
|
+
if (expression == null) out.unread = true;
|
|
612
|
+
else out.markExpr = expression;
|
|
613
|
+
} else if (name === '--deselect') {
|
|
614
|
+
const id = value();
|
|
615
|
+
if (id) out.deselect.push(id);
|
|
616
|
+
else out.unread = true;
|
|
617
|
+
} else if (name === '-p') {
|
|
618
|
+
const plugin = String(value() || '');
|
|
619
|
+
if (!plugin.startsWith('no:') || PYTEST_SELECTING_PLUGINS.has(plugin.slice(3))) out.unread = true;
|
|
620
|
+
} else if (/^-r[A-Za-z]+$/.test(word) || /^-W\S+$/.test(word) || /^-n\S+$/.test(word)) {
|
|
621
|
+
// -ra, -Werror, -n4: the value is in the word.
|
|
622
|
+
} else if (inline != null ? PYTEST_INERT_VALUED.has(name) : PYTEST_INERT_FLAGS.has(name) && !PYTEST_INERT_VALUED.has(name)) {
|
|
623
|
+
// nothing
|
|
624
|
+
} else if (inline == null && name === '--cov') {
|
|
625
|
+
// --cov takes an optional value: a word after it that is no
|
|
626
|
+
// option would be read as a path otherwise.
|
|
627
|
+
if (i + 1 < words.length && !words[i + 1].startsWith('-')) i++;
|
|
628
|
+
} else if (inline == null && PYTEST_INERT_VALUED.has(name)) {
|
|
629
|
+
i++;
|
|
630
|
+
} else {
|
|
631
|
+
out.unread = true;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Does pytest's mark expression select a test with these marks: true,
|
|
639
|
+
* false, or null when it cannot be said (`open`: the test may carry marks
|
|
640
|
+
* that are not read; an expression in a form not modeled).
|
|
641
|
+
*/
|
|
642
|
+
function markSelects(expression, marks, open) {
|
|
643
|
+
const tokens = String(expression).match(/\(|\)|[^\s()]+/g) || [];
|
|
644
|
+
let at = 0;
|
|
645
|
+
const not = value => (value == null ? null : !value);
|
|
646
|
+
const primary = () => {
|
|
647
|
+
const token = tokens[at++];
|
|
648
|
+
if (token === 'not') return not(primary());
|
|
649
|
+
if (token === '(') {
|
|
650
|
+
const inner = either();
|
|
651
|
+
if (tokens[at++] !== ')') throw new Error('unbalanced');
|
|
652
|
+
return inner;
|
|
653
|
+
}
|
|
654
|
+
if (token == null || token === ')' || token === 'and' || token === 'or') throw new Error('unexpected');
|
|
655
|
+
// name(arg=value): a mark with arguments, not modeled.
|
|
656
|
+
if (tokens[at] === '(') throw new Error('arguments');
|
|
657
|
+
return marks.has(token) ? true : open ? null : false;
|
|
658
|
+
};
|
|
659
|
+
const both = () => {
|
|
660
|
+
let value = primary();
|
|
661
|
+
while (tokens[at] === 'and') {
|
|
662
|
+
at++;
|
|
663
|
+
const next = primary();
|
|
664
|
+
value = value === false || next === false ? false : value == null || next == null ? null : true;
|
|
665
|
+
}
|
|
666
|
+
return value;
|
|
667
|
+
};
|
|
668
|
+
const either = () => {
|
|
669
|
+
let value = both();
|
|
670
|
+
while (tokens[at] === 'or') {
|
|
671
|
+
at++;
|
|
672
|
+
const next = both();
|
|
673
|
+
value = value === true || next === true ? true : value == null || next == null ? null : false;
|
|
674
|
+
}
|
|
675
|
+
return value;
|
|
676
|
+
};
|
|
677
|
+
try {
|
|
678
|
+
const value = either();
|
|
679
|
+
return at === tokens.length ? value : null;
|
|
680
|
+
} catch (e) {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** A path glob as a regular expression: `**`, `*`, `?`, `{a,b}`, `[...]`. */
|
|
686
|
+
function pathGlob(glob) {
|
|
687
|
+
const text = String(glob).replace(/^\.\//, '');
|
|
688
|
+
let source = '';
|
|
689
|
+
for (let i = 0; i < text.length; i++) {
|
|
690
|
+
const ch = text[i];
|
|
691
|
+
if (ch === '*' && text[i + 1] === '*') {
|
|
692
|
+
i++;
|
|
693
|
+
if (text[i + 1] === '/') {
|
|
694
|
+
i++;
|
|
695
|
+
source += '(?:.*/)?';
|
|
696
|
+
} else {
|
|
697
|
+
source += '.*';
|
|
698
|
+
}
|
|
699
|
+
} else if (ch === '*') {
|
|
700
|
+
source += '[^/]*';
|
|
701
|
+
} else if (ch === '?') {
|
|
702
|
+
source += '[^/]';
|
|
703
|
+
} else if (ch === '{' || ch === '[') {
|
|
704
|
+
const end = text.indexOf(ch === '{' ? '}' : ']', i);
|
|
705
|
+
if (end < 0) return null;
|
|
706
|
+
source += ch === '[' ? text.slice(i, end + 1)
|
|
707
|
+
: `(?:${text.slice(i + 1, end).split(',').map(part => part.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|')})`;
|
|
708
|
+
i = end;
|
|
709
|
+
} else {
|
|
710
|
+
source += ch.replace(/[.+^$()|\\]/, '\\$&');
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
return new RegExp(`^${source}$`);
|
|
715
|
+
} catch (e) {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// The files `node --test` runs when it is given none (node's own list).
|
|
721
|
+
const NODE_DEFAULT_TEST_FILES = [/(^|\/)[^/]*[._-]test\.[cm]?js$/, /(^|\/)test-[^/]*\.[cm]?js$/,
|
|
722
|
+
/(^|\/)test\.[cm]?js$/, /(^|\/)test\/.*\.[cm]?js$/];
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The paths a script that is one `node ... --test ...` command names, as
|
|
726
|
+
* written (a glob stays a glob). Null when the script is anything else.
|
|
727
|
+
*/
|
|
728
|
+
function nodeTestPaths(script) {
|
|
729
|
+
if (nodeTestOptions(script) == null) return null;
|
|
730
|
+
const words = script.trim().split(/\s+/).filter(Boolean);
|
|
731
|
+
const paths = [];
|
|
732
|
+
for (let i = 1; i < words.length; i++) {
|
|
733
|
+
const word = words[i];
|
|
734
|
+
if (word.startsWith('-')) {
|
|
735
|
+
if (NODE_VALUE_OPTIONS.has(word)) i++;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
paths.push(word.replace(/^(['"])(.*)\1$/, '$2'));
|
|
739
|
+
}
|
|
740
|
+
return paths;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Does node run a file as a test under the project's own script: one of
|
|
745
|
+
* the paths the script names, or, when it names none, one node looks for
|
|
746
|
+
* by itself (every file under a `test` directory among them). node runs a
|
|
747
|
+
* file that declares no test as one test. Null: the script does not say.
|
|
748
|
+
*/
|
|
749
|
+
function nodeRunsFile(runner, file) {
|
|
750
|
+
const paths = nodeTestPaths(runner.script);
|
|
751
|
+
if (paths == null || file == null) return null;
|
|
752
|
+
if (paths.length === 0) return !/(^|\/)node_modules\//.test(file) && NODE_DEFAULT_TEST_FILES.some(pattern => pattern.test(file));
|
|
753
|
+
return paths.some(written => {
|
|
754
|
+
const target = written.replace(/^\.\//, '');
|
|
755
|
+
if (target === file) return true;
|
|
756
|
+
if (!/[*?{[]/.test(target)) return false;
|
|
757
|
+
const pattern = pathGlob(target);
|
|
758
|
+
return !!pattern && pattern.test(file);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The words a project's test script passes to a runner: those of the
|
|
764
|
+
* `test` script and of the scripts it names, after the runner's own name.
|
|
765
|
+
* [] when the script does not call the runner by name.
|
|
766
|
+
*/
|
|
767
|
+
function runnerScriptWords(runner, name) {
|
|
768
|
+
const scripts = runner.scripts && typeof runner.scripts === 'object' ? runner.scripts : {};
|
|
769
|
+
const texts = [];
|
|
770
|
+
if (typeof runner.script === 'string') {
|
|
771
|
+
texts.push(runner.script);
|
|
772
|
+
for (const word of runner.script.split(/[\s"'&|;]+/)) {
|
|
773
|
+
if (word !== 'test' && typeof scripts[word] === 'string') texts.push(scripts[word]);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
const out = [];
|
|
777
|
+
for (const text of texts) {
|
|
778
|
+
for (const segment of text.split(/&&|\|\||;|\|/)) {
|
|
779
|
+
const words = segment.trim().split(/\s+/).filter(Boolean);
|
|
780
|
+
const at = words.findIndex(word => word.split('/').pop() === name || word.split('/').pop() === `_${name}`);
|
|
781
|
+
if (at >= 0) out.push(...words.slice(at + 1));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Options of a runner's command line that point it at another
|
|
788
|
+
// configuration or select files: a script that passes one runs something
|
|
789
|
+
// a command built here does not.
|
|
790
|
+
const SCRIPT_CONFIG_FLAGS = /^(--config|-c|--rootDir|--roots|--projects|--selectProjects|--project|--workspace|--root|--dir|--testMatch|--testRegex|--testPathPattern|--testPathPatterns|--package|--opts)(=|$)/;
|
|
791
|
+
// Options of a mocha command line that decide how a test file loads.
|
|
792
|
+
const MOCHA_LOADING_OPTIONS = new Set(['--require', '-r', '--loader', '--import', '--ui', '-u', '--file', '--node-option', '-n']);
|
|
793
|
+
const MOCHA_VALUE_OPTIONS = new Set([...MOCHA_LOADING_OPTIONS, '--reporter', '-R', '--reporter-option', '--reporter-options',
|
|
794
|
+
'-O', '--timeout', '-t', '--timeouts', '--slow', '-s', '--retries', '--grep', '-g', '--fgrep', '-f', '--ignore',
|
|
795
|
+
'--exclude', '--spec', '--jobs', '-j', '--watch-files', '--watch-ignore', '--extension', '--config', '--package']);
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The options of the project's mocha script that a command for one file
|
|
799
|
+
* needs to load it the same way (--require, --ui, --loader, --file).
|
|
800
|
+
*/
|
|
801
|
+
function mochaLoadingOptions(runner) {
|
|
802
|
+
const words = runnerScriptWords(runner, 'mocha');
|
|
803
|
+
const kept = [];
|
|
804
|
+
for (let i = 0; i < words.length; i++) {
|
|
805
|
+
const word = words[i].replace(/^(['"])(.*)\1$/, '$2');
|
|
806
|
+
const cut = word.startsWith('--') ? word.indexOf('=') : -1;
|
|
807
|
+
const name = cut > 0 ? word.slice(0, cut) : word;
|
|
808
|
+
if (MOCHA_LOADING_OPTIONS.has(name)) {
|
|
809
|
+
kept.push(word);
|
|
810
|
+
if (cut < 0 && i + 1 < words.length) kept.push(words[++i].replace(/^(['"])(.*)\1$/, '$2'));
|
|
811
|
+
} else if (cut < 0 && MOCHA_VALUE_OPTIONS.has(name)) {
|
|
812
|
+
i++;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return kept;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Configuration files of the JavaScript runners.
|
|
819
|
+
const JS_CONFIG_NAMES = {
|
|
820
|
+
jest: new Set(['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.cts',
|
|
821
|
+
'jest.config.mts', 'jest.config.json']),
|
|
822
|
+
vitest: new Set(['vitest.config.js', 'vitest.config.cjs', 'vitest.config.mjs', 'vitest.config.ts',
|
|
823
|
+
'vitest.config.cts', 'vitest.config.mts', 'vite.config.js', 'vite.config.cjs', 'vite.config.mjs',
|
|
824
|
+
'vite.config.ts', 'vite.config.cts', 'vite.config.mts', 'vitest.workspace.js', 'vitest.workspace.mjs',
|
|
825
|
+
'vitest.workspace.ts', 'vitest.workspace.mts', 'vitest.workspace.json', 'vitest.projects.js',
|
|
826
|
+
'vitest.projects.ts', 'vitest.projects.json']),
|
|
827
|
+
mocha: new Set(['.mocharc', '.mocharc.js', '.mocharc.cjs', '.mocharc.mjs', '.mocharc.json', '.mocharc.jsonc',
|
|
828
|
+
'.mocharc.yaml', '.mocharc.yml']),
|
|
829
|
+
};
|
|
830
|
+
const JS_CONFIG_PACKAGE_KEY = { jest: 'jest', mocha: 'mocha' };
|
|
831
|
+
// The keys of a configuration that are known to leave alone which tests a
|
|
832
|
+
// command for one file runs. Every other key may select: it is evaluated
|
|
833
|
+
// where it is modeled (JS_MODELED_KEYS), and the configuration is not read
|
|
834
|
+
// otherwise. vitest's are the keys of `test`; mocha's are compared with
|
|
835
|
+
// dashes and case folded (it takes both spellings).
|
|
836
|
+
const JS_INERT_KEYS = {
|
|
837
|
+
jest: new Set(['automock', 'bail', 'cache', 'cacheDirectory', 'ci', 'clearMocks', 'collectCoverage',
|
|
838
|
+
'collectCoverageFrom', 'coverageDirectory', 'coveragePathIgnorePatterns', 'coverageProvider',
|
|
839
|
+
'coverageReporters', 'coverageThreshold', 'detectLeaks', 'detectOpenHandles', 'displayName',
|
|
840
|
+
'errorOnDeprecated', 'expand', 'extensionsToTreatAsEsm', 'fakeTimers', 'forceCoverageMatch', 'forceExit',
|
|
841
|
+
'globalSetup', 'globalTeardown', 'globals', 'json', 'logHeapUsage', 'maxConcurrency', 'maxWorkers',
|
|
842
|
+
'moduleDirectories', 'moduleNameMapper', 'modulePaths', 'noStackTrace', 'notify', 'notifyMode',
|
|
843
|
+
'openHandlesTimeout', 'outputFile', 'passWithNoTests', 'prettierPath', 'randomize', 'reporters',
|
|
844
|
+
'resetMocks', 'resetModules', 'resolver', 'restoreMocks', 'sandboxInjectedGlobals', 'setupFiles',
|
|
845
|
+
'setupFilesAfterEnv', 'showSeed', 'silent', 'slowTestThreshold', 'snapshotFormat', 'snapshotResolver',
|
|
846
|
+
'snapshotSerializers', 'testEnvironment', 'testEnvironmentOptions', 'testFailureExitCode',
|
|
847
|
+
'testLocationInResults', 'testResultsProcessor', 'testTimeout', 'transform', 'transformIgnorePatterns',
|
|
848
|
+
'unmockedModulePathPatterns', 'useStderr', 'verbose', 'watchPathIgnorePatterns', 'watchPlugins',
|
|
849
|
+
'watchman', 'workerIdleMemoryLimit', 'workerThreads', 'dependencyExtractor', 'waitForUnhandledRejections',
|
|
850
|
+
'//',
|
|
851
|
+
// Names earlier versions of jest used for keys of this list.
|
|
852
|
+
'testURL', 'timers', 'setupTestFrameworkScriptFile', 'mapCoverage', 'name', 'extraGlobals', 'browser']),
|
|
853
|
+
vitest: new Set(['alias', 'api', 'attachmentsDir', 'bail', 'benchmark', 'cache', 'chaiConfig', 'clearMocks',
|
|
854
|
+
'coverage', 'css', 'dangerouslyIgnoreUnhandledErrors', 'deps', 'diff', 'disableConsoleIntercept', 'env',
|
|
855
|
+
'environment', 'environmentMatchGlobs', 'environmentOptions', 'expandSnapshotDiff', 'expect', 'fakeTimers',
|
|
856
|
+
'fileParallelism', 'globals', 'globalSetup', 'hookTimeout', 'includeTaskLocation', 'isolate',
|
|
857
|
+
'logHeapUsage', 'maxConcurrency', 'maxWorkers', 'minWorkers', 'mockReset', 'name', 'onConsoleLog',
|
|
858
|
+
'onStackTrace', 'open', 'outputFile', 'passWithNoTests', 'pool', 'poolMatchGlobs', 'poolOptions',
|
|
859
|
+
'printConsoleTrace', 'provide', 'reporters', 'resolveSnapshotPath', 'restoreMocks', 'retry', 'sequence',
|
|
860
|
+
'server', 'setupFiles', 'silent', 'slowTestThreshold', 'snapshotFormat', 'snapshotSerializers',
|
|
861
|
+
'teardownTimeout', 'testTimeout', 'ui', 'unstubEnvs', 'unstubGlobals', 'update', 'watch']),
|
|
862
|
+
mocha: new Set(['allowuncaught', 'asynconly', 'bail', 'checkleaks', 'color', 'colors', 'diff',
|
|
863
|
+
'enablesourcemaps', 'exit', 'failzero', 'fulltrace', 'global', 'globals', 'import', 'inlinediffs', 'jobs',
|
|
864
|
+
'loader', 'nodeoption', 'nodeoptions', 'parallel', 'recursive', 'reporter', 'reporteroption',
|
|
865
|
+
'reporteroptions', 'require', 'retries', 'slow', 'sort', 'timeout', 'timeouts', 'watch', 'watchfiles',
|
|
866
|
+
'watchignore']),
|
|
867
|
+
};
|
|
868
|
+
const JS_MODELED_KEYS = {
|
|
869
|
+
jest: new Set(['roots', 'testRegex', 'testPathIgnorePatterns']),
|
|
870
|
+
vitest: new Set(['dir']),
|
|
871
|
+
mocha: new Set(),
|
|
872
|
+
};
|
|
873
|
+
// The keys of a vite configuration, beside `test` and `plugins`, that
|
|
874
|
+
// leave vitest's file selection alone.
|
|
875
|
+
const VITE_INERT_KEYS = new Set(['appType', 'assetsInclude', 'base', 'build', 'cacheDir', 'clearScreen', 'css',
|
|
876
|
+
'define', 'dev', 'envDir', 'envPrefix', 'environments', 'esbuild', 'experimental', 'future', 'html', 'json',
|
|
877
|
+
'legacy', 'logLevel', 'mode', 'optimizeDeps', 'oxc', 'preview', 'publicDir', 'resolve', 'server', 'ssr',
|
|
878
|
+
'worker']);
|
|
879
|
+
// The functions a runner's own package gives a configuration to pass
|
|
880
|
+
// through unchanged: the name alone proves nothing, the import does.
|
|
881
|
+
const CONFIG_IDENTITY = {
|
|
882
|
+
jest: { modules: new Set(['jest']), names: new Set(['defineConfig']) },
|
|
883
|
+
vitest: { modules: new Set(['vitest/config', 'vite', 'vitest']), names: new Set(['defineConfig', 'defineProject']) },
|
|
884
|
+
};
|
|
885
|
+
const CONFIG_WRAPPERS = new Set(['parenthesized_expression', 'as_expression', 'satisfies_expression',
|
|
886
|
+
'type_assertion', 'non_null_expression']);
|
|
887
|
+
// Modules whose functions act on the process or the disk, and the
|
|
888
|
+
// functions of them that only read.
|
|
889
|
+
const CONFIG_ACTING_MODULES = new Set(['fs', 'fs/promises', 'child_process', 'module', 'vm', 'worker_threads',
|
|
890
|
+
'cluster', 'process', 'inspector', 'repl']);
|
|
891
|
+
const CONFIG_READING_CALLS = new Set(['readFileSync', 'existsSync', 'readdirSync', 'statSync', 'lstatSync',
|
|
892
|
+
'realpathSync', 'createRequire']);
|
|
893
|
+
const CONFIG_GLOBAL_NAMESPACES = new Set(['JSON', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean']);
|
|
894
|
+
const CONFIG_CONSTRUCTORS = new Set(['URL', 'RegExp', 'Set', 'Map', 'Date']);
|
|
895
|
+
const CONFIG_NAME_NODES = new Set(['identifier', 'shorthand_property_identifier', 'shorthand_property_identifier_pattern']);
|
|
896
|
+
|
|
897
|
+
/** The value of a string literal node (no substitutions), or null. */
|
|
898
|
+
function stringLiteralValue(node) {
|
|
899
|
+
if (!node || (node.type !== 'string' && node.type !== 'template_string')) return null;
|
|
900
|
+
if (node.namedChildren.some(child => child.type === 'template_substitution')) return null;
|
|
901
|
+
const escapes = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', 0: '\0' };
|
|
902
|
+
return node.text.slice(1, -1).replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (whole, escape) => {
|
|
903
|
+
if (escape[0] === 'u' || escape[0] === 'x') {
|
|
904
|
+
const code = parseInt(escape.replace(/^[ux]\{?|\}$/g, ''), 16);
|
|
905
|
+
return Number.isNaN(code) ? whole : String.fromCodePoint(code);
|
|
906
|
+
}
|
|
907
|
+
return Object.prototype.hasOwnProperty.call(escapes, escape) ? escapes[escape] : escape;
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** JSON text with its comments taken out; what a string holds is left as it is. */
|
|
912
|
+
function withoutJsonComments(text) {
|
|
913
|
+
let out = '';
|
|
914
|
+
let i = 0;
|
|
915
|
+
while (i < text.length) {
|
|
916
|
+
const ch = text[i];
|
|
917
|
+
if (ch === '"') {
|
|
918
|
+
let end = i + 1;
|
|
919
|
+
while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1;
|
|
920
|
+
out += text.slice(i, end + 1);
|
|
921
|
+
i = end + 1;
|
|
922
|
+
} else if (ch === '/' && text[i + 1] === '/') {
|
|
923
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
924
|
+
} else if (ch === '/' && text[i + 1] === '*') {
|
|
925
|
+
const end = text.indexOf('*/', i + 2);
|
|
926
|
+
i = end === -1 ? text.length : end + 2;
|
|
927
|
+
out += ' ';
|
|
928
|
+
} else {
|
|
929
|
+
out += ch;
|
|
930
|
+
i++;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return out;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* The keys a configuration sets, each with readers of its value: as
|
|
938
|
+
* strings (null when the value is not a string or a list of strings
|
|
939
|
+
* written out), as an object of keys, and whether it is a list of calls
|
|
940
|
+
* of functions that packages provide. Null when the value is no object.
|
|
941
|
+
*/
|
|
942
|
+
function configKeysOfJson(value) {
|
|
943
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
944
|
+
const keys = new Map();
|
|
945
|
+
for (const [key, held] of Object.entries(value)) {
|
|
946
|
+
keys.set(key, {
|
|
947
|
+
strings: () => typeof held === 'string' ? [held]
|
|
948
|
+
: Array.isArray(held) && held.every(item => typeof item === 'string') ? held : null,
|
|
949
|
+
object: () => configKeysOfJson(held),
|
|
950
|
+
packageCalls: () => Array.isArray(held) && held.length === 0,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
return keys;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* The top-level keys of a YAML mapping written as a block, one `key:` to
|
|
958
|
+
* a line at the left margin; their values are not read. Null for every
|
|
959
|
+
* other way of writing one (a flow mapping, indented keys, a merge key, a
|
|
960
|
+
* key that is not a plain or quoted word): what such a file sets is not
|
|
961
|
+
* read.
|
|
962
|
+
*/
|
|
963
|
+
function configKeysOfYaml(text) {
|
|
964
|
+
const keys = new Map();
|
|
965
|
+
let started = false;
|
|
966
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
967
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
968
|
+
if (!started && keys.size === 0 && /^---\s*(#.*)?$/.test(line)) {
|
|
969
|
+
started = true;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
// The value of the key above: indented lines, or a list at the margin.
|
|
973
|
+
if (/^[ \t]/.test(line) || /^-(\s|$)/.test(line)) {
|
|
974
|
+
if (keys.size === 0) return null;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
const found = /^([A-Za-z][\w-]*|"[A-Za-z][\w-]*"|'[A-Za-z][\w-]*')\s*:(\s|$)/.exec(line);
|
|
978
|
+
if (!found) return null;
|
|
979
|
+
keys.set(found[1].replace(/^['"]|['"]$/g, ''), { strings: () => null, object: () => null, packageCalls: () => false });
|
|
980
|
+
}
|
|
981
|
+
return keys;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* The keys a configuration written as code sets, when the file is one
|
|
986
|
+
* object written out and nothing else in it can change that object or act
|
|
987
|
+
* on the run. The file may hold only: imports that bind names, variable
|
|
988
|
+
* declarations whose values are built without running code of the file
|
|
989
|
+
* (literals, names, operators, calls of functions that packages and the
|
|
990
|
+
* language provide), type declarations, and ONE export (`module.exports =`
|
|
991
|
+
* or `export default`) of an object literal: written in place, held by a
|
|
992
|
+
* variable that is named nowhere else, or passed through the function the
|
|
993
|
+
* runner's own package provides for that (proved by the import). A second
|
|
994
|
+
* statement that writes the export, a name for the object that is used
|
|
995
|
+
* again, a function of the file that is called, a write anywhere: the file
|
|
996
|
+
* is code whose effect is not read (null).
|
|
997
|
+
*/
|
|
998
|
+
function configKeysOfCode(text, typed, runnerId) {
|
|
999
|
+
const tree = safeParse(getParser(typed ? 'typescript' : 'javascript'), text);
|
|
1000
|
+
if (!tree || tree.rootNode.hasError) return null;
|
|
1001
|
+
const root = tree.rootNode;
|
|
1002
|
+
const bindings = new Map(); // local name -> { module, imported } (imported null: the module itself)
|
|
1003
|
+
const declared = new Map(); // local name -> value nodes of its declarators
|
|
1004
|
+
const packaged = name => {
|
|
1005
|
+
const binding = bindings.get(name);
|
|
1006
|
+
return !!binding && !/^\.{0,2}\//.test(binding.module) && !/^[A-Za-z]:[\\/]/.test(binding.module);
|
|
1007
|
+
};
|
|
1008
|
+
const moduleName = binding => binding.module.replace(/^node:/, '');
|
|
1009
|
+
const requiredModule = node => {
|
|
1010
|
+
if (!node || node.type !== 'call_expression') return null;
|
|
1011
|
+
const callee = node.childForFieldName('function');
|
|
1012
|
+
const args = node.childForFieldName('arguments');
|
|
1013
|
+
if (!callee || callee.type !== 'identifier' || callee.text !== 'require' || !args || args.namedChildren.length !== 1) return null;
|
|
1014
|
+
return stringLiteralValue(args.namedChildren[0]);
|
|
1015
|
+
};
|
|
1016
|
+
/** Is a callee a function that a package or the language provides? */
|
|
1017
|
+
const calleeProvided = callee => {
|
|
1018
|
+
if (!callee) return false;
|
|
1019
|
+
if (CONFIG_WRAPPERS.has(callee.type)) return calleeProvided(callee.namedChildren[0]);
|
|
1020
|
+
if (callee.type === 'identifier') {
|
|
1021
|
+
const binding = bindings.get(callee.text);
|
|
1022
|
+
return packaged(callee.text) && !CONFIG_ACTING_MODULES.has(moduleName(binding)) ||
|
|
1023
|
+
(!!binding && packaged(callee.text) && CONFIG_READING_CALLS.has(binding.imported));
|
|
1024
|
+
}
|
|
1025
|
+
if (callee.type === 'call_expression') {
|
|
1026
|
+
// require('path').join, a function a package's function returns
|
|
1027
|
+
return requiredModule(callee) != null ? !CONFIG_ACTING_MODULES.has(requiredModule(callee).replace(/^node:/, ''))
|
|
1028
|
+
: calleeProvided(callee.childForFieldName('function')) && inert(callee.childForFieldName('arguments'));
|
|
1029
|
+
}
|
|
1030
|
+
if (callee.type !== 'member_expression') return false;
|
|
1031
|
+
const property = callee.childForFieldName('property');
|
|
1032
|
+
let base = callee;
|
|
1033
|
+
while (base && (base.type === 'member_expression' || CONFIG_WRAPPERS.has(base.type))) {
|
|
1034
|
+
base = base.type === 'member_expression' ? base.childForFieldName('object') : base.namedChildren[0];
|
|
1035
|
+
}
|
|
1036
|
+
if (!base || !property) return false;
|
|
1037
|
+
if (base.type === 'call_expression') {
|
|
1038
|
+
const required = requiredModule(base);
|
|
1039
|
+
if (required != null) {
|
|
1040
|
+
return !CONFIG_ACTING_MODULES.has(required.replace(/^node:/, '')) || CONFIG_READING_CALLS.has(property.text);
|
|
1041
|
+
}
|
|
1042
|
+
return calleeProvided(base.childForFieldName('function')) && inert(base.childForFieldName('arguments'));
|
|
1043
|
+
}
|
|
1044
|
+
if (base.type === 'meta_property') return true;
|
|
1045
|
+
if (base.type !== 'identifier') return false;
|
|
1046
|
+
if (base.text === 'process') {
|
|
1047
|
+
// process.cwd() and nothing else of the process is called.
|
|
1048
|
+
return sameNodeText(callee.childForFieldName('object'), 'process') && property.text === 'cwd';
|
|
1049
|
+
}
|
|
1050
|
+
if (CONFIG_GLOBAL_NAMESPACES.has(base.text) && !bindings.has(base.text) && !declared.has(base.text)) return true;
|
|
1051
|
+
const binding = bindings.get(base.text);
|
|
1052
|
+
if (!binding || !packaged(base.text)) return false;
|
|
1053
|
+
return !CONFIG_ACTING_MODULES.has(moduleName(binding)) || CONFIG_READING_CALLS.has(property.text);
|
|
1054
|
+
};
|
|
1055
|
+
const sameNodeText = (node, name) => !!node && node.type === 'identifier' && node.text === name;
|
|
1056
|
+
/** Does evaluating an expression run nothing of this file and write nothing? */
|
|
1057
|
+
function inert(node) {
|
|
1058
|
+
if (!node) return true;
|
|
1059
|
+
switch (node.type) {
|
|
1060
|
+
case 'string': case 'number': case 'true': case 'false': case 'null': case 'undefined': case 'regex':
|
|
1061
|
+
case 'identifier': case 'property_identifier': case 'shorthand_property_identifier':
|
|
1062
|
+
case 'private_property_identifier': case 'comment': case 'meta_property': case 'this':
|
|
1063
|
+
case 'string_fragment': case 'escape_sequence': case 'optional_chain':
|
|
1064
|
+
return true;
|
|
1065
|
+
// A function written here is not called here.
|
|
1066
|
+
case 'arrow_function': case 'function_expression': case 'function': case 'generator_function':
|
|
1067
|
+
return true;
|
|
1068
|
+
case 'template_string': case 'template_substitution': case 'parenthesized_expression': case 'array':
|
|
1069
|
+
case 'object': case 'pair': case 'spread_element': case 'binary_expression': case 'ternary_expression':
|
|
1070
|
+
case 'subscript_expression': case 'member_expression': case 'computed_property_name': case 'arguments':
|
|
1071
|
+
case 'as_expression': case 'satisfies_expression': case 'non_null_expression': case 'type_assertion':
|
|
1072
|
+
return node.namedChildren.every(child => /(^|_)type(s|_arguments|_annotation)?$|^type_|_type$/.test(child.type) || inert(child));
|
|
1073
|
+
case 'unary_expression':
|
|
1074
|
+
return node.children[0].type !== 'delete' && node.namedChildren.every(inert);
|
|
1075
|
+
case 'new_expression': {
|
|
1076
|
+
const made = node.childForFieldName('constructor');
|
|
1077
|
+
return !!made && made.type === 'identifier' && CONFIG_CONSTRUCTORS.has(made.text) &&
|
|
1078
|
+
!bindings.has(made.text) && !declared.has(made.text) && inert(node.childForFieldName('arguments'));
|
|
1079
|
+
}
|
|
1080
|
+
case 'call_expression': {
|
|
1081
|
+
if (requiredModule(node) != null) return true;
|
|
1082
|
+
return calleeProvided(node.childForFieldName('function')) && inert(node.childForFieldName('arguments'));
|
|
1083
|
+
}
|
|
1084
|
+
default:
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
let exported = null;
|
|
1090
|
+
let exports = 0;
|
|
1091
|
+
let commonJs = false;
|
|
1092
|
+
const values = []; // every value a declaration evaluates
|
|
1093
|
+
for (const statement of root.namedChildren) {
|
|
1094
|
+
switch (statement.type) {
|
|
1095
|
+
case 'comment': case 'hash_bang_line': case 'empty_statement':
|
|
1096
|
+
case 'type_alias_declaration': case 'interface_declaration':
|
|
1097
|
+
break;
|
|
1098
|
+
case 'import_statement': {
|
|
1099
|
+
const clause = statement.namedChildren.find(child => child.type === 'import_clause');
|
|
1100
|
+
const source = stringLiteralValue(statement.childForFieldName('source'));
|
|
1101
|
+
// An import for its effect runs code that is not read.
|
|
1102
|
+
if (!clause || source == null) return null;
|
|
1103
|
+
for (const part of clause.namedChildren) {
|
|
1104
|
+
if (part.type === 'identifier') bindings.set(part.text, { module: source, imported: 'default' });
|
|
1105
|
+
else if (part.type === 'namespace_import') {
|
|
1106
|
+
const name = part.namedChildren.find(child => child.type === 'identifier');
|
|
1107
|
+
if (name) bindings.set(name.text, { module: source, imported: null });
|
|
1108
|
+
} else if (part.type === 'named_imports') {
|
|
1109
|
+
for (const specifier of part.namedChildren) {
|
|
1110
|
+
if (specifier.type !== 'import_specifier') continue;
|
|
1111
|
+
const name = specifier.childForFieldName('name');
|
|
1112
|
+
const alias = specifier.childForFieldName('alias');
|
|
1113
|
+
if (name) bindings.set((alias || name).text, { module: source, imported: name.text });
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
break;
|
|
1118
|
+
}
|
|
1119
|
+
case 'lexical_declaration': case 'variable_declaration': {
|
|
1120
|
+
for (const declarator of statement.namedChildren) {
|
|
1121
|
+
if (declarator.type !== 'variable_declarator') return null;
|
|
1122
|
+
const target = declarator.childForFieldName('name');
|
|
1123
|
+
const value = declarator.childForFieldName('value');
|
|
1124
|
+
const required = requiredModule(value);
|
|
1125
|
+
if (target && target.type === 'identifier') {
|
|
1126
|
+
if (required != null) bindings.set(target.text, { module: required, imported: null });
|
|
1127
|
+
else {
|
|
1128
|
+
if (!declared.has(target.text)) declared.set(target.text, []);
|
|
1129
|
+
declared.get(target.text).push(value || null);
|
|
1130
|
+
if (value) values.push(value);
|
|
1131
|
+
}
|
|
1132
|
+
} else if (target && target.type === 'object_pattern' && required != null) {
|
|
1133
|
+
for (const part of target.namedChildren) {
|
|
1134
|
+
if (part.type === 'shorthand_property_identifier_pattern') {
|
|
1135
|
+
bindings.set(part.text, { module: required, imported: part.text });
|
|
1136
|
+
} else if (part.type === 'pair_pattern') {
|
|
1137
|
+
const key = part.childForFieldName('key');
|
|
1138
|
+
const local = part.childForFieldName('value');
|
|
1139
|
+
if (!key || !local || local.type !== 'identifier' || key.type !== 'property_identifier') return null;
|
|
1140
|
+
bindings.set(local.text, { module: required, imported: key.text });
|
|
1141
|
+
} else {
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
} else {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
case 'expression_statement': {
|
|
1152
|
+
const expression = statement.namedChildren[0];
|
|
1153
|
+
// A directive ("use strict").
|
|
1154
|
+
if (expression && expression.type === 'string') break;
|
|
1155
|
+
if (!expression || expression.type !== 'assignment_expression') return null;
|
|
1156
|
+
const left = expression.childForFieldName('left');
|
|
1157
|
+
if (!left || left.type !== 'member_expression' || !sameNodeText(left.childForFieldName('object'), 'module') ||
|
|
1158
|
+
left.childForFieldName('property')?.text !== 'exports') return null;
|
|
1159
|
+
exported = expression.childForFieldName('right');
|
|
1160
|
+
commonJs = true;
|
|
1161
|
+
exports++;
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
case 'export_statement': {
|
|
1165
|
+
if (!statement.children.some(child => child.type === 'default')) return null;
|
|
1166
|
+
exported = statement.childForFieldName('value');
|
|
1167
|
+
exports++;
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
default:
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (exports !== 1 || !exported) return null;
|
|
1175
|
+
if (!values.every(inert)) return null;
|
|
1176
|
+
// The names that reach the exported object: each is used exactly as
|
|
1177
|
+
// often as reading it once needs.
|
|
1178
|
+
const uses = new Map();
|
|
1179
|
+
const count = node => {
|
|
1180
|
+
if (CONFIG_NAME_NODES.has(node.type)) uses.set(node.text, (uses.get(node.text) || 0) + 1);
|
|
1181
|
+
for (const child of node.namedChildren) count(child);
|
|
1182
|
+
};
|
|
1183
|
+
count(root);
|
|
1184
|
+
if ((uses.get('module') || 0) !== (commonJs ? 1 : 0) || uses.has('exports') || uses.has('eval') ||
|
|
1185
|
+
uses.has('globalThis') || uses.has('global')) return null;
|
|
1186
|
+
// `require` only as the call of one written-out module name.
|
|
1187
|
+
let requires = 0;
|
|
1188
|
+
const countRequires = node => {
|
|
1189
|
+
if (requiredModule(node) != null) requires++;
|
|
1190
|
+
for (const child of node.namedChildren) countRequires(child);
|
|
1191
|
+
};
|
|
1192
|
+
countRequires(root);
|
|
1193
|
+
if ((uses.get('require') || 0) !== requires) return null;
|
|
1194
|
+
const identity = CONFIG_IDENTITY[runnerId];
|
|
1195
|
+
const objectOf = (node, depth = 0) => {
|
|
1196
|
+
if (!node || depth > 6) return null;
|
|
1197
|
+
if (CONFIG_WRAPPERS.has(node.type)) return objectOf(node.namedChildren[0], depth + 1);
|
|
1198
|
+
if (node.type === 'object') return node;
|
|
1199
|
+
if (node.type === 'identifier') {
|
|
1200
|
+
// One declaration, and this the only other place it is named.
|
|
1201
|
+
const held = declared.get(node.text);
|
|
1202
|
+
if (!held || held.length !== 1 || bindings.has(node.text) || uses.get(node.text) !== 2) return null;
|
|
1203
|
+
return objectOf(held[0], depth + 1);
|
|
1204
|
+
}
|
|
1205
|
+
if (node.type === 'call_expression' && identity) {
|
|
1206
|
+
const callee = node.childForFieldName('function');
|
|
1207
|
+
const args = node.childForFieldName('arguments');
|
|
1208
|
+
const binding = callee && callee.type === 'identifier' && !declared.has(callee.text) ? bindings.get(callee.text) : null;
|
|
1209
|
+
if (binding && identity.modules.has(binding.module) && identity.names.has(binding.imported) &&
|
|
1210
|
+
args && args.namedChildren.length === 1) return objectOf(args.namedChildren[0], depth + 1);
|
|
1211
|
+
}
|
|
1212
|
+
return null;
|
|
1213
|
+
};
|
|
1214
|
+
const fromObject = node => {
|
|
1215
|
+
const object = objectOf(node);
|
|
1216
|
+
if (!object) return null;
|
|
1217
|
+
const keys = new Map();
|
|
1218
|
+
for (const member of object.namedChildren) {
|
|
1219
|
+
if (member.type === 'comment') continue;
|
|
1220
|
+
if (member.type !== 'pair') return null;
|
|
1221
|
+
const key = member.childForFieldName('key');
|
|
1222
|
+
const value = member.childForFieldName('value');
|
|
1223
|
+
const written = !key ? null : key.type === 'property_identifier' ? key.text : stringLiteralValue(key);
|
|
1224
|
+
if (written == null || written === '__proto__' || !inert(value)) return null;
|
|
1225
|
+
keys.set(written, {
|
|
1226
|
+
strings: () => {
|
|
1227
|
+
const one = stringLiteralValue(value);
|
|
1228
|
+
if (one != null) return [one];
|
|
1229
|
+
if (!value || value.type !== 'array') return null;
|
|
1230
|
+
const all = value.namedChildren.filter(item => item.type !== 'comment').map(stringLiteralValue);
|
|
1231
|
+
return all.every(item => item != null) ? all : null;
|
|
1232
|
+
},
|
|
1233
|
+
object: () => fromObject(value),
|
|
1234
|
+
// A list of what functions of packages return (plugins).
|
|
1235
|
+
packageCalls: () => !!value && value.type === 'array' &&
|
|
1236
|
+
value.namedChildren.every(item => item.type === 'comment' ||
|
|
1237
|
+
(item.type === 'call_expression' && requiredModule(item) == null && inert(item))),
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
return keys;
|
|
1241
|
+
};
|
|
1242
|
+
return fromObject(exported);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function configKeysOf(model, dir, name, runnerId) {
|
|
1246
|
+
const text = model.manifests.read(dir, name);
|
|
1247
|
+
if (text == null) return null;
|
|
1248
|
+
if (/\.(json|jsonc)$/.test(name)) {
|
|
1249
|
+
try {
|
|
1250
|
+
return configKeysOfJson(JSON.parse(withoutJsonComments(text)));
|
|
1251
|
+
} catch (e) {
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (/\.(yaml|yml)$/.test(name)) return configKeysOfYaml(text);
|
|
1256
|
+
if (!/\.[cm]?[jt]s$/.test(name)) return null;
|
|
1257
|
+
return configKeysOfCode(text, /\.[cm]?ts$/.test(name), runnerId);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* What a JavaScript runner's configuration does to a command that names a
|
|
1262
|
+
* test file: `inert` (every key it sets is known to leave the selection
|
|
1263
|
+
* alone), `modeled` (`accepts(file)` says whether the configuration
|
|
1264
|
+
* leaves the file in; `ownPattern`: it replaces the runner's default file
|
|
1265
|
+
* pattern), or `unread` (a key that may select is set and not modeled, the
|
|
1266
|
+
* configuration is code that is not one written-out object, or one stands
|
|
1267
|
+
* above the runner's directory).
|
|
1268
|
+
*/
|
|
1269
|
+
function jsRunnerConfig(model, runner) {
|
|
1270
|
+
const memoKey = `${runner.id}\0${runner.dir || ''}`;
|
|
1271
|
+
if (model.jsConfigs.has(memoKey)) return model.jsConfigs.get(memoKey);
|
|
1272
|
+
const answer = (() => {
|
|
1273
|
+
// The project's own script points the runner somewhere else.
|
|
1274
|
+
if (runnerScriptWords(runner, runner.id).some(word => SCRIPT_CONFIG_FLAGS.test(word))) return { state: 'unread' };
|
|
1275
|
+
const names = JS_CONFIG_NAMES[runner.id];
|
|
1276
|
+
const packageKey = JS_CONFIG_PACKAGE_KEY[runner.id];
|
|
1277
|
+
const here = runner.dir || '';
|
|
1278
|
+
for (const dir of ancestorDirs(here ? `${here}/x` : 'x').slice(1)) {
|
|
1279
|
+
if (model.manifests.list(dir).some(name => names.has(name))) return { state: 'unread' };
|
|
1280
|
+
const above = packageKey ? model.manifests.json(dir, 'package.json') : null;
|
|
1281
|
+
if (above && above[packageKey] != null) return { state: 'unread' };
|
|
1282
|
+
}
|
|
1283
|
+
let files = model.manifests.list(here).filter(name => names.has(name));
|
|
1284
|
+
if (runner.id === 'vitest') {
|
|
1285
|
+
if (files.some(name => /^vitest\.(workspace|projects)\./.test(name))) return { state: 'unread' };
|
|
1286
|
+
// vitest's own file is the one read when both are there.
|
|
1287
|
+
if (files.some(name => /^vitest\.config\./.test(name))) files = files.filter(name => /^vitest\.config\./.test(name));
|
|
1288
|
+
}
|
|
1289
|
+
const sources = files.map(name => configKeysOf(model, here, name, runner.id));
|
|
1290
|
+
const manifest = packageKey ? model.manifests.json(here, 'package.json') : null;
|
|
1291
|
+
if (manifest && manifest[packageKey] != null) {
|
|
1292
|
+
const held = manifest[packageKey];
|
|
1293
|
+
sources.push(configKeysOfJson(held));
|
|
1294
|
+
}
|
|
1295
|
+
if (sources.length === 0) return { state: 'inert' };
|
|
1296
|
+
if (sources.some(source => source == null) || (runner.id !== 'mocha' && sources.length > 1)) return { state: 'unread' };
|
|
1297
|
+
let keys = new Map();
|
|
1298
|
+
for (const source of sources) for (const [key, reader] of source) keys.set(key, reader);
|
|
1299
|
+
if (runner.id === 'vitest') {
|
|
1300
|
+
// The runner's part of a vite configuration is its `test` key;
|
|
1301
|
+
// a plugin can rewrite the configuration, so each one has to
|
|
1302
|
+
// be what a package's function returns.
|
|
1303
|
+
for (const [key, reader] of keys) {
|
|
1304
|
+
if (key === 'test' || VITE_INERT_KEYS.has(key)) continue;
|
|
1305
|
+
if (key === 'plugins' && reader.packageCalls()) continue;
|
|
1306
|
+
return { state: 'unread' };
|
|
1307
|
+
}
|
|
1308
|
+
const test = keys.get('test');
|
|
1309
|
+
if (!test) return { state: 'inert' };
|
|
1310
|
+
keys = test.object();
|
|
1311
|
+
if (!keys) return { state: 'unread' };
|
|
1312
|
+
}
|
|
1313
|
+
const fold = key => runner.id === 'mocha' ? key.replace(/-/g, '').toLowerCase() : key;
|
|
1314
|
+
const selecting = [...keys.keys()].filter(key => !JS_INERT_KEYS[runner.id].has(fold(key)));
|
|
1315
|
+
if (selecting.length === 0) return { state: 'inert', keys: new Set(keys.keys()) };
|
|
1316
|
+
if (!selecting.every(key => JS_MODELED_KEYS[runner.id].has(key))) return { state: 'unread' };
|
|
1317
|
+
const absolute = file => path.posix.join(String(model.root).replace(/\\/g, '/'), here, file);
|
|
1318
|
+
if (runner.id === 'jest') {
|
|
1319
|
+
const read = key => keys.has(key) ? keys.get(key).strings() : undefined;
|
|
1320
|
+
const roots = read('roots');
|
|
1321
|
+
const regex = read('testRegex');
|
|
1322
|
+
const ignored = read('testPathIgnorePatterns');
|
|
1323
|
+
if (roots === null || regex === null || ignored === null) return { state: 'unread' };
|
|
1324
|
+
let patterns;
|
|
1325
|
+
let ignores;
|
|
1326
|
+
const base = absolute('').replace(/\/$/, '');
|
|
1327
|
+
// jest puts the root directory where a pattern says <rootDir>.
|
|
1328
|
+
const pattern = source => new RegExp(source.replace(/<rootDir>/g, base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
1329
|
+
try {
|
|
1330
|
+
patterns = regex ? regex.map(pattern) : null;
|
|
1331
|
+
ignores = (ignored || ['/node_modules/']).map(pattern);
|
|
1332
|
+
} catch (e) {
|
|
1333
|
+
return { state: 'unread' };
|
|
1334
|
+
}
|
|
1335
|
+
const rootDirs = roots ? roots.map(root => path.posix.join(base, root.replace(/^<rootDir>\/?/, ''))) : null;
|
|
1336
|
+
if (roots && roots.some(root => path.posix.isAbsolute(root.replace(/^<rootDir>/, '')) && !root.startsWith('<rootDir>'))) {
|
|
1337
|
+
return { state: 'unread' };
|
|
1338
|
+
}
|
|
1339
|
+
return {
|
|
1340
|
+
state: 'modeled', ownPattern: !!patterns,
|
|
1341
|
+
accepts: file => {
|
|
1342
|
+
const full = absolute(file);
|
|
1343
|
+
if (rootDirs && !rootDirs.some(dir => full.startsWith(dir.replace(/\/$/, '') + '/'))) return false;
|
|
1344
|
+
if (ignores.some(pattern => pattern.test(full))) return false;
|
|
1345
|
+
return !patterns || patterns.some(pattern => pattern.test(full));
|
|
1346
|
+
},
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
if (runner.id === 'vitest') {
|
|
1350
|
+
const dir = keys.get('dir').strings();
|
|
1351
|
+
if (!dir || dir.length !== 1 || path.posix.isAbsolute(dir[0]) || dir[0].includes('..')) return { state: 'unread' };
|
|
1352
|
+
const under = dir[0].replace(/^\.\//, '').replace(/\/$/, '');
|
|
1353
|
+
return { state: 'modeled', ownPattern: false, accepts: file => !under || file.startsWith(`${under}/`) };
|
|
1354
|
+
}
|
|
1355
|
+
return { state: 'unread' };
|
|
1356
|
+
})();
|
|
1357
|
+
model.jsConfigs.set(memoKey, answer);
|
|
1358
|
+
return answer;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const RUNNER_OF_FAMILY = {
|
|
1362
|
+
javascript: jsRunner,
|
|
1363
|
+
python: pythonRunner,
|
|
1364
|
+
go: goRunner,
|
|
1365
|
+
rust: rustRunner,
|
|
1366
|
+
java: javaRunner,
|
|
1367
|
+
dotnet: dotnetRunner,
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
// ---------------------------------------------------------------------------
|
|
1371
|
+
// Selectors
|
|
1372
|
+
// ---------------------------------------------------------------------------
|
|
1373
|
+
|
|
1374
|
+
function withheld(reason) {
|
|
1375
|
+
return { selector: null, reason };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function command(runner, words) {
|
|
1379
|
+
return {
|
|
1380
|
+
selector: {
|
|
1381
|
+
command: words.join(' '),
|
|
1382
|
+
...(runner.dir && { cwd: runner.dir }),
|
|
1383
|
+
},
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// The files jest and vitest look for when the project configures nothing.
|
|
1388
|
+
// Neither runs a file outside its pattern, even when given the path
|
|
1389
|
+
// (measured): for such a file the command depends on a configuration that
|
|
1390
|
+
// is not read here.
|
|
1391
|
+
const DEFAULT_TEST_FILES = {
|
|
1392
|
+
jest: [/(^|\/)__tests__\/.*\.[jt]sx?$/, /(^|\/)([^/]*\.)?(spec|test)+\.[jt]sx?$/],
|
|
1393
|
+
vitest: [/\.(test|spec)\.[cm]?[jt]sx?$/],
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
function lookedFor(runnerId, relativePath) {
|
|
1397
|
+
return DEFAULT_TEST_FILES[runnerId].some(pattern => pattern.test(relativePath));
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function titlePattern(unit) {
|
|
1401
|
+
return `^${regexLiteral([...unit.suite, unit.name].join(' '))}$`;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const SELECTORS = {
|
|
1405
|
+
'pytest'(model, runner, fileEntry, unit) {
|
|
1406
|
+
const file = relativeTo(runner.dir, fileEntry.relativePath);
|
|
1407
|
+
if (file == null) return withheld('outside-runner-root');
|
|
1408
|
+
// An option of the project's that selects, adds or leaves out
|
|
1409
|
+
// tests and is not modeled: no command is known to be exact.
|
|
1410
|
+
const options = runner.collection && runner.collection.options;
|
|
1411
|
+
if (options && options.unread) return withheld('runner-options-not-read');
|
|
1412
|
+
if (unit && unit.deselected) return withheld('deselected-by-options');
|
|
1413
|
+
if (!unit) return command(runner, ['pytest', shellWord(file)]);
|
|
1414
|
+
return command(runner, ['pytest', shellWord([file, ...unit.suite, unit.name].join('::'))]);
|
|
1415
|
+
},
|
|
1416
|
+
'node-test'(model, runner, fileEntry, unit, filePath) {
|
|
1417
|
+
const file = relativeTo(runner.dir, fileEntry.relativePath);
|
|
1418
|
+
if (file == null) return withheld('outside-runner-root');
|
|
1419
|
+
const options = runner.evidence === 'package-script' || runner.script
|
|
1420
|
+
? nodeTestOptions(runner.script) : [];
|
|
1421
|
+
const typed = langTraits(fileEntry.language)?.typeSystem === 'structural' &&
|
|
1422
|
+
/\.[cm]?tsx?$/.test(fileEntry.relativePath);
|
|
1423
|
+
// A typed file runs only with what the project's own command loads.
|
|
1424
|
+
if (typed && (!options || options.length === 0)) return withheld('loader-unknown');
|
|
1425
|
+
const words = ['node', '--test', ...(options || []).map(shellWord)];
|
|
1426
|
+
if (unit) {
|
|
1427
|
+
// node tries a pattern on a test's own name and on its full
|
|
1428
|
+
// path (ancestors joined by spaces), and a matching suite runs
|
|
1429
|
+
// all it holds: the full path selects one test unless another
|
|
1430
|
+
// name in the file reads the same.
|
|
1431
|
+
const full = [...unit.suite, unit.name].join(' ');
|
|
1432
|
+
const key = [...unit.suite, unit.name].join('\0');
|
|
1433
|
+
const clash = model.unitsOf(filePath).units.some(other => other !== unit && other.name != null &&
|
|
1434
|
+
(other.name === full || [...other.suite, other.name].join(' ') === full) &&
|
|
1435
|
+
[...other.suite, other.name].join('\0') !== key);
|
|
1436
|
+
if (clash) return withheld('name-not-unique');
|
|
1437
|
+
words.push(`--test-name-pattern=${shellWord(`^${regexLiteral(full)}$`)}`);
|
|
1438
|
+
}
|
|
1439
|
+
words.push(shellWord(file));
|
|
1440
|
+
return command(runner, words);
|
|
1441
|
+
},
|
|
1442
|
+
'jest'(model, runner, fileEntry, unit) {
|
|
1443
|
+
const file = relativeTo(runner.dir, fileEntry.relativePath);
|
|
1444
|
+
if (file == null) return withheld('outside-runner-root');
|
|
1445
|
+
// jest holds a path it is given to its configuration: the file
|
|
1446
|
+
// pattern, the roots, the ignored paths (measured).
|
|
1447
|
+
const config = jsRunnerConfig(model, runner);
|
|
1448
|
+
if (config.state === 'unread') return withheld('runner-config-not-read');
|
|
1449
|
+
if (config.state === 'modeled' && !config.accepts(file)) return withheld('outside-runner-config');
|
|
1450
|
+
if (!(config.state === 'modeled' && config.ownPattern) && !lookedFor('jest', file)) {
|
|
1451
|
+
return withheld('runner-config-not-read');
|
|
1452
|
+
}
|
|
1453
|
+
// A bare path is a pattern jest tries on every test file's path.
|
|
1454
|
+
const words = ['npx', 'jest', '--runTestsByPath', shellWord(file)];
|
|
1455
|
+
if (unit) words.push('-t', shellWord(titlePattern(unit)));
|
|
1456
|
+
return command(runner, words);
|
|
1457
|
+
},
|
|
1458
|
+
'vitest'(model, runner, fileEntry, unit, filePath) {
|
|
1459
|
+
const file = relativeTo(runner.dir, fileEntry.relativePath);
|
|
1460
|
+
if (file == null) return withheld('outside-runner-root');
|
|
1461
|
+
const config = jsRunnerConfig(model, runner);
|
|
1462
|
+
if (config.state === 'unread') return withheld('runner-config-not-read');
|
|
1463
|
+
if (config.state === 'modeled' && !config.accepts(file)) return withheld('outside-runner-config');
|
|
1464
|
+
if (!lookedFor('vitest', file)) return withheld('runner-config-not-read');
|
|
1465
|
+
// vitest runs every file whose path contains the one given.
|
|
1466
|
+
if (model.pathsContaining(runner.dir, file) > 1) return withheld('path-not-unique');
|
|
1467
|
+
// vitest refuses a focused test where CI is set unless told to
|
|
1468
|
+
// allow it (measured).
|
|
1469
|
+
const focus = model.declared(filePath)?.focus ? ['--allowOnly'] : [];
|
|
1470
|
+
const words = ['npx', 'vitest', 'run', ...focus, shellWord(file)];
|
|
1471
|
+
if (unit) words.push('-t', shellWord(titlePattern(unit)));
|
|
1472
|
+
return command(runner, words);
|
|
1473
|
+
},
|
|
1474
|
+
'mocha'(model, runner, fileEntry, unit) {
|
|
1475
|
+
const file = relativeTo(runner.dir, fileEntry.relativePath);
|
|
1476
|
+
if (file == null) return withheld('outside-runner-root');
|
|
1477
|
+
// mocha adds the files its configuration names to the one given,
|
|
1478
|
+
// and applies its grep and ignore to it (measured).
|
|
1479
|
+
const config = jsRunnerConfig(model, runner);
|
|
1480
|
+
if (config.state !== 'inert') return withheld('runner-config-not-read');
|
|
1481
|
+
const loading = mochaLoadingOptions(runner);
|
|
1482
|
+
// A typed file loads only with what the project's own command or
|
|
1483
|
+
// configuration registers.
|
|
1484
|
+
const registers = loading.length > 0 || ['require', 'loader', 'import', 'node-option'].some(key => config.keys && config.keys.has(key));
|
|
1485
|
+
if (/\.[cm]?tsx?$/.test(file) && !registers) return withheld('loader-unknown');
|
|
1486
|
+
const words = ['npx', 'mocha', ...loading.map(shellWord), shellWord(file)];
|
|
1487
|
+
if (unit) words.push('--grep', shellWord(titlePattern(unit)));
|
|
1488
|
+
return command(runner, words);
|
|
1489
|
+
},
|
|
1490
|
+
'go-test'(model, runner, fileEntry, unit) {
|
|
1491
|
+
const file = relativeTo(runner.dir, fileEntry.relativePath);
|
|
1492
|
+
if (file == null) return withheld('outside-runner-root');
|
|
1493
|
+
if (fileEntry.buildConstraint || (unit && unit.notInEveryBuild)) return withheld('build-constraint');
|
|
1494
|
+
const dir = posixDir(file);
|
|
1495
|
+
const pkg = dir ? `./${dir}` : '.';
|
|
1496
|
+
if (!unit) return command(runner, ['go', 'test', shellWord(pkg)]);
|
|
1497
|
+
return command(runner, ['go', 'test', shellWord(pkg), '-run', shellWord(`^${regexLiteral(unit.name)}$`)]);
|
|
1498
|
+
},
|
|
1499
|
+
'cargo-test'(model, runner, fileEntry, unit) {
|
|
1500
|
+
if (runner.customTargets) return withheld('custom-targets');
|
|
1501
|
+
const located = model.rustTarget(runner, fileEntry, unit ? unit.line : 1);
|
|
1502
|
+
if (!located.target) return withheld(located.reason);
|
|
1503
|
+
const words = ['cargo', 'test', ...located.target];
|
|
1504
|
+
if (!unit) {
|
|
1505
|
+
if (located.segs.length > 0) words.push('--', shellWord(`${located.segs.join('::')}::`));
|
|
1506
|
+
return command(runner, words);
|
|
1507
|
+
}
|
|
1508
|
+
if (unit.notInEveryBuild) return withheld('conditional-compilation');
|
|
1509
|
+
words.push('--', shellWord([...located.segs, unit.name].join('::')), '--exact');
|
|
1510
|
+
if (unit.ignored) words.push('--include-ignored');
|
|
1511
|
+
return command(runner, words);
|
|
1512
|
+
},
|
|
1513
|
+
'maven'(model, runner, fileEntry, unit, filePath) {
|
|
1514
|
+
if (!unit) {
|
|
1515
|
+
const classes = topLevelTestClasses(model, filePath);
|
|
1516
|
+
if (classes.length === 0) return withheld('no-test-class');
|
|
1517
|
+
return command(runner, ['mvn', 'test', shellWord(`-Dtest=${classes.join(',')}`)]);
|
|
1518
|
+
}
|
|
1519
|
+
const owner = `${unit.namespace ? unit.namespace + '.' : ''}${unit.suite.join('$')}`;
|
|
1520
|
+
return command(runner, ['mvn', 'test', shellWord(`-Dtest=${owner}#${unit.name}`)]);
|
|
1521
|
+
},
|
|
1522
|
+
'dotnet-test'(model, runner, fileEntry, unit, filePath) {
|
|
1523
|
+
// The filter's own operators are escaped; '+' (a nested type) is not one.
|
|
1524
|
+
const quoted = text => String(text).replace(/[()&|=!~\\]/g, '\\$&');
|
|
1525
|
+
if (!unit) {
|
|
1526
|
+
const classes = topLevelTestClasses(model, filePath);
|
|
1527
|
+
if (classes.length === 0) return withheld('no-test-class');
|
|
1528
|
+
// A fixture built once for each set of arguments runs under
|
|
1529
|
+
// names that carry them: no filter by the class's name holds them.
|
|
1530
|
+
if ((model.declared(filePath)?.classes || []).some(cls => cls.dynamic === 'generated-cases')) {
|
|
1531
|
+
return withheld('generated-cases');
|
|
1532
|
+
}
|
|
1533
|
+
const terms = classes.flatMap(name => [`FullyQualifiedName~${quoted(name)}.`, `FullyQualifiedName~${quoted(name)}+`]);
|
|
1534
|
+
return command(runner, ['dotnet', 'test', '--filter', shellWord(terms.join('|'))]);
|
|
1535
|
+
}
|
|
1536
|
+
const owner = `${unit.namespace ? unit.namespace + '.' : ''}${unit.suite.join('+')}`;
|
|
1537
|
+
return command(runner, ['dotnet', 'test', '--filter', shellWord(`FullyQualifiedName=${quoted(`${owner}.${unit.name}`)}`)]);
|
|
1538
|
+
},
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
/** Qualified names of the outermost classes of a file that hold tests. */
|
|
1542
|
+
function topLevelTestClasses(model, filePath) {
|
|
1543
|
+
const names = new Set();
|
|
1544
|
+
for (const unit of model.unitsOf(filePath).units) {
|
|
1545
|
+
if (unit.kind !== 'test' || unit.suite.length === 0) continue;
|
|
1546
|
+
names.add(`${unit.namespace ? unit.namespace + '.' : ''}${unit.suite[0]}`);
|
|
1547
|
+
}
|
|
1548
|
+
return [...names].sort(codeUnitCompare);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ---------------------------------------------------------------------------
|
|
1552
|
+
// The model
|
|
1553
|
+
// ---------------------------------------------------------------------------
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* @param {object} index - ProjectIndex
|
|
1557
|
+
* @returns {object} per-operation test model
|
|
1558
|
+
*/
|
|
1559
|
+
function createTestModel(index, options = {}) {
|
|
1560
|
+
const model = {
|
|
1561
|
+
manifests: createManifests(index),
|
|
1562
|
+
byFile: new Map(),
|
|
1563
|
+
rawByFile: new Map(),
|
|
1564
|
+
exports: new Map(),
|
|
1565
|
+
runners: new Map(),
|
|
1566
|
+
jsConfigs: new Map(),
|
|
1567
|
+
gates: new Map(),
|
|
1568
|
+
plugins: new Map(),
|
|
1569
|
+
guards: new Map(),
|
|
1570
|
+
foreign: new Map(),
|
|
1571
|
+
ranges: new Map(),
|
|
1572
|
+
readerCaches: new Map(),
|
|
1573
|
+
root: index.root,
|
|
1574
|
+
relPaths: null,
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
model.hasFile = relativePath => {
|
|
1578
|
+
if (!model.relPaths) {
|
|
1579
|
+
model.relPaths = new Set();
|
|
1580
|
+
for (const [, entry] of index.files) model.relPaths.add(entry.relativePath);
|
|
1581
|
+
}
|
|
1582
|
+
return model.relPaths.has(relativePath);
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
/** How many indexed files under `dir` have a path that contains `part`. */
|
|
1586
|
+
model.pathsContaining = (dir, part) => {
|
|
1587
|
+
model.hasFile('');
|
|
1588
|
+
let count = 0;
|
|
1589
|
+
for (const relativePath of model.relPaths) {
|
|
1590
|
+
const inside = relativeTo(dir, relativePath);
|
|
1591
|
+
if (inside != null && inside.includes(part)) count++;
|
|
1592
|
+
}
|
|
1593
|
+
return count;
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
model.runnerOf = filePath => {
|
|
1597
|
+
if (model.runners.has(filePath)) return model.runners.get(filePath);
|
|
1598
|
+
const fileEntry = index.files.get(filePath);
|
|
1599
|
+
const family = fileEntry && langTraits(fileEntry.language)?.testRunnerFamily;
|
|
1600
|
+
const find = family && RUNNER_OF_FAMILY[family];
|
|
1601
|
+
const runner = find ? find(model, fileEntry) : { id: null, reason: 'runner-not-modeled' };
|
|
1602
|
+
model.runners.set(filePath, runner);
|
|
1603
|
+
return runner;
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
* The cargo target that compiles a file and the module path of a line
|
|
1608
|
+
* in it, from the module tree: `--lib`, `--bin NAME` or `--test NAME`.
|
|
1609
|
+
*/
|
|
1610
|
+
model.rustTarget = (runner, fileEntry, line) => {
|
|
1611
|
+
const { rustModulePathAt } = require('./rust-modules');
|
|
1612
|
+
const filePath = path.join(index.root, fileEntry.relativePath);
|
|
1613
|
+
const located = rustModulePathAt(index, index.files.has(filePath) ? filePath : fileEntry.path || filePath, line);
|
|
1614
|
+
if (!located) return { target: null, reason: 'target-unknown' };
|
|
1615
|
+
const rootEntry = index.files.get(located.root);
|
|
1616
|
+
const rootRel = rootEntry && relativeTo(runner.dir, rootEntry.relativePath);
|
|
1617
|
+
if (rootRel == null) return { target: null, reason: 'target-unknown' };
|
|
1618
|
+
const segs = located.segs;
|
|
1619
|
+
if (rootRel === 'src/lib.rs') return { target: ['--lib'], segs };
|
|
1620
|
+
if (rootRel === 'src/main.rs') {
|
|
1621
|
+
return runner.packageName
|
|
1622
|
+
? { target: ['--bin', shellWord(runner.packageName)], segs }
|
|
1623
|
+
: { target: null, reason: 'target-unknown' };
|
|
1624
|
+
}
|
|
1625
|
+
let match = /^src\/bin\/([^/]+)\.rs$/.exec(rootRel) || /^src\/bin\/([^/]+)\/main\.rs$/.exec(rootRel);
|
|
1626
|
+
if (match) return { target: ['--bin', shellWord(match[1])], segs };
|
|
1627
|
+
match = /^tests\/([^/]+)\.rs$/.exec(rootRel) || /^tests\/([^/]+)\/main\.rs$/.exec(rootRel);
|
|
1628
|
+
if (match) return { target: ['--test', shellWord(match[1])], segs };
|
|
1629
|
+
return { target: null, reason: 'target-unknown' };
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
const helpersFor = (filePath, fileEntry, depth = 0) => {
|
|
1633
|
+
const symbols = fileEntry.symbols || [];
|
|
1634
|
+
let lines = null;
|
|
1635
|
+
return {
|
|
1636
|
+
/**
|
|
1637
|
+
* What a module this file imports exports under a name, when
|
|
1638
|
+
* the module is a file of the project whose language reads
|
|
1639
|
+
* exports: followed a few modules deep, never around a cycle.
|
|
1640
|
+
*/
|
|
1641
|
+
exported(moduleName, name) {
|
|
1642
|
+
if (depth >= 4) return null;
|
|
1643
|
+
const relativePath = (fileEntry.moduleResolved || {})[moduleName];
|
|
1644
|
+
if (!relativePath) return null;
|
|
1645
|
+
const target = path.join(index.root, relativePath);
|
|
1646
|
+
const targetEntry = index.files.get(target);
|
|
1647
|
+
const read = targetEntry && langTraits(targetEntry.language)?.testExport;
|
|
1648
|
+
if (typeof read !== 'function') return null;
|
|
1649
|
+
const key = `${target}\0${name}`;
|
|
1650
|
+
if (model.exports.has(key)) return model.exports.get(key);
|
|
1651
|
+
model.exports.set(key, null);
|
|
1652
|
+
let answer;
|
|
1653
|
+
try {
|
|
1654
|
+
answer = read(targetEntry, helpersFor(target, targetEntry, depth + 1), name);
|
|
1655
|
+
} catch (e) {
|
|
1656
|
+
answer = null;
|
|
1657
|
+
}
|
|
1658
|
+
model.exports.set(key, answer);
|
|
1659
|
+
return answer;
|
|
1660
|
+
},
|
|
1661
|
+
/** Symbols whose range holds this one, outermost first. */
|
|
1662
|
+
enclosing(symbol) {
|
|
1663
|
+
const out = [];
|
|
1664
|
+
for (const other of symbols) {
|
|
1665
|
+
if (other === symbol) continue;
|
|
1666
|
+
const end = other.endLine ?? other.startLine;
|
|
1667
|
+
const ownEnd = symbol.endLine ?? symbol.startLine;
|
|
1668
|
+
if (other.startLine > symbol.startLine || end < ownEnd) continue;
|
|
1669
|
+
if (other.startLine === symbol.startLine && end === ownEnd) {
|
|
1670
|
+
// Same lines: the holder is the one named as the owner.
|
|
1671
|
+
if (!(CONTAINER_KINDS.has(other.type) && (symbol.className === other.name ||
|
|
1672
|
+
symbol.enclosingType === other.name))) continue;
|
|
1673
|
+
}
|
|
1674
|
+
if (CONTAINER_KINDS.has(other.type) || CALLABLE_KINDS.has(other.type)) out.push(other);
|
|
1675
|
+
}
|
|
1676
|
+
return out.sort((a, b) => a.startLine - b.startLine ||
|
|
1677
|
+
(b.endLine ?? b.startLine) - (a.endLine ?? a.startLine));
|
|
1678
|
+
},
|
|
1679
|
+
/**
|
|
1680
|
+
* The annotations a type of the project carries itself, when
|
|
1681
|
+
* one type of that name is declared: what an annotation of
|
|
1682
|
+
* the project stands for. Null otherwise.
|
|
1683
|
+
*/
|
|
1684
|
+
metaAnnotations(name) {
|
|
1685
|
+
const defs = (index.symbols.get(String(name).split('.').pop()) || [])
|
|
1686
|
+
.filter(def => CONTAINER_KINDS.has(def.type));
|
|
1687
|
+
if (defs.length !== 1) return null;
|
|
1688
|
+
return (defs[0].annotationsWithArgs || []).map(annotation => String(annotation.name).split('.').pop());
|
|
1689
|
+
},
|
|
1690
|
+
/** The types of this name the project declares: the namespace (package) and file of each. */
|
|
1691
|
+
typesNamed: name => (index.symbols.get(String(name).replace(/[<[(].*$/, '').split('.').pop().trim()) || [])
|
|
1692
|
+
.filter(def => CONTAINER_KINDS.has(def.type))
|
|
1693
|
+
.map(def => ({ namespace: def.namespace || '', file: index.files.get(def.file)?.relativePath || null })),
|
|
1694
|
+
/** Does the project declare a type of this name? */
|
|
1695
|
+
declaresType: name => (index.symbols.get(String(name).replace(/[<[(].*$/, '').split('.').pop().trim()) || [])
|
|
1696
|
+
.some(def => CONTAINER_KINDS.has(def.type)),
|
|
1697
|
+
isContainer: symbol => CONTAINER_KINDS.has(symbol.type),
|
|
1698
|
+
isCallable: symbol => CALLABLE_KINDS.has(symbol.type),
|
|
1699
|
+
/** Names of the types a class derives from, through the project. */
|
|
1700
|
+
ancestors(className) {
|
|
1701
|
+
const seen = new Set();
|
|
1702
|
+
const queue = [className];
|
|
1703
|
+
while (queue.length > 0 && seen.size < 64) {
|
|
1704
|
+
const parents = index.extendsGraph?.get(queue.shift()) || [];
|
|
1705
|
+
// One entry per declaration of the name, each with
|
|
1706
|
+
// the bases it writes.
|
|
1707
|
+
const names = parents.flatMap(parent => typeof parent === 'string' ? [parent]
|
|
1708
|
+
: Array.isArray(parent?.parents) ? parent.parents : parent?.name ? [parent.name] : []);
|
|
1709
|
+
for (const written of names) {
|
|
1710
|
+
const name = String(written).replace(/[<[(].*$/, '').trim();
|
|
1711
|
+
if (!name || seen.has(name)) continue;
|
|
1712
|
+
seen.add(name);
|
|
1713
|
+
queue.push(name);
|
|
1714
|
+
// A written `pkg.Base` is also known by its last name.
|
|
1715
|
+
const last = name.split('.').pop();
|
|
1716
|
+
if (last !== name && !seen.has(last)) { seen.add(last); queue.push(last); }
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return seen;
|
|
1720
|
+
},
|
|
1721
|
+
tree() {
|
|
1722
|
+
try {
|
|
1723
|
+
const content = index._readFile(filePath);
|
|
1724
|
+
return index._getParsedTree(filePath, content, fileEntry.language) ||
|
|
1725
|
+
safeParse(getParser(fileEntry.language), content) || null;
|
|
1726
|
+
} catch (e) {
|
|
1727
|
+
return null;
|
|
1728
|
+
}
|
|
1729
|
+
},
|
|
1730
|
+
lines() {
|
|
1731
|
+
if (!lines) {
|
|
1732
|
+
try {
|
|
1733
|
+
lines = index._readFile(filePath).split('\n');
|
|
1734
|
+
} catch (e) {
|
|
1735
|
+
lines = [];
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
return lines;
|
|
1739
|
+
},
|
|
1740
|
+
collection: collectionFor(filePath, fileEntry),
|
|
1741
|
+
glob: globMatches,
|
|
1742
|
+
// What a reader keeps between two questions about one file.
|
|
1743
|
+
cache: (() => {
|
|
1744
|
+
if (!model.readerCaches.has(filePath)) model.readerCaches.set(filePath, new Map());
|
|
1745
|
+
return model.readerCaches.get(filePath);
|
|
1746
|
+
})(),
|
|
1747
|
+
};
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* What the runner's configuration says about collecting a file: its
|
|
1752
|
+
* naming rules, its options, and whether the runner collects a module
|
|
1753
|
+
* of this name at all (pytest: python_files, by the file's own name).
|
|
1754
|
+
*/
|
|
1755
|
+
const collectionFor = (filePath, fileEntry) => {
|
|
1756
|
+
const runner = model.runnerOf(filePath) || {};
|
|
1757
|
+
const collection = runner.collection || {};
|
|
1758
|
+
if (runner.id !== 'pytest') return collection;
|
|
1759
|
+
const base = path.posix.basename(fileEntry.relativePath);
|
|
1760
|
+
const patterns = collection.files && collection.files.length > 0 ? collection.files : ['test_*.py', '*_test.py'];
|
|
1761
|
+
return { ...collection, collected: patterns.some(glob => globMatches(glob, base)) };
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
/** What the language's reader finds in a file; null when not modeled. */
|
|
1765
|
+
model.declared = filePath => {
|
|
1766
|
+
if (model.rawByFile.has(filePath)) return model.rawByFile.get(filePath);
|
|
1767
|
+
const fileEntry = index.files.get(filePath);
|
|
1768
|
+
const read = fileEntry && langTraits(fileEntry.language)?.testUnits;
|
|
1769
|
+
let found = null;
|
|
1770
|
+
if (typeof read === 'function') {
|
|
1771
|
+
try {
|
|
1772
|
+
found = read(fileEntry, helpersFor(filePath, fileEntry));
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
found = null;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
model.rawByFile.set(filePath, found);
|
|
1778
|
+
return found;
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* The class a base name written in `filePath` denotes: one declared in
|
|
1783
|
+
* the same file, else the only one of that name in files of the same
|
|
1784
|
+
* runner family. Null when none or several.
|
|
1785
|
+
*/
|
|
1786
|
+
const baseClass = (filePath, written) => {
|
|
1787
|
+
const name = String(written).replace(/<.*$/, '').split('.').pop().trim();
|
|
1788
|
+
if (!name) return null;
|
|
1789
|
+
const own = (model.declared(filePath)?.classes || []).filter(cls => cls.name === name);
|
|
1790
|
+
if (own.length === 1) return { filePath, cls: own[0] };
|
|
1791
|
+
if (own.length > 1) return null;
|
|
1792
|
+
const family = langTraits(index.files.get(filePath)?.language)?.testRunnerFamily;
|
|
1793
|
+
const hits = [];
|
|
1794
|
+
const seenFiles = new Set();
|
|
1795
|
+
for (const def of index.symbols.get(name) || []) {
|
|
1796
|
+
if (!CONTAINER_KINDS.has(def.type) || seenFiles.has(def.file)) continue;
|
|
1797
|
+
if (langTraits(index.files.get(def.file)?.language)?.testRunnerFamily !== family) continue;
|
|
1798
|
+
seenFiles.add(def.file);
|
|
1799
|
+
for (const cls of model.declared(def.file)?.classes || []) {
|
|
1800
|
+
if (cls.name === name) hits.push({ filePath: def.file, cls });
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return hits.length === 1 ? hits[0] : null;
|
|
1804
|
+
};
|
|
1805
|
+
|
|
1806
|
+
// What a written type name denotes and whether two methods are one
|
|
1807
|
+
// member, by the language's lookup where it is modeled.
|
|
1808
|
+
const signatures = createSignatures(index, {
|
|
1809
|
+
classes: filePath => model.declared(filePath)?.classes || [],
|
|
1810
|
+
parents: (filePath, cls) => parentsOf(filePath, cls),
|
|
1811
|
+
aliasTerm: (filePath, line) => ((model.declared(filePath)?.aliases || []).find(alias => alias.line === line) || {}).term || null,
|
|
1812
|
+
// Files one runner's manifest builds are compiled together.
|
|
1813
|
+
unitOf: filePath => {
|
|
1814
|
+
const runner = model.runnerOf(filePath);
|
|
1815
|
+
return runner && runner.id && runner.dir != null ? `${runner.id}\0${runner.dir}` : null;
|
|
1816
|
+
},
|
|
1817
|
+
});
|
|
1818
|
+
const parentMemo = new Map();
|
|
1819
|
+
const resolving = new Set();
|
|
1820
|
+
/**
|
|
1821
|
+
* The class each base written on a class denotes, in the order
|
|
1822
|
+
* written. A language whose name lookup is modeled says which class
|
|
1823
|
+
* a name is at the place it is written (a class of the project that
|
|
1824
|
+
* shares the spelling of a type from outside is not it); otherwise the
|
|
1825
|
+
* one class of that name. A base that is no class the project declares
|
|
1826
|
+
* once stands as an opaque entry, `safe` when the language's reader
|
|
1827
|
+
* knows it brings neither a test nor a constructor.
|
|
1828
|
+
*/
|
|
1829
|
+
const parentsOf = (filePath, cls) => {
|
|
1830
|
+
const key = `${filePath}\0${cls.startLine}\0${cls.name}`;
|
|
1831
|
+
if (parentMemo.has(key)) return parentMemo.get(key);
|
|
1832
|
+
const opaque = written => ({
|
|
1833
|
+
opaque: true, name: String(written).replace(/[<[(].*$/, '').split('.').pop().trim(),
|
|
1834
|
+
safe: (cls.plainBases || []).includes(written),
|
|
1835
|
+
});
|
|
1836
|
+
// A class whose bases are being resolved inside its own
|
|
1837
|
+
// resolution: the language rejects the cycle.
|
|
1838
|
+
if (resolving.has(key)) return cls.bases.map(written => ({ written, node: opaque(written) }));
|
|
1839
|
+
resolving.add(key);
|
|
1840
|
+
const out = cls.bases.map((written, i) => {
|
|
1841
|
+
const looked = signatures.baseOf(filePath, cls, (cls.baseTerms || [])[i]);
|
|
1842
|
+
const base = looked === undefined ? baseClass(filePath, written) : looked;
|
|
1843
|
+
return { written, node: base || opaque(written) };
|
|
1844
|
+
});
|
|
1845
|
+
resolving.delete(key);
|
|
1846
|
+
parentMemo.set(key, out);
|
|
1847
|
+
return out;
|
|
1848
|
+
};
|
|
1849
|
+
|
|
1850
|
+
const nodeKey = node => node.opaque ? `?${node.name}` : `${node.filePath}\0${node.cls.startLine}\0${node.cls.name}`;
|
|
1851
|
+
const linearMemo = new Map();
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* The order in which a class's members are looked up: the class, then
|
|
1855
|
+
* the classes it derives from. `c3` is Python's method resolution
|
|
1856
|
+
* order; otherwise depth first in the order written (a superclass
|
|
1857
|
+
* before the interfaces). A base that is no class of the project
|
|
1858
|
+
* stands in the order as an opaque entry (`parentsOf`).
|
|
1859
|
+
*/
|
|
1860
|
+
const linearize = (filePath, cls, mode, trail = new Set()) => {
|
|
1861
|
+
const key = `${filePath}\0${cls.startLine}\0${cls.name}`;
|
|
1862
|
+
if (linearMemo.has(key)) return linearMemo.get(key);
|
|
1863
|
+
const self = { filePath, cls };
|
|
1864
|
+
if (trail.has(key)) return [self];
|
|
1865
|
+
if (cls.imported) {
|
|
1866
|
+
// A class bound by an import is the class it names.
|
|
1867
|
+
const order = [self, ...linearize(cls.imported.filePath, cls.imported.cls, mode, trail)];
|
|
1868
|
+
linearMemo.set(key, order);
|
|
1869
|
+
return order;
|
|
1870
|
+
}
|
|
1871
|
+
trail.add(key);
|
|
1872
|
+
const parents = parentsOf(filePath, cls).map(parent => parent.node);
|
|
1873
|
+
const below = parent => parent.opaque ? [parent] : linearize(parent.filePath, parent.cls, mode, trail);
|
|
1874
|
+
let order = [self];
|
|
1875
|
+
if (mode === 'c3') {
|
|
1876
|
+
const sequences = [...parents.map(parent => below(parent).slice()), parents.slice()];
|
|
1877
|
+
while (sequences.some(sequence => sequence.length > 0)) {
|
|
1878
|
+
const head = sequences.filter(sequence => sequence.length > 0).map(sequence => sequence[0])
|
|
1879
|
+
.find(candidate => !sequences.some(other =>
|
|
1880
|
+
other.slice(1).some(node => nodeKey(node) === nodeKey(candidate))));
|
|
1881
|
+
if (!head) {
|
|
1882
|
+
// No consistent order: the language rejects the class.
|
|
1883
|
+
order = [self, { opaque: true, name: '', safe: false }];
|
|
1884
|
+
break;
|
|
1885
|
+
}
|
|
1886
|
+
order.push(head);
|
|
1887
|
+
for (const sequence of sequences) {
|
|
1888
|
+
if (sequence.length > 0 && nodeKey(sequence[0]) === nodeKey(head)) sequence.shift();
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
} else {
|
|
1892
|
+
const seen = new Set([key]);
|
|
1893
|
+
for (const parent of parents) {
|
|
1894
|
+
for (const node of below(parent)) {
|
|
1895
|
+
if (seen.has(nodeKey(node))) continue;
|
|
1896
|
+
seen.add(nodeKey(node));
|
|
1897
|
+
order.push(node);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
trail.delete(key);
|
|
1902
|
+
linearMemo.set(key, order);
|
|
1903
|
+
return order;
|
|
1904
|
+
};
|
|
1905
|
+
const orderOf = (filePath, cls) => linearize(filePath, cls, model.declared(filePath)?.linearization || null);
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Tests a runnable class runs without declaring them: for each
|
|
1909
|
+
* test-shaped name, the first class in lookup order that declares a
|
|
1910
|
+
* member of that name. A test found behind a base that is not read is
|
|
1911
|
+
* not attributed (`withheld`): that base may declare the name itself.
|
|
1912
|
+
*
|
|
1913
|
+
* Which member of a nearer class is "the same member" is the
|
|
1914
|
+
* language's and the runner's to say (`hiddenBy` on a test): by name
|
|
1915
|
+
* where a name is all a member has (Python; and MSTest, which takes
|
|
1916
|
+
* one method of each name), by signature where a language overloads
|
|
1917
|
+
* (a method of the same name with other parameters is another method
|
|
1918
|
+
* and hides nothing), or only a declared override of that signature
|
|
1919
|
+
* (NUnit: a method that hides by `new` leaves the inherited test
|
|
1920
|
+
* running).
|
|
1921
|
+
*
|
|
1922
|
+
* Two signatures are the same when their parameters have the same
|
|
1923
|
+
* types, and a type is what its name denotes where it is written
|
|
1924
|
+
* (`signatures.relate`): the answer can be that this is not known.
|
|
1925
|
+
* Then the test may be the class's or may not (`uncertain`): it is
|
|
1926
|
+
* listed as one that runs only sometimes, under no command, with
|
|
1927
|
+
* every body it may run (`maybe`).
|
|
1928
|
+
*
|
|
1929
|
+
* Where the runner takes a test from the nearest declaration that
|
|
1930
|
+
* carries its mark (`byDeclaration`: JUnit 4, TestNG, xUnit), a class
|
|
1931
|
+
* nearer that overrides the method without the mark does not hide it:
|
|
1932
|
+
* the test runs with that class's body (`body`).
|
|
1933
|
+
*/
|
|
1934
|
+
const inheritedBy = (filePath, cls, select = null) => {
|
|
1935
|
+
const out = [];
|
|
1936
|
+
// What the classes nearer than the one looked at declare, nearest
|
|
1937
|
+
// first: every member by name, the methods with what they are
|
|
1938
|
+
// compared by, and the tests.
|
|
1939
|
+
const names = new Set();
|
|
1940
|
+
const testedNames = new Set();
|
|
1941
|
+
const members = [];
|
|
1942
|
+
const tested = [];
|
|
1943
|
+
const declare = (node, each) => {
|
|
1944
|
+
for (const member of each.members || []) names.add(member);
|
|
1945
|
+
// A method that is a test is compared as one: its row among
|
|
1946
|
+
// the members is the one that shares its parameter list (two
|
|
1947
|
+
// methods can start on one line).
|
|
1948
|
+
const lists = new Set((each.tests || []).map(test => test.params).filter(Boolean));
|
|
1949
|
+
const lines = new Set((each.tests || []).filter(test => !test.params).map(test => test.startLine));
|
|
1950
|
+
for (const row of each.memberRows || []) {
|
|
1951
|
+
if (!(row.params ? lists.has(row.params) : lines.has(row.startLine))) members.push({ row, at: node });
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
const sideOf = ({ row, at }) => ({ filePath: at.filePath, cls: at.cls, row });
|
|
1955
|
+
/**
|
|
1956
|
+
* A declared override overrides one method: where the classes
|
|
1957
|
+
* above it are all read and declare one method of that name with
|
|
1958
|
+
* as many parameters, it is that one, however its types are
|
|
1959
|
+
* written.
|
|
1960
|
+
*/
|
|
1961
|
+
const onlySlot = (member, test, node) => {
|
|
1962
|
+
const order = orderOf(member.at.filePath, member.at.cls).slice(1);
|
|
1963
|
+
if (order.some(above => above.opaque)) return false;
|
|
1964
|
+
const arity = (member.row.params || []).length;
|
|
1965
|
+
const universal = langTraits(index.files.get(member.at.filePath)?.language)?.universalSupertypeMembers || {};
|
|
1966
|
+
if (universal[member.row.name] === arity) return false;
|
|
1967
|
+
const slots = order.flatMap(above => (above.cls.memberRows || [])
|
|
1968
|
+
.filter(row => row.name === member.row.name && (row.params || []).length === arity)
|
|
1969
|
+
.map(row => ({ row, at: above })));
|
|
1970
|
+
return slots.length > 0 && slots.every(slot =>
|
|
1971
|
+
(slot.at.cls === node.cls && slot.row.startLine === test.startLine) ||
|
|
1972
|
+
signatures.relate(sideOf(slot), { filePath: node.filePath, cls: node.cls, row: test }) === 'same');
|
|
1973
|
+
};
|
|
1974
|
+
const start = { filePath, cls };
|
|
1975
|
+
declare(start, cls);
|
|
1976
|
+
for (const test of cls.tests || []) {
|
|
1977
|
+
names.add(test.name);
|
|
1978
|
+
testedNames.add(test.name);
|
|
1979
|
+
tested.push({ row: test, at: start });
|
|
1980
|
+
}
|
|
1981
|
+
let behindUnknown = false;
|
|
1982
|
+
let withheldAny = false;
|
|
1983
|
+
for (const node of orderOf(filePath, cls).slice(1)) {
|
|
1984
|
+
if (node.opaque) {
|
|
1985
|
+
if (!node.safe) behindUnknown = true;
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
for (const test of node.cls.tests) {
|
|
1989
|
+
// A test its runner runs in the declaring class only.
|
|
1990
|
+
if (test.ownOnly) continue;
|
|
1991
|
+
const rule = test.hiddenBy || 'name';
|
|
1992
|
+
const mine = { filePath: node.filePath, cls: node.cls, row: test };
|
|
1993
|
+
let body = null;
|
|
1994
|
+
let uncertain = false;
|
|
1995
|
+
const maybe = [];
|
|
1996
|
+
let again = false;
|
|
1997
|
+
if (rule === 'name') {
|
|
1998
|
+
// A nearer class declares the same test.
|
|
1999
|
+
if (testedNames.has(test.name)) {
|
|
2000
|
+
// A runner that takes one method of each name
|
|
2001
|
+
// reports the inherited test too under a test
|
|
2002
|
+
// that hides it without overriding it, and runs
|
|
2003
|
+
// the nearer body for it (`againUnderHiding`:
|
|
2004
|
+
// measured with one version of MSTest, so the
|
|
2005
|
+
// test is listed as one that runs only sometimes).
|
|
2006
|
+
const nearer = tested.find(other => other.row.name === test.name);
|
|
2007
|
+
if (!test.againUnderHiding || !nearer || nearer.row.overrides || nearer.again) continue;
|
|
2008
|
+
body = nearer;
|
|
2009
|
+
again = true;
|
|
2010
|
+
tested.push({ row: test, at: node, again: true });
|
|
2011
|
+
} else {
|
|
2012
|
+
testedNames.add(test.name);
|
|
2013
|
+
tested.push({ row: test, at: node });
|
|
2014
|
+
// A nearer class declares the same member without the mark.
|
|
2015
|
+
if (names.has(test.name) && !test.byDeclaration) continue;
|
|
2016
|
+
}
|
|
2017
|
+
} else {
|
|
2018
|
+
// A runner that takes a method as replaced by one whose
|
|
2019
|
+
// parameters could be passed to it: another signature
|
|
2020
|
+
// is no proof the test is still the class's.
|
|
2021
|
+
const relate = other => {
|
|
2022
|
+
const relation = signatures.relate(mine, sideOf(other));
|
|
2023
|
+
return relation === 'different' && test.replacedByAssignable &&
|
|
2024
|
+
(other.row.params || []).length === (test.params || []).length ? 'unknown' : relation;
|
|
2025
|
+
};
|
|
2026
|
+
// A method the language does not let a class override
|
|
2027
|
+
// (`sealed`: always; `sealedOutside`: from another
|
|
2028
|
+
// package) is not replaced by an unmarked one of its
|
|
2029
|
+
// signature: the two are two methods, and the test
|
|
2030
|
+
// keeps its body. (A marked one is the class's test of
|
|
2031
|
+
// that signature: measured.)
|
|
2032
|
+
const overrides = other => !test.sealed &&
|
|
2033
|
+
(test.sealedOutside == null || (other.at.cls.namespace || '') === test.sealedOutside);
|
|
2034
|
+
// A nearer class declares the same test. (Where only a
|
|
2035
|
+
// declared override takes a test away, a test that
|
|
2036
|
+
// hides it by another declaration runs beside it.)
|
|
2037
|
+
let hidden = false;
|
|
2038
|
+
for (const other of tested) {
|
|
2039
|
+
if (other.row.name !== test.name || (rule === 'override' && !other.row.overrides)) continue;
|
|
2040
|
+
const relation = relate(other);
|
|
2041
|
+
if (relation === 'same') {
|
|
2042
|
+
hidden = true;
|
|
2043
|
+
break;
|
|
2044
|
+
}
|
|
2045
|
+
if (relation === 'unknown') uncertain = true;
|
|
2046
|
+
}
|
|
2047
|
+
testedNames.add(test.name);
|
|
2048
|
+
tested.push({ row: test, at: node });
|
|
2049
|
+
if (hidden) continue;
|
|
2050
|
+
// A nearer class declares the same member without the
|
|
2051
|
+
// mark: the nearest that is it for certain, and the
|
|
2052
|
+
// nearer ones that may be.
|
|
2053
|
+
for (const other of members) {
|
|
2054
|
+
if (other.row.name !== test.name || (rule === 'override' && !other.row.overrides) || !overrides(other)) continue;
|
|
2055
|
+
let relation = relate(other);
|
|
2056
|
+
if (relation === 'unknown' && other.row.overrides && onlySlot(other, test, node)) relation = 'same';
|
|
2057
|
+
if (relation === 'same') {
|
|
2058
|
+
body = other;
|
|
2059
|
+
break;
|
|
2060
|
+
}
|
|
2061
|
+
if (relation === 'unknown') maybe.push(other);
|
|
2062
|
+
}
|
|
2063
|
+
if (body && !test.byDeclaration) continue;
|
|
2064
|
+
// A class declares one method of a signature: beside
|
|
2065
|
+
// the one that is it, another of that class is not.
|
|
2066
|
+
const open = maybe.filter(other => !body || other.at.cls !== body.at.cls);
|
|
2067
|
+
maybe.length = 0;
|
|
2068
|
+
maybe.push(...open);
|
|
2069
|
+
if (maybe.length > 0) uncertain = true;
|
|
2070
|
+
}
|
|
2071
|
+
// A member the runner does not take as a test still hides
|
|
2072
|
+
// the one of that name further up.
|
|
2073
|
+
if (select && !select(test)) continue;
|
|
2074
|
+
if (behindUnknown) withheldAny = true;
|
|
2075
|
+
else {
|
|
2076
|
+
out.push({
|
|
2077
|
+
test, base: node,
|
|
2078
|
+
...((test.byDeclaration || again) && body && { body }),
|
|
2079
|
+
...(uncertain && { uncertain: true, maybe }),
|
|
2080
|
+
...(again && { again: true }),
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
declare(node, node.cls);
|
|
2085
|
+
for (const test of node.cls.tests) names.add(test.name);
|
|
2086
|
+
}
|
|
2087
|
+
return { tests: out, open: behindUnknown, withheld: withheldAny };
|
|
2088
|
+
};
|
|
2089
|
+
|
|
2090
|
+
/**
|
|
2091
|
+
* Is there code beside a test file that the runner lets decide which
|
|
2092
|
+
* tests run (the language's `testGate`): a TestMain in its Go package
|
|
2093
|
+
* that may not run the tests. (pytest's conftest.py files are read
|
|
2094
|
+
* with the modules they stand above: see guardsOf.)
|
|
2095
|
+
*/
|
|
2096
|
+
const gatedAtRunTime = (filePath, runner) => {
|
|
2097
|
+
const relativePath = index.files.get(filePath).relativePath;
|
|
2098
|
+
const decides = candidate => {
|
|
2099
|
+
if (!model.gates.has(candidate)) {
|
|
2100
|
+
const entry = index.files.get(candidate);
|
|
2101
|
+
const gate = entry && langTraits(entry.language)?.testGate;
|
|
2102
|
+
let answer = false;
|
|
2103
|
+
if (typeof gate === 'function') {
|
|
2104
|
+
try {
|
|
2105
|
+
answer = !!gate(entry, helpersFor(candidate, entry));
|
|
2106
|
+
} catch (e) {
|
|
2107
|
+
answer = true;
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
model.gates.set(candidate, answer);
|
|
2111
|
+
}
|
|
2112
|
+
return model.gates.get(candidate);
|
|
2113
|
+
};
|
|
2114
|
+
if (runner.id === 'go-test') {
|
|
2115
|
+
if (!model.goTestFiles) {
|
|
2116
|
+
model.goTestFiles = new Map();
|
|
2117
|
+
for (const [candidate, entry] of index.files) {
|
|
2118
|
+
if (!/_test\.go$/.test(entry.relativePath)) continue;
|
|
2119
|
+
const dir = posixDir(entry.relativePath);
|
|
2120
|
+
if (!model.goTestFiles.has(dir)) model.goTestFiles.set(dir, []);
|
|
2121
|
+
model.goTestFiles.get(dir).push(candidate);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
return (model.goTestFiles.get(posixDir(relativePath)) || []).some(decides);
|
|
2125
|
+
}
|
|
2126
|
+
return false;
|
|
2127
|
+
};
|
|
2128
|
+
|
|
2129
|
+
// ---- a reader that gives facts per file (`derived`): pytest ----------
|
|
2130
|
+
|
|
2131
|
+
/** The project file a module name written in a file resolves to, or null. */
|
|
2132
|
+
const moduleFile = (filePath, moduleName) => {
|
|
2133
|
+
const relative = (index.files.get(filePath)?.moduleResolved || {})[moduleName];
|
|
2134
|
+
const target = relative ? path.join(index.root, relative) : null;
|
|
2135
|
+
return target && index.files.has(target) ? target : null;
|
|
2136
|
+
};
|
|
2137
|
+
|
|
2138
|
+
/** The project file a dotted module name stands for, from the runner's directory or the project's. */
|
|
2139
|
+
const moduleNamed = (filePath, dotted) => {
|
|
2140
|
+
const runner = model.runnerOf(filePath) || {};
|
|
2141
|
+
const relative = String(dotted).replace(/\./g, '/');
|
|
2142
|
+
for (const base of new Set([runner.dir || '', ''])) {
|
|
2143
|
+
for (const tail of [`${relative}.py`, `${relative}/__init__.py`]) {
|
|
2144
|
+
const candidate = base ? `${base}/${tail}` : tail;
|
|
2145
|
+
if (model.hasFile(candidate)) return path.join(index.root, candidate);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
return null;
|
|
2149
|
+
};
|
|
2150
|
+
|
|
2151
|
+
/**
|
|
2152
|
+
* Does a module declare tests where one of its functions is called
|
|
2153
|
+
* (a declaration inside a function that is no suite's callback), itself
|
|
2154
|
+
* or through the modules whose functions it calls?
|
|
2155
|
+
*/
|
|
2156
|
+
const declaresWhenCalled = (filePath, seen = new Set()) => {
|
|
2157
|
+
if (seen.has(filePath)) return false;
|
|
2158
|
+
seen.add(filePath);
|
|
2159
|
+
const found = model.declared(filePath);
|
|
2160
|
+
if (!found) return false;
|
|
2161
|
+
if ((found.units || []).some(unit => unit.kind === 'test' &&
|
|
2162
|
+
(unit.indirect || unit.dynamic === 'declared-indirectly'))) return true;
|
|
2163
|
+
return (found.calledWhenCalled || []).some(name => {
|
|
2164
|
+
const target = moduleFile(filePath, name);
|
|
2165
|
+
return !!target && declaresWhenCalled(target, seen);
|
|
2166
|
+
});
|
|
2167
|
+
};
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* What a name of a module is, as far as tests go: a class or a function
|
|
2171
|
+
* of its top, or what another name of the module or another module
|
|
2172
|
+
* gives it (an alias, an import, a star import), a few steps deep.
|
|
2173
|
+
*/
|
|
2174
|
+
const resolveName = (filePath, name, depth = 0) => {
|
|
2175
|
+
if (!filePath || depth > 6) return null;
|
|
2176
|
+
const found = model.declared(filePath);
|
|
2177
|
+
if (!found) return null;
|
|
2178
|
+
const cls = (found.classes || []).find(other => other.name === name && other.suite.length === 0);
|
|
2179
|
+
if (cls) return { kind: 'class', filePath, cls };
|
|
2180
|
+
const fn = (found.functions || []).find(other => other.name === name);
|
|
2181
|
+
if (fn) return { kind: 'function', filePath, fn };
|
|
2182
|
+
for (const entry of found.imports || []) {
|
|
2183
|
+
if (entry.name !== name) continue;
|
|
2184
|
+
const from = entry.module == null ? filePath : moduleFile(filePath, entry.module);
|
|
2185
|
+
if (from === filePath && entry.original === name) continue;
|
|
2186
|
+
const hit = resolveName(from, entry.original, depth + 1);
|
|
2187
|
+
if (hit) return hit;
|
|
2188
|
+
}
|
|
2189
|
+
for (const star of found.stars || []) {
|
|
2190
|
+
const from = moduleFile(filePath, star.module);
|
|
2191
|
+
const there = from ? model.declared(from) : null;
|
|
2192
|
+
if (!there || there.exportsAll === 'unknown') continue;
|
|
2193
|
+
if (there.exportsAll ? !there.exportsAll.includes(name) : name.startsWith('_')) continue;
|
|
2194
|
+
const hit = resolveName(from, name, depth + 1);
|
|
2195
|
+
if (hit) return hit;
|
|
2196
|
+
}
|
|
2197
|
+
return null;
|
|
2198
|
+
};
|
|
2199
|
+
|
|
2200
|
+
/**
|
|
2201
|
+
* Switches other modules write on the classes and functions of a
|
|
2202
|
+
* module, each on a name it took from it. The object is one: a module
|
|
2203
|
+
* the runner loads after the writer sees the write, so what such a
|
|
2204
|
+
* class or function is depends on the order of the run. Looked for
|
|
2205
|
+
* among the modules that import this one.
|
|
2206
|
+
*/
|
|
2207
|
+
const switchedElsewhere = filePath => {
|
|
2208
|
+
if (model.foreign.has(filePath)) return model.foreign.get(filePath);
|
|
2209
|
+
const answer = { classes: new Map(), functions: new Map() };
|
|
2210
|
+
model.foreign.set(filePath, answer);
|
|
2211
|
+
const add = (map, key, write) => {
|
|
2212
|
+
if (!map.has(key)) map.set(key, []);
|
|
2213
|
+
map.get(key).push(write);
|
|
2214
|
+
};
|
|
2215
|
+
for (const importer of [...(index.exportGraph?.get(filePath) || [])].sort(codeUnitCompare)) {
|
|
2216
|
+
if (importer === filePath || !index.files.has(importer)) continue;
|
|
2217
|
+
let text;
|
|
2218
|
+
try {
|
|
2219
|
+
text = index._readFile(importer);
|
|
2220
|
+
} catch (e) {
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
if (!text.includes('__test__')) continue;
|
|
2224
|
+
const there = model.declared(importer);
|
|
2225
|
+
const switches = there && there.derived && there.module ? there.module.switches || {} : {};
|
|
2226
|
+
for (const name of Object.keys(switches).sort(codeUnitCompare)) {
|
|
2227
|
+
const hit = resolveName(importer, name);
|
|
2228
|
+
if (!hit || hit.filePath !== filePath) continue;
|
|
2229
|
+
const write = { on: switches[name].on, forced: switches[name].forced, by: importer };
|
|
2230
|
+
if (hit.kind === 'class') add(answer.classes, hit.cls, write);
|
|
2231
|
+
else add(answer.functions, hit.fn.name, write);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
return answer;
|
|
2235
|
+
};
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* Does one of the functions a file calls in other modules of the
|
|
2239
|
+
* project stop a test when called (it skips, raises, or calls a
|
|
2240
|
+
* function of its own module that does)? One module deep.
|
|
2241
|
+
*/
|
|
2242
|
+
const callStops = (filePath, calls) => (calls || []).some(call => {
|
|
2243
|
+
const target = moduleFile(filePath, call.module) || moduleNamed(filePath, call.module);
|
|
2244
|
+
const there = target ? model.declared(target) : null;
|
|
2245
|
+
return !!there && (there.stopping || []).includes(call.name);
|
|
2246
|
+
});
|
|
2247
|
+
/**
|
|
2248
|
+
* What a name of a module is as a row of a table of parameters
|
|
2249
|
+
* ('run', 'skip', 'sometimes'), read in the module that binds it and,
|
|
2250
|
+
* where that module took it from another, in that one: a few modules
|
|
2251
|
+
* deep. Null: the module does not bind it.
|
|
2252
|
+
*/
|
|
2253
|
+
const readRow = (file, name, member, call, depth) => {
|
|
2254
|
+
const there = file ? model.declared(file) : null;
|
|
2255
|
+
if (!there || typeof there.rowNamed !== 'function') return null;
|
|
2256
|
+
const state = there.rowNamed(name, member, call);
|
|
2257
|
+
if (!state || typeof state !== 'object') return state;
|
|
2258
|
+
if (depth >= 4) return 'sometimes';
|
|
2259
|
+
if (state.via) return viaLookup(file, state.via, depth + 1) ?? 'sometimes';
|
|
2260
|
+
// A name a star import brings: read in the module that binds it.
|
|
2261
|
+
for (const module of state.stars || []) {
|
|
2262
|
+
const found = viaLookup(file, { module, name, ...(member && { member }), call }, depth + 1);
|
|
2263
|
+
if (found != null) return found;
|
|
2264
|
+
}
|
|
2265
|
+
return null;
|
|
2266
|
+
};
|
|
2267
|
+
const viaLookup = (filePath, via, depth) => {
|
|
2268
|
+
const direct = moduleFile(filePath, via.module) || moduleNamed(filePath, via.module);
|
|
2269
|
+
const state = readRow(direct, via.name, via.member || null, !!via.call, depth);
|
|
2270
|
+
if (state != null) return state;
|
|
2271
|
+
// Not a name of that module: a module of that package.
|
|
2272
|
+
let inner = null;
|
|
2273
|
+
if (direct && path.basename(direct) === '__init__.py') {
|
|
2274
|
+
for (const tail of [`${via.name}.py`, path.join(via.name, '__init__.py')]) {
|
|
2275
|
+
const candidate = path.join(path.dirname(direct), tail);
|
|
2276
|
+
if (!inner && index.files.has(candidate)) inner = candidate;
|
|
2277
|
+
}
|
|
2278
|
+
} else if (!direct && !String(via.module).startsWith('.')) {
|
|
2279
|
+
inner = moduleNamed(filePath, `${via.module}.${via.name}`);
|
|
2280
|
+
}
|
|
2281
|
+
if (!inner || !model.declared(inner)) return null;
|
|
2282
|
+
// The module itself is a plain row; calling it is not read.
|
|
2283
|
+
if (!via.member) return via.call ? 'sometimes' : 'run';
|
|
2284
|
+
return readRow(inner, via.member, null, !!via.call, depth);
|
|
2285
|
+
};
|
|
2286
|
+
/** The same for a name written in `filePath`: what is not found may be anything. */
|
|
2287
|
+
const viaState = (filePath, via) => viaLookup(filePath, via, 0) ?? 'sometimes';
|
|
2288
|
+
/**
|
|
2289
|
+
* What tables of parameters whose rows other modules name leave of a
|
|
2290
|
+
* test written in `filePath`: 'skip' (one of them has no row that runs
|
|
2291
|
+
* the body), 'sometimes' (one may have none), or null.
|
|
2292
|
+
*/
|
|
2293
|
+
const tablesState = (filePath, tables) => {
|
|
2294
|
+
let answer = null;
|
|
2295
|
+
for (const table of tables || []) {
|
|
2296
|
+
const states = table.via.map(via => viaState(filePath, via));
|
|
2297
|
+
if (states.includes('run')) continue;
|
|
2298
|
+
if (table.rest === 'skip' && states.every(state => state === 'skip')) return 'skip';
|
|
2299
|
+
answer = 'sometimes';
|
|
2300
|
+
}
|
|
2301
|
+
return answer;
|
|
2302
|
+
};
|
|
2303
|
+
/** A file's fixtures, with what the functions they call and the rows they name elsewhere add. */
|
|
2304
|
+
const fixturesOf = (filePath, fixtures) => (fixtures || []).map(fixture =>
|
|
2305
|
+
!fixture.decides && (callStops(filePath, fixture.calls) || tablesState(filePath, fixture.tables))
|
|
2306
|
+
? { ...fixture, decides: true } : fixture);
|
|
2307
|
+
|
|
2308
|
+
/**
|
|
2309
|
+
* What a file does when the runner loads it as a plugin (a conftest.py,
|
|
2310
|
+
* a module another names as one), with the modules it loads in turn:
|
|
2311
|
+
* whether a hook can change what is collected or run, whether it has
|
|
2312
|
+
* the hook a test module may have too, and the fixtures it brings.
|
|
2313
|
+
*/
|
|
2314
|
+
const pluginFacts = (filePath, seen = new Set()) => {
|
|
2315
|
+
const top = seen.size === 0;
|
|
2316
|
+
if (top && model.plugins.has(filePath)) return model.plugins.get(filePath);
|
|
2317
|
+
if (seen.has(filePath)) return { decides: false, everywhere: false, generates: false, fixtures: [] };
|
|
2318
|
+
seen.add(filePath);
|
|
2319
|
+
const found = model.declared(filePath);
|
|
2320
|
+
const runtime = found && found.runtime;
|
|
2321
|
+
// A plugin that is not read may do anything.
|
|
2322
|
+
const answer = {
|
|
2323
|
+
decides: !runtime || runtime.decides,
|
|
2324
|
+
// A hook pytest calls once for the whole run decides for
|
|
2325
|
+
// every test of it, wherever the file stands.
|
|
2326
|
+
everywhere: !runtime || !!runtime.everywhere,
|
|
2327
|
+
generates: !runtime || !!runtime.generates,
|
|
2328
|
+
fixtures: runtime ? fixturesOf(filePath, runtime.fixtures) : [],
|
|
2329
|
+
};
|
|
2330
|
+
// A hook that calls a function of another module of the project
|
|
2331
|
+
// that can stop a test decides as if it stopped it itself.
|
|
2332
|
+
for (const call of (runtime && runtime.hookCalls) || []) {
|
|
2333
|
+
if (!callStops(filePath, [call])) continue;
|
|
2334
|
+
answer.decides = true;
|
|
2335
|
+
if (call.everywhere) answer.everywhere = true;
|
|
2336
|
+
if (call.generates) answer.generates = true;
|
|
2337
|
+
}
|
|
2338
|
+
for (const plugin of runtime ? runtime.plugins : []) {
|
|
2339
|
+
const target = plugin.by === 'import' ? moduleFile(filePath, plugin.module) : moduleNamed(filePath, plugin.module);
|
|
2340
|
+
// A module outside the project is a plugin like any installed one.
|
|
2341
|
+
if (!target) continue;
|
|
2342
|
+
const inner = pluginFacts(target, seen);
|
|
2343
|
+
answer.decides = answer.decides || inner.decides;
|
|
2344
|
+
answer.generates = answer.generates || inner.generates;
|
|
2345
|
+
// A module named as a plugin is one for the whole run.
|
|
2346
|
+
if (inner.everywhere || (plugin.by === 'name' && inner.decides)) answer.everywhere = true;
|
|
2347
|
+
answer.fixtures.push(...inner.fixtures);
|
|
2348
|
+
}
|
|
2349
|
+
// Fixtures taken by name from other modules of the project.
|
|
2350
|
+
for (const entry of (found && found.imports) || []) {
|
|
2351
|
+
const target = entry.module == null ? null : moduleFile(filePath, entry.module);
|
|
2352
|
+
const there = target ? model.declared(target) : null;
|
|
2353
|
+
const fixture = there && there.runtime
|
|
2354
|
+
? fixturesOf(target, there.runtime.fixtures).find(other => other.function === entry.original) : null;
|
|
2355
|
+
if (fixture) answer.fixtures.push(fixture.name === fixture.function ? { ...fixture, name: entry.name } : fixture);
|
|
2356
|
+
}
|
|
2357
|
+
if (top) model.plugins.set(filePath, answer);
|
|
2358
|
+
return answer;
|
|
2359
|
+
};
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* The fixture names that can stop a test: those that decide, and those
|
|
2363
|
+
* that ask for one that does. With `is`: the names that reach a
|
|
2364
|
+
* fixture of that kind instead.
|
|
2365
|
+
*/
|
|
2366
|
+
const stoppingNames = (fixtures, is = fixture => fixture.decides) => {
|
|
2367
|
+
const names = new Set();
|
|
2368
|
+
for (const fixture of fixtures) if (is(fixture) && fixture.name != null) names.add(fixture.name);
|
|
2369
|
+
let grew = true;
|
|
2370
|
+
while (grew) {
|
|
2371
|
+
grew = false;
|
|
2372
|
+
for (const fixture of fixtures) {
|
|
2373
|
+
if (fixture.name == null || names.has(fixture.name) || !fixture.requests.some(name => names.has(name))) continue;
|
|
2374
|
+
names.add(fixture.name);
|
|
2375
|
+
grew = true;
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
return names;
|
|
2379
|
+
};
|
|
2380
|
+
|
|
2381
|
+
/** Does a fixture every test gets have a table of parameters, its own or one it asks for? */
|
|
2382
|
+
const everyTabled = (fixtures, tabled) => fixtures.some(fixture => (fixture.autouse || fixture.name == null) &&
|
|
2383
|
+
(!!fixture.params || fixture.requests.some(name => tabled.has(name))));
|
|
2384
|
+
|
|
2385
|
+
/**
|
|
2386
|
+
* What the code beside a test module can do to its tests before their
|
|
2387
|
+
* bodies run: the conftest.py files above it, the plugins those and the
|
|
2388
|
+
* module name, the module's own fixtures. `gated`: every test of the
|
|
2389
|
+
* module runs only sometimes (a hook can change what is collected or
|
|
2390
|
+
* run, a fixture every test gets can stop it). `fixtures`: the ones a
|
|
2391
|
+
* test of the module can ask for.
|
|
2392
|
+
*/
|
|
2393
|
+
const guardsOf = filePath => {
|
|
2394
|
+
if (model.guards.has(filePath)) return model.guards.get(filePath);
|
|
2395
|
+
const relativePath = index.files.get(filePath).relativePath;
|
|
2396
|
+
const fixtures = [];
|
|
2397
|
+
let gated = false;
|
|
2398
|
+
let generates = false;
|
|
2399
|
+
// The conftest.py files a run of this file alone loads whose hooks
|
|
2400
|
+
// decide for the whole run.
|
|
2401
|
+
const wholeRun = [];
|
|
2402
|
+
for (const dir of ancestorDirs(relativePath)) {
|
|
2403
|
+
const conftest = path.join(index.root, dir, 'conftest.py');
|
|
2404
|
+
if (conftest === filePath || !index.files.has(conftest)) continue;
|
|
2405
|
+
const facts = pluginFacts(conftest);
|
|
2406
|
+
if (facts.decides) gated = true;
|
|
2407
|
+
if (facts.generates) generates = true;
|
|
2408
|
+
if (facts.everywhere) wholeRun.push(index.files.get(conftest).relativePath);
|
|
2409
|
+
fixtures.push(...facts.fixtures);
|
|
2410
|
+
}
|
|
2411
|
+
// The module itself is no plugin: of its own hooks pytest calls
|
|
2412
|
+
// the one that generates tests; the modules it names are plugins.
|
|
2413
|
+
const own = pluginFacts(filePath);
|
|
2414
|
+
const runtime = model.declared(filePath)?.runtime;
|
|
2415
|
+
if (own.generates || (own.everywhere && !(runtime && runtime.everywhere))) gated = true;
|
|
2416
|
+
if (own.generates) generates = true;
|
|
2417
|
+
fixtures.push(...own.fixtures);
|
|
2418
|
+
// A conftest.py anywhere under the runner's directory whose hook
|
|
2419
|
+
// is called for the whole run: it is loaded with any run that
|
|
2420
|
+
// collects a file below it, and decides for every test of that run.
|
|
2421
|
+
const runner = model.runnerOf(filePath) || {};
|
|
2422
|
+
if (!gated) {
|
|
2423
|
+
if (!model.conftests) {
|
|
2424
|
+
model.hasFile('');
|
|
2425
|
+
model.conftests = [...model.relPaths].filter(other => path.posix.basename(other) === 'conftest.py')
|
|
2426
|
+
.sort(codeUnitCompare);
|
|
2427
|
+
}
|
|
2428
|
+
for (const other of model.conftests) {
|
|
2429
|
+
if (relativeTo(runner.dir, other) == null) continue;
|
|
2430
|
+
const conftest = path.join(index.root, other);
|
|
2431
|
+
if (conftest !== filePath && pluginFacts(conftest).everywhere) {
|
|
2432
|
+
gated = true;
|
|
2433
|
+
break;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
const stopping = stoppingNames(fixtures);
|
|
2438
|
+
if (fixtures.some(fixture => (fixture.autouse || fixture.name == null) &&
|
|
2439
|
+
(fixture.decides || fixture.requests.some(name => stopping.has(name))))) gated = true;
|
|
2440
|
+
const tabled = stoppingNames(fixtures, fixture => fixture.params);
|
|
2441
|
+
const answer = {
|
|
2442
|
+
gated, fixtures, stopping, tabled,
|
|
2443
|
+
// A fixture every test gets that has a table of parameters, or
|
|
2444
|
+
// asks for one that has: every test runs once for each row.
|
|
2445
|
+
everyTabled: everyTabled(fixtures, tabled),
|
|
2446
|
+
// A hook that makes tests from a test (pytest_generate_tests).
|
|
2447
|
+
generates,
|
|
2448
|
+
wholeRun: wholeRun.sort(codeUnitCompare),
|
|
2449
|
+
};
|
|
2450
|
+
model.guards.set(filePath, answer);
|
|
2451
|
+
return answer;
|
|
2452
|
+
};
|
|
2453
|
+
|
|
2454
|
+
/**
|
|
2455
|
+
* The units of a file whose reader gives facts (pytest): the module's
|
|
2456
|
+
* own tests, the tests of every class the runner collects (its own and
|
|
2457
|
+
* the ones it inherits, in the language's lookup order), and the tests
|
|
2458
|
+
* the module holds under names it took from elsewhere or bound again.
|
|
2459
|
+
*
|
|
2460
|
+
* Whether a class is collected is settled over its lookup order: the
|
|
2461
|
+
* nearest `__test__` (false: no; not a literal: sometimes; the literal
|
|
2462
|
+
* True: yes whatever the class is called), a constructor anywhere in
|
|
2463
|
+
* it (pytest's classes only), abstract members nothing nearer
|
|
2464
|
+
* implements, a name the runner takes or a TestCase of unittest among
|
|
2465
|
+
* its bases, and the classes around it. Marks, setups and fixtures of
|
|
2466
|
+
* every class in that order reach its tests, as do the module's.
|
|
2467
|
+
*/
|
|
2468
|
+
const derivedFacts = (filePath, found) => {
|
|
2469
|
+
// A module switched off holds no test.
|
|
2470
|
+
if (found.off) return { modeled: true, units: [], tests: 0, inheriting: false };
|
|
2471
|
+
const relativePath = index.files.get(filePath).relativePath;
|
|
2472
|
+
const relativeOf = other => index.files.get(other).relativePath;
|
|
2473
|
+
const runner = model.runnerOf(filePath) || {};
|
|
2474
|
+
const naming = runner.collection || {};
|
|
2475
|
+
const named = (patterns, fallback, name) => (patterns && patterns.length > 0 ? patterns : fallback)
|
|
2476
|
+
.some(pattern => /[*?[]/.test(pattern) ? globMatches(pattern, name) : name.startsWith(pattern));
|
|
2477
|
+
const declaredModule = found.module || {};
|
|
2478
|
+
// What the module does when it is imported and before its tests,
|
|
2479
|
+
// with what the functions it calls in other modules add.
|
|
2480
|
+
const moduleTable = tablesState(filePath, declaredModule.tables);
|
|
2481
|
+
const mod = {
|
|
2482
|
+
...declaredModule,
|
|
2483
|
+
...(!declaredModule.skip && callStops(filePath, declaredModule.skipCalls) && { skip: 'sometimes' }),
|
|
2484
|
+
...(moduleTable && declaredModule.skip !== 'skip' && { skip: moduleTable }),
|
|
2485
|
+
...(!declaredModule.guard && callStops(filePath, declaredModule.guardCalls) && { guard: true }),
|
|
2486
|
+
};
|
|
2487
|
+
const guards = guardsOf(filePath);
|
|
2488
|
+
let more = !!found.inheriting;
|
|
2489
|
+
|
|
2490
|
+
// Names the module takes from other modules, and other names for
|
|
2491
|
+
// what it holds: a test class or function among them runs here too.
|
|
2492
|
+
const entries = [...(found.imports || [])];
|
|
2493
|
+
const own = new Set([...(found.functions || []).map(fn => fn.name),
|
|
2494
|
+
...(found.classes || []).filter(cls => cls.suite.length === 0).map(cls => cls.name),
|
|
2495
|
+
...entries.map(entry => entry.name)]);
|
|
2496
|
+
for (const star of naming.importedTests === false ? [] : found.stars || []) {
|
|
2497
|
+
const from = moduleFile(filePath, star.module);
|
|
2498
|
+
const there = from ? model.declared(from) : null;
|
|
2499
|
+
// What a module that is not read brings is not seen.
|
|
2500
|
+
if (!there || there.exportsAll === 'unknown') {
|
|
2501
|
+
more = true;
|
|
2502
|
+
continue;
|
|
2503
|
+
}
|
|
2504
|
+
if ((there.stars || []).length > 0) more = true;
|
|
2505
|
+
const names = there.exportsAll || [
|
|
2506
|
+
...(there.functions || []).map(fn => fn.name),
|
|
2507
|
+
...(there.classes || []).filter(cls => cls.suite.length === 0).map(cls => cls.name),
|
|
2508
|
+
...(there.imports || []).map(entry => entry.name),
|
|
2509
|
+
].filter(name => !name.startsWith('_'));
|
|
2510
|
+
for (const name of new Set(names)) {
|
|
2511
|
+
if (!own.has(name)) entries.push({ name, original: name, module: star.module, line: star.line });
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
const classesHere = [...(found.classes || [])];
|
|
2515
|
+
const taken = [];
|
|
2516
|
+
const bound = new Set();
|
|
2517
|
+
// A switch this module writes on a class it took from another
|
|
2518
|
+
// module: the class is that object, so the switch holds for every
|
|
2519
|
+
// class here that derives from it.
|
|
2520
|
+
const switchedHere = new Map();
|
|
2521
|
+
// Classes taken from other modules whose constructor is written here.
|
|
2522
|
+
const constructorsOpen = new Set();
|
|
2523
|
+
for (const entry of entries) {
|
|
2524
|
+
if (bound.has(entry.name)) continue;
|
|
2525
|
+
const from = entry.module == null ? filePath : moduleFile(filePath, entry.module);
|
|
2526
|
+
if (!from || (from === filePath && entry.original === entry.name)) continue;
|
|
2527
|
+
const hit = resolveName(from, entry.original);
|
|
2528
|
+
if (!hit) continue;
|
|
2529
|
+
// collect_imported_tests = false: only what the module
|
|
2530
|
+
// defines itself is collected in it.
|
|
2531
|
+
if (naming.importedTests === false && hit.filePath !== filePath) continue;
|
|
2532
|
+
bound.add(entry.name);
|
|
2533
|
+
// A switch written here on the name.
|
|
2534
|
+
const written = (mod.switches || {})[entry.name];
|
|
2535
|
+
if (hit.kind === 'class' && written) switchedHere.set(hit.cls, written);
|
|
2536
|
+
if (hit.kind === 'class' && (mod.constructorWrites || []).includes(entry.name)) constructorsOpen.add(hit.cls);
|
|
2537
|
+
if (hit.kind === 'class') {
|
|
2538
|
+
classesHere.push({
|
|
2539
|
+
name: entry.name, suite: [], bases: [], imported: { filePath: hit.filePath, cls: hit.cls },
|
|
2540
|
+
named: named(naming.classes, ['Test'], entry.name),
|
|
2541
|
+
...(written && { enabled: written.on }),
|
|
2542
|
+
...(written && written.forced && { forced: written.forced }),
|
|
2543
|
+
line: entry.line, startLine: entry.line, endLine: entry.line,
|
|
2544
|
+
members: [], tests: [], marks: [], marksOpen: false,
|
|
2545
|
+
});
|
|
2546
|
+
} else {
|
|
2547
|
+
taken.push({ entry, fn: hit.fn, target: hit.filePath, written });
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// The classes around a class, outermost first.
|
|
2552
|
+
const outerOf = cls => cls.imported ? [] : classesHere.filter(outer => !outer.imported && outer !== cls &&
|
|
2553
|
+
outer.startLine <= cls.startLine && outer.endLine >= cls.endLine).sort((a, b) => a.startLine - b.startLine);
|
|
2554
|
+
const NO = Object.freeze({ collected: 'no' });
|
|
2555
|
+
const standings = new Map();
|
|
2556
|
+
const standingOf = cls => {
|
|
2557
|
+
if (standings.has(cls)) return standings.get(cls);
|
|
2558
|
+
standings.set(cls, NO);
|
|
2559
|
+
const answer = (() => {
|
|
2560
|
+
const order = orderOf(filePath, cls);
|
|
2561
|
+
const nodes = order.filter(node => !node.opaque);
|
|
2562
|
+
const real = nodes.map(node => node.cls);
|
|
2563
|
+
const unread = order.some(node => node.opaque && !node.safe);
|
|
2564
|
+
const unittest = real.some(each => each.unittestBase);
|
|
2565
|
+
let collected = 'yes';
|
|
2566
|
+
let why = null;
|
|
2567
|
+
const weaken = reason => {
|
|
2568
|
+
if (collected === 'yes') {
|
|
2569
|
+
collected = 'maybe';
|
|
2570
|
+
why = reason;
|
|
2571
|
+
}
|
|
2572
|
+
};
|
|
2573
|
+
// The nearest switch: one written here on a class taken
|
|
2574
|
+
// from another module comes before that class's own. One
|
|
2575
|
+
// another module writes on it holds where that module was
|
|
2576
|
+
// loaded first: which, is the order of the run.
|
|
2577
|
+
const switchOf = node => {
|
|
2578
|
+
const each = node.cls;
|
|
2579
|
+
if (switchedHere.has(each)) return { enabled: switchedHere.get(each).on, forced: switchedHere.get(each).forced };
|
|
2580
|
+
const others = (switchedElsewhere(node.filePath).classes.get(each) || []).filter(write => write.by !== filePath);
|
|
2581
|
+
if (others.length > 0) {
|
|
2582
|
+
// Whether it is on is not settled; it is taken
|
|
2583
|
+
// whatever it is called only where a write says so.
|
|
2584
|
+
return { enabled: 'unknown', forced: others.some(write => write.forced) ? 'unknown' : each.forced };
|
|
2585
|
+
}
|
|
2586
|
+
return each.enabled !== undefined ? { enabled: each.enabled, forced: each.forced } : null;
|
|
2587
|
+
};
|
|
2588
|
+
const switched = nodes.map(switchOf).find(Boolean) || null;
|
|
2589
|
+
if (switched && switched.enabled === false) return NO;
|
|
2590
|
+
if (!unittest) {
|
|
2591
|
+
// The runner refuses a class whose constructor is not
|
|
2592
|
+
// the language's own. Which one a class has is what its
|
|
2593
|
+
// lookup order binds first: a class that writes the
|
|
2594
|
+
// builtin's back has it again, whatever stands behind.
|
|
2595
|
+
let custom = false;
|
|
2596
|
+
for (const name of found.constructors || []) {
|
|
2597
|
+
for (const node of order) {
|
|
2598
|
+
if (node.opaque) {
|
|
2599
|
+
if (node.safe) continue;
|
|
2600
|
+
// A base that is not read may bind one.
|
|
2601
|
+
weaken('base-not-resolved');
|
|
2602
|
+
break;
|
|
2603
|
+
}
|
|
2604
|
+
const kind = constructorsOpen.has(node.cls) ? 'unknown' : (node.cls.constructs || {})[name];
|
|
2605
|
+
if (!kind) continue;
|
|
2606
|
+
if (kind === 'custom') custom = true;
|
|
2607
|
+
else if (kind === 'unknown') weaken('constructor-not-read');
|
|
2608
|
+
break;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
if (custom) return NO;
|
|
2612
|
+
}
|
|
2613
|
+
// Abstract members that nothing nearer implements: abc
|
|
2614
|
+
// makes the class abstract and pytest leaves it out.
|
|
2615
|
+
const implemented = new Set();
|
|
2616
|
+
let abstractLeft = false;
|
|
2617
|
+
for (const each of real) {
|
|
2618
|
+
const abstract = each.abstract || [];
|
|
2619
|
+
if (abstract.some(name => !implemented.has(name))) abstractLeft = true;
|
|
2620
|
+
for (const member of each.members || []) if (!abstract.includes(member)) implemented.add(member);
|
|
2621
|
+
}
|
|
2622
|
+
if (abstractLeft) {
|
|
2623
|
+
if (real.some(each => each.abc === true)) return NO;
|
|
2624
|
+
if (real.some(each => each.abc === 'unknown') || unread) weaken('base-not-resolved');
|
|
2625
|
+
}
|
|
2626
|
+
if (!unittest && !cls.named) {
|
|
2627
|
+
// Not the runner's by its name, nor one of unittest's
|
|
2628
|
+
// (a base from outside the project is taken to be a
|
|
2629
|
+
// TestCase where it is named like one, and not otherwise).
|
|
2630
|
+
const forced = switched ? switched.forced : undefined;
|
|
2631
|
+
if (forced === 'unknown') weaken('collection-switch');
|
|
2632
|
+
else if (forced !== true) return NO;
|
|
2633
|
+
}
|
|
2634
|
+
if (switched && switched.enabled === 'unknown') weaken('collection-switch');
|
|
2635
|
+
if (real.some(each => each.unsettled)) weaken('defined-conditionally');
|
|
2636
|
+
for (const outer of outerOf(cls)) {
|
|
2637
|
+
const around = standingOf(outer);
|
|
2638
|
+
// pytest collects a class inside a class it collects;
|
|
2639
|
+
// a class of unittest holds methods only.
|
|
2640
|
+
if (around.collected === 'no' || around.regime === 'unittest') return NO;
|
|
2641
|
+
if (around.collected === 'maybe') weaken(around.why);
|
|
2642
|
+
}
|
|
2643
|
+
// Each class's fixtures and setups, read in its own file.
|
|
2644
|
+
const fixtures = nodes.flatMap(node => fixturesOf(node.filePath, node.cls.fixtures));
|
|
2645
|
+
// Tables of parameters on the classes, their rows read where each is written.
|
|
2646
|
+
const tables = nodes.map(node => tablesState(node.filePath, node.cls.tables));
|
|
2647
|
+
return {
|
|
2648
|
+
collected, why, regime: unittest ? 'unittest' : 'pytest',
|
|
2649
|
+
// What reaches a class through its lookup order.
|
|
2650
|
+
skip: real.some(each => each.skip === 'skip') || tables.includes('skip') ? 'skip'
|
|
2651
|
+
: real.some(each => each.skip === 'sometimes') || tables.includes('sometimes') ? 'sometimes' : null,
|
|
2652
|
+
guard: nodes.some(node => node.cls.guard || callStops(node.filePath, node.cls.guardCalls)),
|
|
2653
|
+
// A class's own hook that makes tests from a test.
|
|
2654
|
+
generates: real.some(each => each.generates),
|
|
2655
|
+
fixtures,
|
|
2656
|
+
usefixtures: real.flatMap(each => each.usefixtures || []),
|
|
2657
|
+
usefixturesOpen: real.some(each => each.usefixturesOpen),
|
|
2658
|
+
open: unread,
|
|
2659
|
+
};
|
|
2660
|
+
})();
|
|
2661
|
+
standings.set(cls, answer);
|
|
2662
|
+
return answer;
|
|
2663
|
+
};
|
|
2664
|
+
|
|
2665
|
+
const classesOf = new Map(); // unit -> the classes around it, for the runner's mark expression
|
|
2666
|
+
let units = [];
|
|
2667
|
+
/** A unit with what reaches it: its own state, its classes', the module's, the code beside it. */
|
|
2668
|
+
const finish = (base, row, around, alsoOpen = null, rowFile = filePath) => {
|
|
2669
|
+
const states = around.map(standingOf);
|
|
2670
|
+
const dynamic = row.dynamic || alsoOpen || states.map(each => each.collected === 'maybe' ? each.why : null).find(Boolean) ||
|
|
2671
|
+
(mod.unsettled ? 'collection-switch' : null);
|
|
2672
|
+
const classFixtures = states.flatMap(each => each.fixtures);
|
|
2673
|
+
const scoped = classFixtures.length > 0 ? [...guards.fixtures, ...classFixtures] : guards.fixtures;
|
|
2674
|
+
const stopping = classFixtures.length > 0 ? stoppingNames(scoped) : guards.stopping;
|
|
2675
|
+
const requests = [...(row.requests || []), ...(mod.usefixtures || []), ...(naming.usefixtures || []),
|
|
2676
|
+
...states.flatMap(each => each.usefixtures)];
|
|
2677
|
+
// The rows of the test's own table that other modules name,
|
|
2678
|
+
// read from the file the test is written in.
|
|
2679
|
+
const rowTable = tablesState(rowFile, row.tables);
|
|
2680
|
+
// One declaration the runner reports as several tests: a
|
|
2681
|
+
// table of parameters, its own, its classes', its module's, a
|
|
2682
|
+
// fixture's (one it asks for, or one every test gets), or a
|
|
2683
|
+
// hook that makes tests from it.
|
|
2684
|
+
const tabled = classFixtures.length > 0 ? stoppingNames(scoped, fixture => fixture.params) : guards.tabled;
|
|
2685
|
+
const byFixture = requests.some(name => tabled.has(name)) ||
|
|
2686
|
+
(classFixtures.length > 0 ? everyTabled(scoped, tabled) : guards.everyTabled);
|
|
2687
|
+
const several = (row.marks || []).includes('parametrize') || (found.marks || []).includes('parametrize') ||
|
|
2688
|
+
around.some(cls => orderOf(filePath, cls).some(node => !node.opaque && (node.cls.marks || []).includes('parametrize'))) ||
|
|
2689
|
+
byFixture || guards.generates || states.some(each => each.generates);
|
|
2690
|
+
const skipped = !!row.skipped || rowTable === 'skip' || mod.skip === 'skip' || states.some(each => each.skip === 'skip');
|
|
2691
|
+
const sometimes = !!row.conditional || !!dynamic || rowTable === 'sometimes' || mod.skip === 'sometimes' ||
|
|
2692
|
+
!!mod.guard || guards.gated ||
|
|
2693
|
+
states.some(each => each.skip === 'sometimes' || each.guard || each.usefixturesOpen || each.generates) ||
|
|
2694
|
+
!!row.requestsOpen || !!mod.usefixturesOpen || requests.some(name => stopping.has(name)) ||
|
|
2695
|
+
// A test of unittest takes no parameters: under a
|
|
2696
|
+
// fixture's table the runner reports an error for it.
|
|
2697
|
+
(byFixture && states.some(each => each.regime === 'unittest'));
|
|
2698
|
+
const unit = {
|
|
2699
|
+
...base,
|
|
2700
|
+
...(skipped && { skipped: true }),
|
|
2701
|
+
...(sometimes && { conditional: true }),
|
|
2702
|
+
...(dynamic && { dynamic }),
|
|
2703
|
+
...(several && { several: true }),
|
|
2704
|
+
...(row.marks && { marks: row.marks, marksOpen: !!row.marksOpen }),
|
|
2705
|
+
};
|
|
2706
|
+
classesOf.set(unit, around);
|
|
2707
|
+
units.push(unit);
|
|
2708
|
+
};
|
|
2709
|
+
// A function the runner takes only by a switch that is not settled.
|
|
2710
|
+
const byForce = row => !row.named && row.forced === 'unknown' ? 'collection-switch' : null;
|
|
2711
|
+
|
|
2712
|
+
// Functions of this module whose switch another module writes.
|
|
2713
|
+
const switchedBy = switchedElsewhere(filePath).functions;
|
|
2714
|
+
for (const row of found.units) {
|
|
2715
|
+
if (row.kind !== 'test') continue;
|
|
2716
|
+
finish({ kind: 'test', name: row.name, suite: [], line: row.line, startLine: row.startLine, endLine: row.endLine },
|
|
2717
|
+
row, [], byForce(row) || (switchedBy.has(row.name) ? 'collection-switch' : null));
|
|
2718
|
+
}
|
|
2719
|
+
// One the module leaves switched off, or under a name the runner
|
|
2720
|
+
// does not take, that another module switches on.
|
|
2721
|
+
for (const fn of switchedBy.size > 0 ? found.functions || [] : []) {
|
|
2722
|
+
if (found.units.some(row => row.kind === 'test' && row.name === fn.name && row.line === fn.line)) continue;
|
|
2723
|
+
if (!switchedBy.get(fn.name)?.some(write => write.on !== false && (fn.named || write.forced))) continue;
|
|
2724
|
+
finish({ kind: 'test', name: fn.name, suite: [], line: fn.line, startLine: fn.startLine, endLine: fn.endLine },
|
|
2725
|
+
fn, [], 'collection-switch');
|
|
2726
|
+
}
|
|
2727
|
+
for (const cls of classesHere) {
|
|
2728
|
+
const standing = standingOf(cls);
|
|
2729
|
+
if (standing.collected === 'no') continue;
|
|
2730
|
+
// unittest takes the methods its loader names; pytest the
|
|
2731
|
+
// names its patterns take and the ones switched on.
|
|
2732
|
+
// A member its class switches off is none, unless a switch
|
|
2733
|
+
// this module writes in a way that is not read may have
|
|
2734
|
+
// turned it on again.
|
|
2735
|
+
const live = row => !row.off || !!found.switchesOpen;
|
|
2736
|
+
const select = standing.regime === 'unittest' ? row => live(row) && row.prefixed
|
|
2737
|
+
: row => live(row) && (row.named || !!row.forced);
|
|
2738
|
+
const reopened = row => row.off ? 'collection-switch' : null;
|
|
2739
|
+
const inherited = cls.bases.length > 0 || cls.imported
|
|
2740
|
+
? inheritedBy(filePath, cls, select) : { tests: [], open: false, withheld: false };
|
|
2741
|
+
const rows = (cls.tests || []).filter(select);
|
|
2742
|
+
// unittest runs runTest when a class has no other test.
|
|
2743
|
+
if (standing.regime === 'unittest' && rows.length === 0 && inherited.tests.length === 0) {
|
|
2744
|
+
const fallback = (cls.tests || []).find(row => row.name === 'runTest');
|
|
2745
|
+
if (fallback) rows.push(fallback);
|
|
2746
|
+
// The nearest one in the lookup order, where the class has none.
|
|
2747
|
+
else if (cls.bases.length > 0 || cls.imported) {
|
|
2748
|
+
const above = inheritedBy(filePath, cls, row => row.name === 'runTest');
|
|
2749
|
+
inherited.tests.push(...above.tests);
|
|
2750
|
+
if (above.withheld) inherited.withheld = true;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
// A base that is not read may bring tests of its own.
|
|
2754
|
+
if (inherited.withheld || standing.open) more = true;
|
|
2755
|
+
const around = [...outerOf(cls), cls];
|
|
2756
|
+
const suite = [...cls.suite, cls.name];
|
|
2757
|
+
const before = units.length;
|
|
2758
|
+
for (const row of rows) {
|
|
2759
|
+
const open = reopened(row) || (standing.regime === 'unittest' ? null : byForce(row));
|
|
2760
|
+
if (row.alias) {
|
|
2761
|
+
// Another name for a test of the class: its body stands where that test is.
|
|
2762
|
+
finish({
|
|
2763
|
+
kind: 'test', name: row.name, suite, line: row.line, startLine: row.line, endLine: row.line,
|
|
2764
|
+
inherited: {
|
|
2765
|
+
file: relativePath, filePath, owner: cls.name, ownerLine: cls.startLine,
|
|
2766
|
+
line: row.of, startLine: row.startLine, endLine: row.endLine,
|
|
2767
|
+
},
|
|
2768
|
+
}, row, around, open);
|
|
2769
|
+
} else {
|
|
2770
|
+
finish({ kind: 'test', name: row.name, suite, line: row.line, startLine: row.startLine, endLine: row.endLine },
|
|
2771
|
+
row, around, open);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
for (const { test, base } of inherited.tests) {
|
|
2775
|
+
finish({
|
|
2776
|
+
kind: 'test', name: test.name, suite, line: cls.line, startLine: cls.line, endLine: cls.line,
|
|
2777
|
+
inherited: {
|
|
2778
|
+
file: relativeOf(base.filePath), filePath: base.filePath, owner: base.cls.name,
|
|
2779
|
+
ownerLine: base.cls.startLine, line: test.alias ? test.of : test.line,
|
|
2780
|
+
startLine: test.startLine, endLine: test.endLine,
|
|
2781
|
+
},
|
|
2782
|
+
}, test, around, reopened(test) || (standing.regime === 'unittest' ? null : byForce(test)), base.filePath);
|
|
2783
|
+
}
|
|
2784
|
+
if (units.length > before) {
|
|
2785
|
+
units.push({
|
|
2786
|
+
kind: 'suite', name: cls.name, suite: cls.suite,
|
|
2787
|
+
line: cls.line, startLine: cls.startLine, endLine: cls.endLine,
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
for (const { entry, fn, target, written } of taken) {
|
|
2792
|
+
// The switch is the function's own, whoever holds it: a write
|
|
2793
|
+
// made here comes after the ones of the module it is taken from.
|
|
2794
|
+
// Without one, a third module's write holds where that module
|
|
2795
|
+
// was loaded first.
|
|
2796
|
+
const others = written ? [] : (switchedElsewhere(target).functions.get(fn.name) || [])
|
|
2797
|
+
.filter(write => write.by !== filePath);
|
|
2798
|
+
// And where a switch this module writes is not read, one its
|
|
2799
|
+
// own module switched off may be on again, under any name.
|
|
2800
|
+
const reopened = !written && !!fn.off && !!found.switchesOpen;
|
|
2801
|
+
const on = written ? written.on : others.length > 0 || reopened ? 'unknown' : fn.off ? false : true;
|
|
2802
|
+
const row = {
|
|
2803
|
+
...fn, named: named(naming.functions, ['test'], entry.name),
|
|
2804
|
+
forced: written ? written.forced : others.some(write => write.forced) || reopened ? 'unknown' : fn.forced,
|
|
2805
|
+
};
|
|
2806
|
+
if ((!row.named && !row.forced) || on === false) continue;
|
|
2807
|
+
finish({
|
|
2808
|
+
kind: 'test', name: entry.name, suite: [], line: entry.line, startLine: entry.line, endLine: entry.line,
|
|
2809
|
+
inherited: {
|
|
2810
|
+
file: relativeOf(target), filePath: target, owner: null, ownerLine: 0,
|
|
2811
|
+
line: fn.line, startLine: fn.startLine, endLine: fn.endLine,
|
|
2812
|
+
},
|
|
2813
|
+
}, row, [], (on === 'unknown' ? 'collection-switch' : null) || byForce(row), target);
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// The runner's configured options: a mark expression and tests
|
|
2817
|
+
// left out by name apply to a test however it is named on the
|
|
2818
|
+
// command line (measured).
|
|
2819
|
+
const options = naming.options;
|
|
2820
|
+
if (options && (options.markExpr || options.deselect.length > 0)) {
|
|
2821
|
+
const file = relativeTo(runner.dir, relativePath);
|
|
2822
|
+
units = units.map(unit => {
|
|
2823
|
+
if (unit.kind !== 'test' || unit.skipped) return unit;
|
|
2824
|
+
const id = [file, ...unit.suite, unit.name].join('::');
|
|
2825
|
+
let selected = !(file != null && options.deselect.some(prefix => id.startsWith(prefix)));
|
|
2826
|
+
if (selected && options.markExpr) {
|
|
2827
|
+
const marks = new Set([...(unit.marks || []), ...(found.marks || [])]);
|
|
2828
|
+
let open = !!unit.marksOpen || !!found.marksOpen;
|
|
2829
|
+
// Marks of the classes around the test, and of every
|
|
2830
|
+
// class those derive from.
|
|
2831
|
+
for (const cls of classesOf.get(unit) || []) {
|
|
2832
|
+
for (const node of orderOf(filePath, cls)) {
|
|
2833
|
+
if (node.opaque) {
|
|
2834
|
+
if (!node.safe) open = true;
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
for (const mark of node.cls.marks || []) marks.add(mark);
|
|
2838
|
+
if (node.cls.marksOpen) open = true;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
selected = markSelects(options.markExpr, marks, open);
|
|
2842
|
+
}
|
|
2843
|
+
if (selected === false) return { ...unit, skipped: true, deselected: true };
|
|
2844
|
+
return selected == null ? { ...unit, conditional: true } : unit;
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2847
|
+
units.sort((a, b) => a.startLine - b.startLine || (b.endLine - a.endLine) ||
|
|
2848
|
+
codeUnitCompare(a.name || '', b.name || ''));
|
|
2849
|
+
return {
|
|
2850
|
+
modeled: true,
|
|
2851
|
+
units,
|
|
2852
|
+
// A test that is declared and not executed is not counted.
|
|
2853
|
+
tests: units.filter(unit => unit.kind === 'test' && !unit.skipped).length,
|
|
2854
|
+
inheriting: more,
|
|
2855
|
+
};
|
|
2856
|
+
};
|
|
2857
|
+
|
|
2858
|
+
/**
|
|
2859
|
+
* A file's units: the tests and suites it declares, by line, and the
|
|
2860
|
+
* tests its runnable classes inherit (placed at the class). `modeled`
|
|
2861
|
+
* is false when the language's units are not read.
|
|
2862
|
+
*/
|
|
2863
|
+
model.unitsOf = filePath => {
|
|
2864
|
+
if (model.byFile.has(filePath)) return model.byFile.get(filePath);
|
|
2865
|
+
const found = model.declared(filePath);
|
|
2866
|
+
let facts;
|
|
2867
|
+
if (!found) {
|
|
2868
|
+
facts = { modeled: false, units: [], tests: 0, inheriting: false };
|
|
2869
|
+
} else if (found.notCollected) {
|
|
2870
|
+
// A module its runner does not collect declares no test.
|
|
2871
|
+
facts = { modeled: true, units: [], tests: 0, inheriting: false };
|
|
2872
|
+
} else if (found.derived) {
|
|
2873
|
+
facts = derivedFacts(filePath, found);
|
|
2874
|
+
} else {
|
|
2875
|
+
const relativePath = index.files.get(filePath).relativePath;
|
|
2876
|
+
const runner = model.runnerOf(filePath) || {};
|
|
2877
|
+
const classesHere = [...(found.classes || [])];
|
|
2878
|
+
const within = (unit, cls) => unit.startLine >= cls.startLine && unit.endLine <= cls.endLine;
|
|
2879
|
+
// A setup that can stop a test, in a class the test's class
|
|
2880
|
+
// derives from: the test runs only sometimes.
|
|
2881
|
+
const guardedAbove = cls => cls.bases.length > 0 &&
|
|
2882
|
+
orderOf(filePath, cls).slice(1).some(node => !node.opaque && node.cls.guard);
|
|
2883
|
+
const guarded = classesHere.filter(cls => cls.runnable && guardedAbove(cls));
|
|
2884
|
+
// A class its runner refuses (one of its marked methods is one
|
|
2885
|
+
// the runner does not accept), or a class deriving from one:
|
|
2886
|
+
// none of its tests runs, its own or inherited.
|
|
2887
|
+
const refusalMemo = new Map();
|
|
2888
|
+
const refusalOf = cls => {
|
|
2889
|
+
if (!refusalMemo.has(cls)) {
|
|
2890
|
+
const order = orderOf(filePath, cls);
|
|
2891
|
+
const refused = order.some(node => !node.opaque && (node.cls.refused || node.cls.mayRefuse));
|
|
2892
|
+
// @RunWith is inherited, including through an
|
|
2893
|
+
// intermediate class that declares no tests. A runner
|
|
2894
|
+
// anywhere in the ancestry, or an unread base that may
|
|
2895
|
+
// name one, prevents a definite standard-runner refusal.
|
|
2896
|
+
const custom = order.some(node => node.opaque ? !node.safe : node.cls.runner || node.cls.mayRefuse);
|
|
2897
|
+
refusalMemo.set(cls, refused ? (custom ? 'conditional' : 'refused') : null);
|
|
2898
|
+
}
|
|
2899
|
+
return refusalMemo.get(cls);
|
|
2900
|
+
};
|
|
2901
|
+
const refusedHere = cls => refusalOf(cls) === 'refused';
|
|
2902
|
+
const maybeRefused = cls => refusalOf(cls) === 'conditional';
|
|
2903
|
+
const runsIn = unit => classesHere.filter(cls => within(unit, cls)).sort((a, b) => b.startLine - a.startLine)[0];
|
|
2904
|
+
// A focus leaves other tests of the file declared and not
|
|
2905
|
+
// executed, by the rule of the runner that runs the file.
|
|
2906
|
+
const left = found.focus ? focusedOut(found.units, model.runnerOf(filePath).id) : new Set();
|
|
2907
|
+
let units = found.units.map(unit => {
|
|
2908
|
+
let out = unit;
|
|
2909
|
+
if (unit.kind === 'test' && !unit.conditional && guarded.some(cls => within(unit, cls))) {
|
|
2910
|
+
out = { ...out, conditional: true };
|
|
2911
|
+
}
|
|
2912
|
+
if (left.has(unit)) out = { ...out, skipped: true };
|
|
2913
|
+
if (unit.kind === 'test' && !out.skipped) {
|
|
2914
|
+
const holder = runsIn(unit);
|
|
2915
|
+
if (holder && refusedHere(holder)) out = { ...out, skipped: true };
|
|
2916
|
+
else if (holder && maybeRefused(holder) && !out.conditional) out = { ...out, conditional: true };
|
|
2917
|
+
}
|
|
2918
|
+
return out;
|
|
2919
|
+
});
|
|
2920
|
+
// jest refuses a file that gives a table of cases no row
|
|
2921
|
+
// (measured): none of its tests runs.
|
|
2922
|
+
if (found.emptyTable && runner.id === 'jest') {
|
|
2923
|
+
units = units.map(unit => unit.kind === 'test' && !unit.skipped ? { ...unit, conditional: true } : unit);
|
|
2924
|
+
}
|
|
2925
|
+
// `open`: the file calls through a name that may be its
|
|
2926
|
+
// runner's declaring function, or hands that function on.
|
|
2927
|
+
let inheriting = !!found.inheriting || !!found.open;
|
|
2928
|
+
// A function of another module that declares tests when it is
|
|
2929
|
+
// called: the tests are this file's, and are not listed here.
|
|
2930
|
+
if ((found.calledModules || []).some(name => {
|
|
2931
|
+
const target = moduleFile(filePath, name);
|
|
2932
|
+
return !!target && target !== filePath && declaresWhenCalled(target);
|
|
2933
|
+
})) inheriting = true;
|
|
2934
|
+
// node runs a file that declares no test as one test.
|
|
2935
|
+
if (found.units.length === 0 && runner.id === 'node-test' &&
|
|
2936
|
+
nodeRunsFile(runner, relativeTo(runner.dir, relativePath))) inheriting = true;
|
|
2937
|
+
for (const cls of classesHere) {
|
|
2938
|
+
if (!cls.runnable || cls.bases.length === 0) continue;
|
|
2939
|
+
const inherited = inheritedBy(filePath, cls);
|
|
2940
|
+
const own = units.some(unit => unit.kind === 'test' && within(unit, cls));
|
|
2941
|
+
// A base that is not read may bring tests of its own.
|
|
2942
|
+
if (inherited.withheld || inherited.open) inheriting = true;
|
|
2943
|
+
if (inherited.tests.some(({ test }) => !test.needsClassMark || cls.marked) && !own) {
|
|
2944
|
+
units.push({
|
|
2945
|
+
kind: 'suite', name: cls.name, suite: cls.suite,
|
|
2946
|
+
line: cls.line, startLine: cls.startLine, endLine: cls.endLine,
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
for (const { test, base, body, uncertain, maybe, again } of inherited.tests) {
|
|
2950
|
+
// A runner that wants the class that runs a test
|
|
2951
|
+
// marked takes none through one that is not.
|
|
2952
|
+
if (test.needsClassMark && !cls.marked) continue;
|
|
2953
|
+
// No command names a test whose runner cannot be told
|
|
2954
|
+
// which one it is under the class that inherits it, or
|
|
2955
|
+
// one that a method of the class may have replaced
|
|
2956
|
+
// (their parameter types are not resolved).
|
|
2957
|
+
const unnamed = test.dynamic || cls.dynamic || test.inheritedDynamic ||
|
|
2958
|
+
(uncertain && 'signature-not-resolved') || (again && 'reported-again');
|
|
2959
|
+
const state = {
|
|
2960
|
+
...((test.skipped || cls.skipped || refusedHere(cls)) && { skipped: true }),
|
|
2961
|
+
...((cls.sometimes || test.conditional || cls.guard || guardedAbove(cls) || uncertain || again || maybeRefused(cls)) && { conditional: true }),
|
|
2962
|
+
...(unnamed && { dynamic: unnamed }),
|
|
2963
|
+
...((test.several || cls.several) && { several: true }),
|
|
2964
|
+
// The bodies of the methods that may be the test's
|
|
2965
|
+
// instead, where the runner gives a test the body
|
|
2966
|
+
// of the method that replaces it: a change to one
|
|
2967
|
+
// of them may reach it.
|
|
2968
|
+
...(uncertain && test.byDeclaration && maybe.length > 0 && {
|
|
2969
|
+
alternatives: maybe.map(other => ({
|
|
2970
|
+
file: index.files.get(other.at.filePath).relativePath,
|
|
2971
|
+
filePath: other.at.filePath, ownerLine: other.at.cls.startLine,
|
|
2972
|
+
startLine: other.row.startLine, endLine: other.row.endLine,
|
|
2973
|
+
})),
|
|
2974
|
+
}),
|
|
2975
|
+
};
|
|
2976
|
+
const suite = [...cls.suite, cls.name];
|
|
2977
|
+
// The class redeclares the member without the mark:
|
|
2978
|
+
// the test is the inherited one, its body the class's own.
|
|
2979
|
+
if (body && body.at.cls === cls && !uncertain && !again) {
|
|
2980
|
+
units.push({
|
|
2981
|
+
kind: 'test', name: test.name, suite, ...(cls.namespace && { namespace: cls.namespace }),
|
|
2982
|
+
line: body.row.line, startLine: body.row.startLine, endLine: body.row.endLine, ...state,
|
|
2983
|
+
});
|
|
2984
|
+
continue;
|
|
2985
|
+
}
|
|
2986
|
+
// Where the body stands: the declaration that makes the
|
|
2987
|
+
// test, or the nearest class that redeclares the member.
|
|
2988
|
+
const at = body ? body.at : base;
|
|
2989
|
+
const where = body ? body.row : test;
|
|
2990
|
+
units.push({
|
|
2991
|
+
kind: 'test', name: test.name, suite, ...(cls.namespace && { namespace: cls.namespace }),
|
|
2992
|
+
line: cls.line, startLine: cls.line, endLine: cls.line, ...state,
|
|
2993
|
+
inherited: {
|
|
2994
|
+
file: index.files.get(at.filePath).relativePath,
|
|
2995
|
+
filePath: at.filePath, owner: at.cls.name,
|
|
2996
|
+
ownerLine: at.cls.startLine, line: where.line,
|
|
2997
|
+
startLine: where.startLine, endLine: where.endLine,
|
|
2998
|
+
},
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
// A mark on a class that makes its methods tests holds for
|
|
3002
|
+
// the classes deriving from it (`classMark`): the methods
|
|
3003
|
+
// this class declares that such a mark takes (`candidate`).
|
|
3004
|
+
if (!cls.classMark && orderOf(filePath, cls).slice(1).some(node => !node.opaque && node.cls.classMark)) {
|
|
3005
|
+
let added = false;
|
|
3006
|
+
for (const row of cls.memberRows || []) {
|
|
3007
|
+
if (!row.candidate || units.some(unit => unit.kind === 'test' && unit.name === row.name &&
|
|
3008
|
+
unit.startLine === row.startLine)) continue;
|
|
3009
|
+
added = true;
|
|
3010
|
+
units.push({
|
|
3011
|
+
kind: 'test', name: row.name, suite: [...cls.suite, cls.name],
|
|
3012
|
+
...(cls.namespace && { namespace: cls.namespace }),
|
|
3013
|
+
line: row.line, startLine: row.startLine, endLine: row.endLine,
|
|
3014
|
+
...(cls.skipped && { skipped: true }),
|
|
3015
|
+
...((cls.sometimes || cls.guard || guardedAbove(cls)) && { conditional: true }),
|
|
3016
|
+
dynamic: 'declared-in-a-base-class',
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
if (added && !own && inherited.tests.length === 0) {
|
|
3020
|
+
units.push({
|
|
3021
|
+
kind: 'suite', name: cls.name, suite: cls.suite,
|
|
3022
|
+
line: cls.line, startLine: cls.startLine, endLine: cls.endLine,
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
// Code the runner hands the decision to: what it does is not
|
|
3028
|
+
// read, so a test under it runs only sometimes.
|
|
3029
|
+
if (gatedAtRunTime(filePath, runner)) {
|
|
3030
|
+
units = units.map(unit => unit.kind === 'test' && !unit.skipped ? { ...unit, conditional: true } : unit);
|
|
3031
|
+
}
|
|
3032
|
+
units.sort((a, b) => a.startLine - b.startLine || (b.endLine - a.endLine) ||
|
|
3033
|
+
codeUnitCompare(a.name || '', b.name || ''));
|
|
3034
|
+
facts = {
|
|
3035
|
+
modeled: true,
|
|
3036
|
+
units,
|
|
3037
|
+
// A test that is declared and not executed is not counted.
|
|
3038
|
+
tests: units.filter(unit => unit.kind === 'test' && !unit.skipped).length,
|
|
3039
|
+
inheriting,
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
model.byFile.set(filePath, facts);
|
|
3043
|
+
return facts;
|
|
3044
|
+
};
|
|
3045
|
+
|
|
3046
|
+
/**
|
|
3047
|
+
* What has to be the same for two files to share one command: a hook
|
|
3048
|
+
* that decides for a whole run is loaded with every file below its
|
|
3049
|
+
* conftest.py, so a file from elsewhere joined to one of those would
|
|
3050
|
+
* run under a hook its own command never loads; a module that
|
|
3051
|
+
* switches a class of another module on or off does it for every
|
|
3052
|
+
* module loaded after it.
|
|
3053
|
+
*/
|
|
3054
|
+
model.joinKey = filePath => {
|
|
3055
|
+
const found = model.declared(filePath);
|
|
3056
|
+
if (!found || !found.derived || found.notCollected) return '';
|
|
3057
|
+
// A module that writes a switch on a name it took from another
|
|
3058
|
+
// module changes that object for every module loaded after it:
|
|
3059
|
+
// it is run by itself.
|
|
3060
|
+
const alone = Object.keys((found.module && found.module.switches) || {}).length > 0 ||
|
|
3061
|
+
((found.module && found.module.constructorWrites) || []).length > 0
|
|
3062
|
+
? `\0switches:${index.files.get(filePath).relativePath}` : '';
|
|
3063
|
+
return guardsOf(filePath).wholeRun.join('\0') + alone;
|
|
3064
|
+
};
|
|
3065
|
+
|
|
3066
|
+
/** Classes that derive from the one named, through the project. */
|
|
3067
|
+
const descendantFiles = className => {
|
|
3068
|
+
const files = new Set();
|
|
3069
|
+
const seen = new Set([className]);
|
|
3070
|
+
const queue = [className];
|
|
3071
|
+
while (queue.length > 0 && seen.size < 512) {
|
|
3072
|
+
for (const child of index.extendedByGraph?.get(queue.shift()) || []) {
|
|
3073
|
+
const name = typeof child === 'string' ? child : child?.name;
|
|
3074
|
+
if (!name || seen.has(name)) continue;
|
|
3075
|
+
seen.add(name);
|
|
3076
|
+
queue.push(name);
|
|
3077
|
+
for (const def of index.symbols.get(name) || []) {
|
|
3078
|
+
if (CONTAINER_KINDS.has(def.type)) files.add(def.file);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
return files;
|
|
3083
|
+
};
|
|
3084
|
+
|
|
3085
|
+
/**
|
|
3086
|
+
* The tests that execute a line of test code: the unit that holds it,
|
|
3087
|
+
* and every test a deriving class inherits from the method that holds
|
|
3088
|
+
* it. A line in a suite outside its tests answers with the suite. An
|
|
3089
|
+
* empty answer: the line belongs to no unit (file-level code).
|
|
3090
|
+
* @returns {Array<{filePath: string, unit: object}>}
|
|
3091
|
+
*/
|
|
3092
|
+
model.runsAt = (filePath, line) => {
|
|
3093
|
+
const out = [];
|
|
3094
|
+
// The units of a file that may run this code. A module its runner
|
|
3095
|
+
// does not collect by its name declares none: it is not read for
|
|
3096
|
+
// the answer.
|
|
3097
|
+
const unitsIn = file => {
|
|
3098
|
+
if (!model.byFile.has(file)) {
|
|
3099
|
+
const entry = index.files.get(file);
|
|
3100
|
+
if (entry && collectionFor(file, entry).collected === false) return [];
|
|
3101
|
+
}
|
|
3102
|
+
return model.unitsOf(file).units;
|
|
3103
|
+
};
|
|
3104
|
+
const unit = model.unitAt(filePath, line);
|
|
3105
|
+
if (unit && unit.kind === 'test') out.push({ filePath, unit });
|
|
3106
|
+
const holder = (model.declared(filePath)?.classes || [])
|
|
3107
|
+
.filter(cls => cls.startLine <= line && cls.endLine >= line)
|
|
3108
|
+
.sort((a, b) => b.startLine - a.startLine)[0];
|
|
3109
|
+
// The method that holds the line: a test's declaration, or a method
|
|
3110
|
+
// a deriving class's test takes its body from.
|
|
3111
|
+
const template = holder && [...holder.tests, ...(holder.memberRows || [])]
|
|
3112
|
+
.find(row => row.startLine <= line && row.endLine >= line);
|
|
3113
|
+
const importers = [...(index.exportGraph?.get(filePath) || [])];
|
|
3114
|
+
if (template) {
|
|
3115
|
+
const held = from => !!from && from.filePath === filePath && from.ownerLine === holder.startLine &&
|
|
3116
|
+
from.startLine === template.startLine && from.endLine === template.endLine;
|
|
3117
|
+
for (const file of [...new Set([...descendantFiles(holder.name), ...importers, filePath])].sort(codeUnitCompare)) {
|
|
3118
|
+
for (const candidate of unitsIn(file)) {
|
|
3119
|
+
// Every test whose body this is: the one a deriving
|
|
3120
|
+
// class inherits, another name for it in its class;
|
|
3121
|
+
// and every test whose body it may be.
|
|
3122
|
+
if ((held(candidate.inherited) || (candidate.alternatives || []).some(held)) &&
|
|
3123
|
+
!out.some(seen => seen.filePath === file && seen.unit === candidate)) {
|
|
3124
|
+
out.push({ filePath: file, unit: candidate });
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
// A function another module imports under a test's name runs there.
|
|
3130
|
+
const fn = holder ? null : (model.declared(filePath)?.functions || [])
|
|
3131
|
+
.find(other => other.startLine <= line && other.endLine >= line);
|
|
3132
|
+
if (fn) {
|
|
3133
|
+
for (const file of [...new Set([...importers, filePath])].sort(codeUnitCompare)) {
|
|
3134
|
+
for (const candidate of unitsIn(file)) {
|
|
3135
|
+
const from = candidate.inherited;
|
|
3136
|
+
if (from && from.filePath === filePath && from.owner == null && from.startLine === fn.startLine) {
|
|
3137
|
+
out.push({ filePath: file, unit: candidate });
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
if (out.length === 0 && unit) out.push({ filePath, unit });
|
|
3143
|
+
return out;
|
|
3144
|
+
};
|
|
3145
|
+
|
|
3146
|
+
/**
|
|
3147
|
+
* Can a file hold test code at all? Asked before a file is read for
|
|
3148
|
+
* the tests in it. A language says so from what the index keeps
|
|
3149
|
+
* (`testCodeGate`): whether a declaration of the file carries a mark
|
|
3150
|
+
* that can make a test, and which types its classes derive from. No
|
|
3151
|
+
* mark here and none in the file of a class of the project one of its
|
|
3152
|
+
* classes derives from: no test, and no body of one. A language
|
|
3153
|
+
* without the gate is read. (`options.ungated` reads every file: the
|
|
3154
|
+
* gate must never change an answer.)
|
|
3155
|
+
*/
|
|
3156
|
+
const gates = new Map();
|
|
3157
|
+
const projectDeclaresType = name => (index.symbols.get(name) || []).some(def => CONTAINER_KINDS.has(def.type));
|
|
3158
|
+
model.mayHoldTests = (filePath, trail = new Set()) => {
|
|
3159
|
+
if (options.ungated) return true;
|
|
3160
|
+
if (gates.has(filePath)) return gates.get(filePath);
|
|
3161
|
+
const entry = index.files.get(filePath);
|
|
3162
|
+
const gate = entry && langTraits(entry.language)?.testCodeGate;
|
|
3163
|
+
if (typeof gate !== 'function') return true;
|
|
3164
|
+
if (trail.has(filePath)) return false;
|
|
3165
|
+
let answer;
|
|
3166
|
+
try {
|
|
3167
|
+
const facts = gate(entry, projectDeclaresType);
|
|
3168
|
+
answer = !!facts.marked;
|
|
3169
|
+
// A base written under a using alias is the type the alias
|
|
3170
|
+
// names (the file's own, a global one, a project file's).
|
|
3171
|
+
const aliases = !answer && langTraits(entry.language)?.typeNameScoping === 'namespace-usings'
|
|
3172
|
+
? csharpUsings(index, filePath) : null;
|
|
3173
|
+
if (aliases && aliases.unreadable) answer = true;
|
|
3174
|
+
const next = new Set(trail).add(filePath);
|
|
3175
|
+
for (const base of answer ? [] : facts.bases || []) {
|
|
3176
|
+
const names = [base];
|
|
3177
|
+
if (aliases && aliases.aliases.has(base)) names.push(String(aliases.aliases.get(base)).split('.').pop());
|
|
3178
|
+
for (const def of names.flatMap(name => index.symbols.get(name) || [])) {
|
|
3179
|
+
if (!CONTAINER_KINDS.has(def.type) || !def.file || def.file === filePath) continue;
|
|
3180
|
+
if (model.mayHoldTests(def.file, next)) {
|
|
3181
|
+
answer = true;
|
|
3182
|
+
break;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
if (answer) break;
|
|
3186
|
+
}
|
|
3187
|
+
} catch (e) {
|
|
3188
|
+
answer = true;
|
|
3189
|
+
}
|
|
3190
|
+
// An answer reached inside a cycle of classes is not kept: it may
|
|
3191
|
+
// be 'no' only because the walk came back to its start.
|
|
3192
|
+
if (answer || trail.size === 0) gates.set(filePath, answer);
|
|
3193
|
+
return answer;
|
|
3194
|
+
};
|
|
3195
|
+
|
|
3196
|
+
/**
|
|
3197
|
+
* The lines of a file a test executes, as [start, end] ranges: its own
|
|
3198
|
+
* tests and suites, and the methods and functions a test another file
|
|
3199
|
+
* lists runs as its body (a base class's test a deriving class
|
|
3200
|
+
* inherits, a method that gives an inherited test its body, a function
|
|
3201
|
+
* another module imports under a test's name). This is what makes code
|
|
3202
|
+
* test code, whatever its file is called: a base class in a file whose
|
|
3203
|
+
* name is no test file's holds the bodies of the tests that inherit
|
|
3204
|
+
* from it. Empty for a file the model knows no test in.
|
|
3205
|
+
*/
|
|
3206
|
+
model.testRangesOf = filePath => {
|
|
3207
|
+
if (model.ranges.has(filePath)) return model.ranges.get(filePath);
|
|
3208
|
+
model.ranges.set(filePath, []);
|
|
3209
|
+
const ranges = [];
|
|
3210
|
+
const found = model.mayHoldTests(filePath) ? model.declared(filePath) : null;
|
|
3211
|
+
if (found) {
|
|
3212
|
+
for (const unit of model.unitsOf(filePath).units) {
|
|
3213
|
+
if (!unit.inherited) ranges.push([unit.startLine, unit.endLine]);
|
|
3214
|
+
}
|
|
3215
|
+
const covered = row => ranges.some(([start, end]) => row.startLine >= start && row.endLine <= end);
|
|
3216
|
+
const rows = [...(found.functions || [])];
|
|
3217
|
+
for (const cls of found.classes || []) {
|
|
3218
|
+
rows.push(...(cls.tests || []));
|
|
3219
|
+
// A method that is no test gives one its body only in a
|
|
3220
|
+
// class that inherits a test of its name.
|
|
3221
|
+
if (cls.bases.length === 0 || (cls.memberRows || []).length === 0) continue;
|
|
3222
|
+
const inherited = new Set();
|
|
3223
|
+
for (const node of orderOf(filePath, cls).slice(1)) {
|
|
3224
|
+
if (!node.opaque) for (const test of node.cls.tests || []) inherited.add(test.name);
|
|
3225
|
+
}
|
|
3226
|
+
if (inherited.size > 0) rows.push(...cls.memberRows.filter(row => inherited.has(row.name)));
|
|
3227
|
+
}
|
|
3228
|
+
rows.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
|
|
3229
|
+
for (const row of rows) {
|
|
3230
|
+
if (row.startLine == null || row.endLine == null || covered(row)) continue;
|
|
3231
|
+
if (model.runsAt(filePath, row.startLine).length > 0) ranges.push([row.startLine, row.endLine]);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
model.ranges.set(filePath, ranges);
|
|
3235
|
+
return ranges;
|
|
3236
|
+
};
|
|
3237
|
+
|
|
3238
|
+
/** The innermost unit holding a line: a test before a suite. */
|
|
3239
|
+
model.unitAt = (filePath, line) => {
|
|
3240
|
+
let best = null;
|
|
3241
|
+
for (const unit of model.unitsOf(filePath).units) {
|
|
3242
|
+
if (unit.startLine > line) break;
|
|
3243
|
+
if (unit.endLine < line || unit.inherited) continue;
|
|
3244
|
+
if (!best || (unit.kind === 'test' && best.kind !== 'test') ||
|
|
3245
|
+
(unit.kind === best.kind && unit.line >= best.line)) best = unit;
|
|
3246
|
+
}
|
|
3247
|
+
return best;
|
|
3248
|
+
};
|
|
3249
|
+
|
|
3250
|
+
/** Tests a unit stands for: itself, or every test a suite holds. */
|
|
3251
|
+
model.testsWithin = (filePath, unit) => {
|
|
3252
|
+
const { units } = model.unitsOf(filePath);
|
|
3253
|
+
if (!unit) return units.filter(candidate => candidate.kind === 'test');
|
|
3254
|
+
if (unit.kind === 'test') return [unit];
|
|
3255
|
+
return units.filter(candidate => candidate.kind === 'test' &&
|
|
3256
|
+
candidate.startLine >= unit.startLine && candidate.endLine <= unit.endLine);
|
|
3257
|
+
};
|
|
3258
|
+
|
|
3259
|
+
/**
|
|
3260
|
+
* `tests`: declares a unit, or can hold tests the declarations do not
|
|
3261
|
+
* show. `support`: test-path code that is not a test. `unmodeled`: the
|
|
3262
|
+
* language's units are not read.
|
|
3263
|
+
*/
|
|
3264
|
+
model.roleOf = filePath => {
|
|
3265
|
+
const facts = model.unitsOf(filePath);
|
|
3266
|
+
if (!facts.modeled) return 'unmodeled';
|
|
3267
|
+
if (facts.tests > 0 || facts.units.length > 0 || facts.inheriting) return 'tests';
|
|
3268
|
+
// A language whose tests are all declarations: none read, none there.
|
|
3269
|
+
if (model.declared(filePath)?.complete || model.declared(filePath)?.notCollected) return 'support';
|
|
3270
|
+
const fileEntry = index.files.get(filePath);
|
|
3271
|
+
// node runs every file it is told to, or looks for, as a test: a
|
|
3272
|
+
// script that declares none is one.
|
|
3273
|
+
const runner = model.runnerOf(filePath) || {};
|
|
3274
|
+
if (runner.id === 'node-test' && nodeRunsFile(runner, relativeTo(runner.dir, fileEntry.relativePath))) return 'tests';
|
|
3275
|
+
const named = langTraits(fileEntry.language)?.testFileNames || [];
|
|
3276
|
+
const base = path.posix.basename(fileEntry.relativePath);
|
|
3277
|
+
const collection = (model.runnerOf(filePath) || {}).collection || {};
|
|
3278
|
+
if (collection.files && collection.files.some(glob => globMatches(glob, base))) return 'tests';
|
|
3279
|
+
if (named.some(pattern => pattern.test(fileEntry.relativePath) || pattern.test(base))) return 'tests';
|
|
3280
|
+
return 'support';
|
|
3281
|
+
};
|
|
3282
|
+
|
|
3283
|
+
/** The command that runs a unit (or the file when `unit` is null). */
|
|
3284
|
+
model.selectorOf = (filePath, unit) => {
|
|
3285
|
+
const fileEntry = index.files.get(filePath);
|
|
3286
|
+
const runner = model.runnerOf(filePath);
|
|
3287
|
+
if (!runner.id) return withheld(runner.reason || 'no-runner-evidence');
|
|
3288
|
+
const build = SELECTORS[runner.id];
|
|
3289
|
+
if (!build) return withheld('runner-not-modeled');
|
|
3290
|
+
if (unit && unit.kind !== 'test') return withheld('not-a-test');
|
|
3291
|
+
if (unit && unit.name == null) return withheld('name-not-literal');
|
|
3292
|
+
if (unit && unit.dynamic) return withheld(unit.dynamic);
|
|
3293
|
+
// Runners select by name: two declarations of one name in one
|
|
3294
|
+
// suite are one selection.
|
|
3295
|
+
if (unit && model.unitsOf(filePath).units.some(other => other !== unit && other.kind === 'test' &&
|
|
3296
|
+
other.name === unit.name && other.suite.length === unit.suite.length &&
|
|
3297
|
+
other.suite.every((name, i) => name === unit.suite[i]) &&
|
|
3298
|
+
!(other.inherited && unit.inherited && other.startLine !== unit.startLine))) {
|
|
3299
|
+
return withheld('name-not-unique');
|
|
3300
|
+
}
|
|
3301
|
+
try {
|
|
3302
|
+
return build(model, runner, fileEntry, unit || null, filePath);
|
|
3303
|
+
} catch (e) {
|
|
3304
|
+
return withheld('selector-failed');
|
|
3305
|
+
}
|
|
3306
|
+
};
|
|
3307
|
+
|
|
3308
|
+
/** A unit as an answer row. */
|
|
3309
|
+
model.describe = (filePath, unit) => {
|
|
3310
|
+
const { selector, reason } = unit.kind === 'test' ? model.selectorOf(filePath, unit) : withheld('not-a-test');
|
|
3311
|
+
return {
|
|
3312
|
+
kind: unit.kind,
|
|
3313
|
+
name: unit.name,
|
|
3314
|
+
suite: unit.suite,
|
|
3315
|
+
line: unit.line,
|
|
3316
|
+
...(unit.inherited && { inheritedFrom: `${unit.inherited.file}:${unit.inherited.line}` }),
|
|
3317
|
+
...(unit.conditional && { conditional: true }),
|
|
3318
|
+
...(unit.skipped && { skipped: true }),
|
|
3319
|
+
...(unit.kind === 'test' && selector && { selector }),
|
|
3320
|
+
...(unit.kind === 'test' && !selector && { selectorWithheld: reason }),
|
|
3321
|
+
};
|
|
3322
|
+
};
|
|
3323
|
+
|
|
3324
|
+
/** What a file is to a runner: role, runner, the command for the file. */
|
|
3325
|
+
model.fileFacts = filePath => {
|
|
3326
|
+
const role = model.roleOf(filePath);
|
|
3327
|
+
const runner = model.runnerOf(filePath);
|
|
3328
|
+
const facts = model.unitsOf(filePath);
|
|
3329
|
+
const out = { role, tests: facts.tests };
|
|
3330
|
+
// The runner reports more tests than are counted: the file holds
|
|
3331
|
+
// some that are not listed, or a listed one stands for several.
|
|
3332
|
+
if (role === 'tests' && (facts.inheriting ||
|
|
3333
|
+
facts.units.some(unit => unit.kind === 'test' && !unit.skipped && standsForSeveral(unit)))) out.moreTests = true;
|
|
3334
|
+
if (runner.id) out.runner = runner.id;
|
|
3335
|
+
if (role === 'tests') {
|
|
3336
|
+
const tests = facts.units.filter(unit => unit.kind === 'test');
|
|
3337
|
+
// A module its runner is told not to collect has no command.
|
|
3338
|
+
// A file whose tests are all declared inside functions has
|
|
3339
|
+
// them only where something calls those functions; one whose
|
|
3340
|
+
// every test is left out of some builds has no command that
|
|
3341
|
+
// is known to run it.
|
|
3342
|
+
const { selector, reason } = model.declared(filePath)?.off
|
|
3343
|
+
? withheld('collection-switched-off')
|
|
3344
|
+
: tests.length > 0 && tests.every(unit => unit.dynamic === 'declared-indirectly')
|
|
3345
|
+
? withheld('declared-indirectly')
|
|
3346
|
+
: tests.length > 0 && tests.every(unit => unit.notInEveryBuild)
|
|
3347
|
+
? withheld('conditional-tests')
|
|
3348
|
+
: tests.length > 0 && tests.every(unit => unit.deselected)
|
|
3349
|
+
? withheld('deselected-by-options') : model.selectorOf(filePath, null);
|
|
3350
|
+
if (selector) out.run = selector;
|
|
3351
|
+
else out.runWithheld = reason;
|
|
3352
|
+
}
|
|
3353
|
+
return out;
|
|
3354
|
+
};
|
|
3355
|
+
|
|
3356
|
+
return model;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
/**
|
|
3360
|
+
* Add a `test-case` row for every test that holds one of the matches that
|
|
3361
|
+
* count, and for every titled unit whose title names the term. The row
|
|
3362
|
+
* sits on the unit's first line; a line that already has a row keeps it.
|
|
3363
|
+
* @param {object[]} matches - rows of one file, extended in place
|
|
3364
|
+
* @param {{counts: function, titleNeedsMatch?: boolean, titles?: boolean, extra?: object}} options
|
|
3365
|
+
*/
|
|
3366
|
+
function addTestCaseRows(model, index, filePath, term, matches, options) {
|
|
3367
|
+
const taken = new Set(matches.map(match => match.line));
|
|
3368
|
+
const counted = matches.filter(options.counts);
|
|
3369
|
+
const added = [];
|
|
3370
|
+
const add = unit => {
|
|
3371
|
+
if (taken.has(unit.startLine)) return;
|
|
3372
|
+
taken.add(unit.startLine);
|
|
3373
|
+
added.push({
|
|
3374
|
+
line: unit.startLine,
|
|
3375
|
+
content: (index.getLineContent(filePath, unit.startLine) || '').trim(),
|
|
3376
|
+
matchType: 'test-case',
|
|
3377
|
+
...(options.extra || {}),
|
|
3378
|
+
});
|
|
3379
|
+
};
|
|
3380
|
+
if (!model.unitsOf(filePath).modeled) {
|
|
3381
|
+
// A language whose tests are not read as units: the function the
|
|
3382
|
+
// harness invokes around a match stands for the test. No name,
|
|
3383
|
+
// suite or command is claimed for it.
|
|
3384
|
+
const fileEntry = index.files.get(filePath);
|
|
3385
|
+
let kindOf;
|
|
3386
|
+
try {
|
|
3387
|
+
kindOf = getLanguageAdapter(fileEntry.language)?.getEntryPointKind || null;
|
|
3388
|
+
} catch (e) {
|
|
3389
|
+
kindOf = null;
|
|
3390
|
+
}
|
|
3391
|
+
for (const symbol of (kindOf && fileEntry.symbols) || []) {
|
|
3392
|
+
if (kindOf(symbol) !== 'test') continue;
|
|
3393
|
+
const end = symbol.endLine ?? symbol.startLine;
|
|
3394
|
+
if (counted.some(match => match.line >= symbol.startLine && match.line <= end)) {
|
|
3395
|
+
add({ startLine: symbol.startLine });
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
matches.push(...added);
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
for (const match of counted) {
|
|
3402
|
+
const unit = model.unitAt(filePath, match.line);
|
|
3403
|
+
if (unit && unit.kind === 'test') add(unit);
|
|
3404
|
+
}
|
|
3405
|
+
if (options.titles !== false && term) {
|
|
3406
|
+
// A title is prose: the term counts where it stands as a word.
|
|
3407
|
+
const named = new RegExp('(^|[^\\w$])' + escapeRegExp(term) + '([^\\w$]|$)');
|
|
3408
|
+
for (const unit of model.unitsOf(filePath).units) {
|
|
3409
|
+
if (!unit.titled || unit.name == null || !named.test(unit.name)) continue;
|
|
3410
|
+
if (options.titleNeedsMatch &&
|
|
3411
|
+
!counted.some(match => match.line >= unit.startLine && match.line <= unit.endLine)) continue;
|
|
3412
|
+
add(unit);
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
matches.push(...added);
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
/**
|
|
3419
|
+
* One declaration: two tests of one name on two lines are two tests, and
|
|
3420
|
+
* so are two a class inherits under one name (two methods of that name
|
|
3421
|
+
* with other parameters), which stand on the class's line both.
|
|
3422
|
+
*/
|
|
3423
|
+
function testKey(relativePath, unit) {
|
|
3424
|
+
return [relativePath, ...unit.suite, unit.name == null ? '' : unit.name, `@${unit.startLine}`,
|
|
3425
|
+
...(unit.inherited ? [`<${unit.inherited.file}:${unit.inherited.startLine}`] : [])].join('\0');
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
/**
|
|
3429
|
+
* What the lines of a test-path file that reach a symbol amount to: the
|
|
3430
|
+
* file's role and runner, the units of THIS file that execute those lines
|
|
3431
|
+
* (plus `extra`: units of this file that execute lines of another, handed
|
|
3432
|
+
* over by that file's answer), how many tests that is, and the command for
|
|
3433
|
+
* the file. `moreTests`: `tests` is a lower bound (the file holds tests
|
|
3434
|
+
* the reader does not list, or a counted declaration stands for several
|
|
3435
|
+
* of the runner's tests). `conditionalTests`: how many of the counted
|
|
3436
|
+
* tests run only sometimes. `carried` (not enumerable) holds the units of
|
|
3437
|
+
* OTHER files that execute these lines: a test a class inherits runs where
|
|
3438
|
+
* the class is.
|
|
3439
|
+
* @returns {{role: string, runner?: string, run?: object, runWithheld?: string,
|
|
3440
|
+
* units: object[], tests: number, moreTests?: boolean, conditionalTests?: number,
|
|
3441
|
+
* testKeys: string[], carried: Array<{filePath: string, unit: object}>}}
|
|
3442
|
+
*/
|
|
3443
|
+
function linkFile(model, index, filePath, lines, extra = []) {
|
|
3444
|
+
const facts = model.fileFacts(filePath);
|
|
3445
|
+
const relativePath = index.files.get(filePath).relativePath;
|
|
3446
|
+
const rows = new Map();
|
|
3447
|
+
const tests = new Set();
|
|
3448
|
+
const sometimes = new Set();
|
|
3449
|
+
const carried = [];
|
|
3450
|
+
// Tests the file holds and does not list: which of them these lines
|
|
3451
|
+
// reach is not known.
|
|
3452
|
+
let more = facts.role === 'tests' && model.unitsOf(filePath).inheriting;
|
|
3453
|
+
const count = test => {
|
|
3454
|
+
if (test.skipped) return;
|
|
3455
|
+
tests.add(testKey(relativePath, test));
|
|
3456
|
+
if (test.conditional) sometimes.add(testKey(relativePath, test));
|
|
3457
|
+
if (standsForSeveral(test)) more = true;
|
|
3458
|
+
};
|
|
3459
|
+
const take = unit => {
|
|
3460
|
+
const key = `${unit.kind}\0${testKey(relativePath, unit)}`;
|
|
3461
|
+
if (!rows.has(key)) rows.set(key, model.describe(filePath, unit));
|
|
3462
|
+
for (const test of model.testsWithin(filePath, unit)) count(test);
|
|
3463
|
+
};
|
|
3464
|
+
let fileLevel = false;
|
|
3465
|
+
for (const line of [...new Set(lines)].sort((a, b) => a - b)) {
|
|
3466
|
+
const runs = model.runsAt(filePath, line);
|
|
3467
|
+
if (runs.length === 0) {
|
|
3468
|
+
fileLevel = true;
|
|
3469
|
+
continue;
|
|
3470
|
+
}
|
|
3471
|
+
for (const run of runs) {
|
|
3472
|
+
if (run.filePath === filePath) take(run.unit);
|
|
3473
|
+
else carried.push(run);
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
for (const unit of extra) take(unit);
|
|
3477
|
+
// Code outside every unit runs with the file: all its tests count.
|
|
3478
|
+
if (fileLevel && facts.role === 'tests') {
|
|
3479
|
+
for (const test of model.testsWithin(filePath, null)) count(test);
|
|
3480
|
+
}
|
|
3481
|
+
const units = [...rows.values()].sort((a, b) => a.line - b.line || codeUnitCompare(a.name || '', b.name || ''));
|
|
3482
|
+
const out = {
|
|
3483
|
+
role: facts.role,
|
|
3484
|
+
...(facts.runner && { runner: facts.runner }),
|
|
3485
|
+
...(facts.run && { run: facts.run }),
|
|
3486
|
+
...(facts.runWithheld && { runWithheld: facts.runWithheld }),
|
|
3487
|
+
units,
|
|
3488
|
+
tests: tests.size,
|
|
3489
|
+
// The runner reports more tests than are counted here: the
|
|
3490
|
+
// count is a lower bound.
|
|
3491
|
+
...(more && { moreTests: true }),
|
|
3492
|
+
// Tests counted that run only sometimes.
|
|
3493
|
+
...(sometimes.size > 0 && { conditionalTests: sometimes.size }),
|
|
3494
|
+
testKeys: [...tests].sort(codeUnitCompare),
|
|
3495
|
+
};
|
|
3496
|
+
Object.defineProperty(out, 'carried', { value: carried, enumerable: false });
|
|
3497
|
+
Object.defineProperty(out, 'conditionalKeys', { value: [...sometimes].sort(codeUnitCompare), enumerable: false });
|
|
3498
|
+
return out;
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
/**
|
|
3502
|
+
* Link several files at once: each file's own lines, then the units that
|
|
3503
|
+
* other files' lines carried into it. A file that only receives carried
|
|
3504
|
+
* units (its class inherits a test whose body is elsewhere) gets a row of
|
|
3505
|
+
* its own.
|
|
3506
|
+
* @param {Map<string, number[]>} linesByFile - absolute path -> lines
|
|
3507
|
+
* @returns {Map<string, object>} absolute path -> linkFile answer, plus
|
|
3508
|
+
* `from`: the relative paths whose lines its carried units execute
|
|
3509
|
+
*/
|
|
3510
|
+
function linkFiles(model, index, linesByFile) {
|
|
3511
|
+
const carriedInto = new Map(); // absolute path -> { units: Map, from: Set }
|
|
3512
|
+
for (const [filePath, lines] of linesByFile) {
|
|
3513
|
+
for (const run of linkFile(model, index, filePath, lines).carried) {
|
|
3514
|
+
let held = carriedInto.get(run.filePath);
|
|
3515
|
+
if (!held) {
|
|
3516
|
+
held = { units: new Map(), from: new Set() };
|
|
3517
|
+
carriedInto.set(run.filePath, held);
|
|
3518
|
+
}
|
|
3519
|
+
held.units.set(testKey('', run.unit), run.unit);
|
|
3520
|
+
held.from.add(index.files.get(filePath).relativePath);
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
const out = new Map();
|
|
3524
|
+
const all = new Set([...linesByFile.keys(), ...carriedInto.keys()]);
|
|
3525
|
+
for (const filePath of [...all].sort(codeUnitCompare)) {
|
|
3526
|
+
const held = carriedInto.get(filePath);
|
|
3527
|
+
const linked = linkFile(model, index, filePath, linesByFile.get(filePath) || [],
|
|
3528
|
+
held ? [...held.units.values()] : []);
|
|
3529
|
+
if (held) linked.from = [...held.from].sort(codeUnitCompare);
|
|
3530
|
+
out.set(filePath, linked);
|
|
3531
|
+
}
|
|
3532
|
+
return out;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
// Runners whose file command ends in the path and takes several of them.
|
|
3536
|
+
const TAKES_MANY = new Set(['pytest', 'node-test', 'jest', 'vitest', 'mocha', 'go-test']);
|
|
3537
|
+
// Runners in which a focus in one file holds for every file of the run:
|
|
3538
|
+
// such a file is run by itself, or the others' tests would not run.
|
|
3539
|
+
const FOCUS_SPANS_THE_RUN = new Set(['mocha']);
|
|
3540
|
+
|
|
3541
|
+
/**
|
|
3542
|
+
* The commands that run a set of test files, files of one runner and one
|
|
3543
|
+
* working directory joined where the runner takes several paths.
|
|
3544
|
+
* @returns {string[]}
|
|
3545
|
+
*/
|
|
3546
|
+
function commandsFor(model, filePaths) {
|
|
3547
|
+
const groups = new Map();
|
|
3548
|
+
const single = [];
|
|
3549
|
+
for (const filePath of filePaths) {
|
|
3550
|
+
// The file's own answer decides: a command it withholds is not
|
|
3551
|
+
// offered again here.
|
|
3552
|
+
const selector = model.fileFacts(filePath).run;
|
|
3553
|
+
if (!selector) continue;
|
|
3554
|
+
const runner = model.runnerOf(filePath);
|
|
3555
|
+
const cut = selector.command.lastIndexOf(' ');
|
|
3556
|
+
const last = selector.command.slice(cut + 1);
|
|
3557
|
+
if (!TAKES_MANY.has(runner.id) || cut < 0 || /['"\\]/.test(last) ||
|
|
3558
|
+
(FOCUS_SPANS_THE_RUN.has(runner.id) && model.declared(filePath)?.focus)) {
|
|
3559
|
+
if (!single.includes(selectorText(selector))) single.push(selectorText(selector));
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
const head = selector.command.slice(0, cut);
|
|
3563
|
+
const key = `${selector.cwd || ''}\0${head}\0${model.joinKey(filePath)}`;
|
|
3564
|
+
if (!groups.has(key)) groups.set(key, { cwd: selector.cwd, head, paths: [] });
|
|
3565
|
+
const group = groups.get(key);
|
|
3566
|
+
if (!group.paths.includes(last)) group.paths.push(last);
|
|
3567
|
+
}
|
|
3568
|
+
const joined = [...groups.values()].map(group =>
|
|
3569
|
+
selectorText({ command: `${group.head} ${group.paths.join(' ')}`, ...(group.cwd && { cwd: group.cwd }) }));
|
|
3570
|
+
return [...joined, ...single];
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
/** fnmatch as pytest applies it to a name: `*`, `?`, and `[...]`. */
|
|
3574
|
+
function globMatches(glob, name) {
|
|
3575
|
+
const source = String(glob).replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
3576
|
+
try {
|
|
3577
|
+
return new RegExp(`^${source}$`).test(name);
|
|
3578
|
+
} catch (e) {
|
|
3579
|
+
return false;
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
/** A selector as one shell line. */
|
|
3584
|
+
function selectorText(selector) {
|
|
3585
|
+
if (!selector) return null;
|
|
3586
|
+
return selector.cwd ? `(cd ${shellWord(selector.cwd)} && ${selector.command})` : selector.command;
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
// Declarations whose names at run time are not the written one, or that
|
|
3590
|
+
// exist once for every call of the function around them.
|
|
3591
|
+
const SEVERAL_KINDS = new Set(['generated-cases', 'name-not-literal', 'suite-name-not-literal', 'declared-indirectly']);
|
|
3592
|
+
|
|
3593
|
+
/**
|
|
3594
|
+
* Does one declaration stand for several of the runner's tests: a table
|
|
3595
|
+
* of cases, a parametrized test, a title computed at run time, a
|
|
3596
|
+
* declaration inside a function that may be called more than once.
|
|
3597
|
+
*/
|
|
3598
|
+
function standsForSeveral(unit) {
|
|
3599
|
+
return !!unit.several || SEVERAL_KINDS.has(unit.dynamic);
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
/**
|
|
3603
|
+
* A count of tests as text: "3 tests", "at least 3 tests" when tests
|
|
3604
|
+
* that are not listed may add to it, "(1 conditional)" for the ones that
|
|
3605
|
+
* run only sometimes.
|
|
3606
|
+
*/
|
|
3607
|
+
function testCountText(count, more, conditional) {
|
|
3608
|
+
if (!count) return more ? 'tests not listed' : '';
|
|
3609
|
+
return `${more ? 'at least ' : ''}${count} test${count === 1 ? '' : 's'}${conditional ? ` (${conditional} conditional)` : ''}`;
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
/** A unit's name as people write it: `suite > suite > name`. */
|
|
3613
|
+
function unitLabel(unit) {
|
|
3614
|
+
return [...(unit.suite || []), unit.name == null ? '(computed name)' : unit.name].join(' > ');
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
module.exports = {
|
|
3618
|
+
createTestModel, selectorText, unitLabel, shellWord, globMatches, testCountText, standsForSeveral,
|
|
3619
|
+
addTestCaseRows, linkFile, linkFiles, commandsFor,
|
|
3620
|
+
// Exposed for tests.
|
|
3621
|
+
scriptRunners, nodeTestOptions, configWords, tableText, namesRequirement, focusedOut,
|
|
3622
|
+
shellSplit, configArguments, pytestOptions, markSelects, pathGlob, nodeTestPaths, nodeRunsFile,
|
|
3623
|
+
mochaLoadingOptions, runnerScriptWords,
|
|
3624
|
+
};
|